From 1852dd3c1247ee66b4265b0a7ee8b3afd08a8fa5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 17:14:15 -0300 Subject: messages: a fixed log of what the rows said, and a word to read it back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message row is cleared by the next keystroke, so anything reported while you were looking at another pane was gone before you could read it — a save that failed, a watcher's reload, a builtin's complaint. `setMessage` now records into a fixed ring first: no allocation and no failure path, because it sits underneath `reportError`, which is reached from sites that are reporting an allocation failure. `Messages` (`SPC h m`) reads it back oldest-first. Three things an adversarial pass found, each of which defeated the feature: PROGRESS IS NOT A MESSAGE. A language server emits `Indexing 47%` several times a second, and every tick is a distinct string BY CONSTRUCTION, so no de-duplication can collapse it: at the client's one-per-150ms throttle it takes about nineteen seconds to push every real message out of the ring. A log that one indexing run empties is not a log. That path is `setStatus` now — the row, and nothing else. THE CLOCK MADE EVERY HOST MESSAGE UNIQUE. `message.stamp` prefixes `HH:MM:SS`, so `saved /x.zig` at 14:32:07 and at :09 compared unequal and the ring filled with rows that look identical and each say (x1) — exactly the case the de-duplication exists for. It compares `message.body` now, the row without its clock, and the newest wording wins so the row carries the last time it happened rather than the first. It also keys on the PANE (one pane's failure must not be recorded as another's) and compares the truncated form, so two identical messages over 256 bytes stop being two rows. AND THE CAPACITY BELONGS IN limits.zig. 128 entries is 32.75 KiB that is allocated whether or not anybody reads it — 8.5% of the ESP32-P4's whole 384 KiB heap, about the size of its effect ring. The board takes sixteen. The builtins/leader goldens move because the listing gains a row, and builtins.snap middle-clicks a SCREEN COORDINATE that Tutor moved out of; both updated selectively and verified against a fresh run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf --- src/builtins.zig | 11 +++++ src/config.zig | 5 ++ src/gui/gui.zig | 2 +- src/limits.zig | 10 ++++ src/macos.zig | 2 +- src/message.zig | 19 ++++++++ src/output_pane.zig | 17 +++++++ src/pardes.zig | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/tty/tty.zig | 2 +- 9 files changed, 201 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/builtins.zig b/src/builtins.zig index 40a5083c..f7822caf 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -738,6 +738,17 @@ pub const Config = struct { } }; +/// Read back what the message rows said. A message row is cleared by the next +/// keystroke, so anything reported while you were looking at another pane was +/// gone before you could read it — a failed save, a watcher's reload, a +/// builtin's complaint. +pub const Messages = struct { + pub const output: OutputTraits = .{ .name = config.messages_buffer }; + pub fn run(c: Ctx) void { + output_pane.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); + } +}; + /// This build's version and what changed to reach it, printed into an output /// buffer the same way Config prints the live settings. pub const Changelog = struct { diff --git a/src/config.zig b/src/config.zig index 522ae76f..331cb4ab 100644 --- a/src/config.zig +++ b/src/config.zig @@ -147,6 +147,10 @@ pub const leader_path = paths: { .Config = "fc", .Tutor = "ht", .Changelog = "hc", + // `Messages` joins the help group because it answers the same kind of + // question they do — "what did that say?" — about lines that have + // already left the screen. + .Messages = "hm", .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", @@ -901,6 +905,7 @@ pub const pdf_sections_buffer = "+PdfSections"; pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; +pub const messages_buffer = "+Messages"; /// What `9p ` opens a remote file into. NOT the remote path: an /// output buffer's name comes off the command that filled it, and the path is /// the command's ARGUMENT, which is what makes two remote files two panes. diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b31fb5f6..3876a222 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3614,7 +3614,7 @@ const Shell = struct { // session news, same row and same stamp a completed save uses. .lsp_status => |text| { var mbuf: [256]u8 = undefined; - s.core.setMessage(s.core.active, message.stamp(&mbuf, "lsp", text)); + s.core.setStatus(s.core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_allocator.free(text); s.saw_event = true; }, diff --git a/src/limits.zig b/src/limits.zig index 75bf5262..ee94a4f6 100644 --- a/src/limits.zig +++ b/src/limits.zig @@ -116,6 +116,16 @@ pub const cwd_buf_cap = if (board) 0 else 1024; /// deepest undo steps and nothing else — no truncation, no dropped edit. pub const undo_max = if (board) 16 else 256; +/// How many message-row lines the session keeps for `Messages`, and one of the +/// bigger fixed costs on `Pardes`: an entry is 262 bytes, so 128 of them is +/// 32.75 KiB that is allocated whether or not anybody ever reads it. That is +/// 8.5% of the board's whole 384 KiB heap and about the size of its effect +/// ring, so the board takes sixteen — enough that a failure you looked away +/// from is still there, which is the whole point, and not enough to matter +/// beside the panes. This belongs here rather than in config.zig for exactly +/// the reason the file's header gives: it is a board-shaped capacity. +pub const message_log = if (board) 16 else 128; + /// Bounds the only user-editable, schema-owned tag fragment. It IS the storage /// bound: `Pane.tag_tail` is `[max_tag_tail]u8`, and every writer (appendTag, /// tagInsert, restoreDumpTail, the acmefs `tag` file) refuses input that does diff --git a/src/macos.zig b/src/macos.zig index c50d64cf..ddab78e9 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1404,7 +1404,7 @@ fn drainInbox(st: *State) bool { // about whichever pane happened to ask. .lsp_status => |text| { var mbuf: [256]u8 = undefined; - st.core.setMessage(st.core.active, message.stamp(&mbuf, "lsp", text)); + st.core.setStatus(st.core.active, message.stamp(&mbuf, "lsp", text)); }, // The filter's answer, then join the worker that produced it. // diff --git a/src/message.zig b/src/message.zig index 0935ce96..0855c448 100644 --- a/src/message.zig +++ b/src/message.zig @@ -43,6 +43,25 @@ extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; /// clock cannot live in a golden — but a message that vanished under the /// harness could not be pinned at all, so the row still renders, still says /// what happened, and still occupies the same columns. +/// The stamped text WITHOUT its clock — what two of these rows have in common +/// when they say the same thing at different times. +/// +/// The message log de-duplicates on this rather than on the whole row, because +/// the clock makes every host message unique by construction: `saved x` at +/// 14:32:07 and at 14:32:09 are different strings, so a watched file rebuilt +/// in a loop filled the ring with identical-looking rows, each counted once. +/// That is exactly the case the de-duplication exists for. +pub fn body(text: []const u8) []const u8 { + // `HH:MM:SS ` — ten bytes, digits or the `--:--:--` the harness blanks + // them to. Anything else is a message that was never stamped. + if (text.len < 10) return text; + if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; + for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { + if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; + } + return text[10..]; +} + pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { var clock: [8]u8 = "--:--:--".*; const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; diff --git a/src/output_pane.zig b/src/output_pane.zig index 288c3e43..f4721136 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -519,6 +519,23 @@ pub fn openConfig(p: *Pardes, id: usize) !void { return openRead(p, id, .{ .cmd = .Config }, "", content); } +/// The message-row log, oldest first — the lines that were said in passing and +/// then cleared by the next keystroke. +pub fn openMessages(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + var i: usize = 0; + while (p.messageLog(i)) |m| : (i += 1) { + if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); + try out.writer.writeAll(m.slice()); + if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); + try out.writer.writeByte('\n'); + } + if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Messages }, "", content); +} + /// The version banner plus the embedded CHANGELOG, so an installed binary can /// say what it is and what changed without a repository beside it. pub fn openChangelog(p: *Pardes, id: usize) !void { diff --git a/src/pardes.zig b/src/pardes.zig index b87a5d6a..357a6b13 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -40,6 +40,7 @@ const runtime_cfg = @import("runtime_config.zig"); /// Every board-shaped capacity, in one table keyed on a profile rather than on /// the platform. See src/limits.zig. const limits = @import("limits.zig"); +const message = @import("message.zig"); const selection_pipe = @import("selection_pipe.zig"); /// acme's control filesystem, as a pure transaction over this core: the FILES /// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The @@ -700,6 +701,50 @@ test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" try std.testing.expectEqual(@as(i32, 1), pane.cur_row); } +test "the message log keeps what the row forgets, and collapses repeats" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + + p.setMessage(0, "14:32:07 saved /x.zig"); + p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock + p.setMessage(0, "save: AccessDenied"); + + // Two entries, not three: the clock does not make a message new. This is + // the case the de-duplication exists for and the one it used to miss, + // because `message.stamp` makes every host message unique by construction. + try std.testing.expectEqual(@as(usize, 2), p.messages_len); + const first = p.messageLog(0).?; + try std.testing.expectEqual(@as(u16, 2), first.repeats); + // ...and the NEWEST wording is what survives, so the row carries the last + // time it happened rather than the first. + try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice()); + try std.testing.expectEqualStrings("save: AccessDenied", p.messageLog(1).?.slice()); + try std.testing.expect(p.messageLog(2) == null); + + // The same text from a DIFFERENT pane is a different event: one pane's + // failure must not be recorded as another's. + p.setMessage(1, "save: AccessDenied"); + try std.testing.expectEqual(@as(usize, 3), p.messages_len); + try std.testing.expectEqual(@as(u8, 1), p.messageLog(2).?.pane); + + // Progress is NOT logged: it arrives several times a second for a whole + // index and would push everything else out (`setStatus`). + p.setStatus(0, "14:32:10 lsp rust-analyzer: Indexing 47%"); + try std.testing.expectEqual(@as(usize, 3), p.messages_len); + + // The ring wraps rather than grows, and still reads oldest-first. + for (0..limits.message_log + 5) |i| { + var buf: [32]u8 = undefined; + p.setMessage(0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable); + } + try std.testing.expectEqual(@as(usize, limits.message_log), p.messages_len); + try std.testing.expectEqualStrings("line 5", p.messageLog(0).?.slice()); + var last_buf: [32]u8 = undefined; + const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable; + try std.testing.expectEqualStrings(want_last, p.messageLog(limits.message_log - 1).?.slice()); +} + test "the acme chords act once per selection, not once on the primary" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -6132,6 +6177,24 @@ fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { }; } +/// One line that was said on a message row. Fixed storage so the log cannot +/// fail: `setMessage` is reached from `reportError`, which is reached from +/// paths that are reporting an allocation failure. +pub const LoggedMessage = struct { + pub const cap = 256; + text: [cap]u8 = undefined, + len: u16 = 0, + /// Which pane said it, or 0xff for one said to no pane in particular. + pane: u8 = 0xff, + /// How many times in a row. Saturating: a key held down against the same + /// refusal is one event, not four hundred. + repeats: u16 = 0, + + pub fn slice(m: *const LoggedMessage) []const u8 { + return m.text[0..m.len]; + } +}; + const PendingPipe = struct { id: u32, pane: usize, @@ -6476,6 +6539,12 @@ pub const Pardes = struct { /// The in-program answers behind every unimplemented host method. Per /// instance, so several cores behind one fan-out host stay independent. fallback: Fallback, + /// The message-row log: a fixed ring, never allocated, never grown. See + /// `logMessage` and the `Messages` builtin. + messages: [limits.message_log]LoggedMessage = @splat(.{}), + /// Next slot to write. `messages_len` saturates at the ring's size. + messages_head: usize = 0, + messages_len: usize = 0, /// Input the loop has not consumed yet. Single-threaded: a host's worker /// threads keep their own thread-safe inbox and post from the loop thread, /// which is what keeps this ring lock-free. @@ -7214,12 +7283,79 @@ pub const Pardes = struct { /// mouse event — and nothing here knows whether the row is free: an armed /// prompt simply outranks a message at render time, so a message posted /// under one is stored and invisible rather than refused. + /// A message row that is NOT worth remembering: unsolicited progress from a + /// language server, which arrives several times a second for the whole of a + /// large index. + /// + /// `rust-analyzer: Indexing 47% core` is a different string every tick by + /// construction, so no de-duplication can collapse it, and at the client's + /// throttle of one per 150ms per server it takes about NINETEEN SECONDS to + /// push every save, error and reload out of a 128-entry ring. A log that + /// one indexing run empties is not a log. Progress belongs on the row, + /// where it is read as it happens and then replaced; the log is for things + /// that were said once. + pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { + p.showMessage(id, text); + } + pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { + // LOGGED FIRST, and logged even when the pane is gone. A message row + // is cleared by the next keystroke (see `clearMessages`), so anything + // said while the user was looking elsewhere — a save that failed, a + // watcher's reload, a builtin's complaint — used to be unrecoverable + // the instant it appeared. `Messages` reads this back. + p.logMessage(id, text); + p.showMessage(id, text); + } + + fn showMessage(p: *Pardes, id: usize, text: []const u8) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; pane.msg_len = @intCast(@min(text.len, pane.msg.len)); @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } + + /// Append to the ring, oldest overwritten. No allocation and no failure: + /// this sits under every `reportError` in the program, including the ones + /// raised because an allocation just failed. + fn logMessage(p: *Pardes, id: usize, text: []const u8) void { + if (text.len == 0) return; + const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff; + // What is STORED is truncated to the slot, so what is COMPARED must be + // too: comparing a 300-byte message against its own 256-byte record + // never matched, and two identical long messages each got their own + // row. And the comparison is on `message.body` — the row without its + // clock — because a stamp makes every host message unique by + // construction, which defeated this entirely for `saved`/`reloaded`. + const kept = text[0..@min(text.len, LoggedMessage.cap)]; + if (p.messages_len > 0) { + const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log]; + if (last.pane == pane and + std.mem.eql(u8, message.body(last.slice()), message.body(kept))) + { + // The NEWEST wording wins, so the row carries the latest clock + // rather than the moment the run started. + last.len = @intCast(kept.len); + @memcpy(last.text[0..last.len], kept); + last.repeats +|= 1; + return; + } + } + const slot = &p.messages[p.messages_head]; + slot.len = @intCast(kept.len); + @memcpy(slot.text[0..slot.len], kept); + slot.pane = pane; + slot.repeats = 1; + p.messages_head = (p.messages_head + 1) % limits.message_log; + p.messages_len = @min(p.messages_len + 1, limits.message_log); + } + + /// The log oldest-first, which is reading order. + pub fn messageLog(p: *const Pardes, i: usize) ?*const LoggedMessage { + if (i >= p.messages_len) return null; + const first = (p.messages_head + limits.message_log - p.messages_len) % limits.message_log; + return &p.messages[(first + i) % limits.message_log]; + } pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index ff86ef88..fb0a5b8e 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1151,7 +1151,7 @@ const Shell = struct { // server is session news, not a fact about the pane that asked. .lsp_status => |text| { var mbuf: [256]u8 = undefined; - core.setMessage(core.active, message.stamp(&mbuf, "lsp", text)); + core.setStatus(core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_gpa.free(text); }, .pipe_done => |response_value| { -- cgit v1.3