summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 17:14:15 -0300
committerGabriel Schneider <[email protected]>2026-09-06 17:14:15 -0300
commit1852dd3c1247ee66b4265b0a7ee8b3afd08a8fa5 (patch)
tree34f9bfba7439f80dfdd2870c716924f89d4cbf9f /src/pardes.zig
parent8f34d29cb7b480545d25c68a8a6146deefdf0523 (diff)
downloadpardes-1852dd3c1247ee66b4265b0a7ee8b3afd08a8fa5.tar.gz
pardes-1852dd3c1247ee66b4265b0a7ee8b3afd08a8fa5.zip
messages: a fixed log of what the rows said, and a word to read it back
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) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig136
1 files changed, 136 insertions, 0 deletions
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;