summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-11 17:07:45 -0300
committerGabriel Schneider <[email protected]>2026-08-12 13:04:32 -0300
commita28c3b71a917dcaae6ba4cd87b356eeab94462c4 (patch)
tree87f1016f28de479258cfc98ed54bda683ca27a95 /src/term_pane.zig
parent24aff2afe3cc827fe1a920a044922aeacd1ff6a3 (diff)
downloadpardes-a28c3b71a917dcaae6ba4cd87b356eeab94462c4.tar.gz
pardes-a28c3b71a917dcaae6ba4cd87b356eeab94462c4.zip
terminal: memoize the motion surface instead of dumping the scrollback per key
Every keystroke in a shell pane rebuilt the motion surface from scratch: shellRows dumped ghostty's WHOLE history+active grid, split it, blanked the prompt rows and handed back slices into the scratch arena, which the next update threw away. A pane sitting on a multi-megabyte agent transcript paid an O(scrollback) dump per press of `j`, and paid it once or twice per key, since flatSurface then rebuilt the same rows joined by '\n' beside it. The dump is now memoized against the pane it was built for (term_pane.RowsCache on Pardes.shell_rows), gpa-owned rather than scratch-arena because the whole point is to outlive the update that built it. One entry, not a table: the surface is built for the pane the cursor is in, and a second pane asking would only double a multi-megabyte buffer for a slot it is about to lose again. A pane that is not the live one is answered from the arena as before. The lifetime rule is the part that would have rotted silently, so it is one rule and it is written down: `rows` is handed out to callers, so everything that notices the entry has gone bad — output arrived, the grid reflowed, the pane died, another pane wants the slot — only marks it `stale`, and the buffers are freed in exactly two places, `sweep` at the TOP of an update before any handler can be holding them, and `reset` when the editor goes away. Nothing frees mid-update. dropPane clears the pointer immediately though: a freed pane's address comes back from the allocator as a different pane, and an entry still naming it would answer for the wrong grid. Two things fall out of having the join already: - flatSurface returns the memo's `text` verbatim when the lines it was handed are the cached rows untouched, instead of rebuilding the join. - paneCursorLines returns `rows` directly when there is no edit buffer, where it used to copy the array one slice at a time to produce exactly what it was given. One bug on the way past, in the same function: an EMPTY edit buffer writes one line but modal.lineCount("") is 0, so `ls` was sized one short of what the loop writes — the same floor the paste site needs. Killing a whole line (`A<C-u>`, `d%`) on a buffer covering the last row made that a length of zero. And test/perf.zig grows the axis that would have caught this: a terminal scoreboard beside the file one, three scrollback fixtures (64 KiB, 1 MiB, 8 MiB — half the ceiling) against render / output / resize-rows / resize-cols / key-down / edit-char, sharing the existing text and JSON reports and the --base comparison. resize-cols and resize-rows are both there because a COLUMN change reflows every page in the list and a row change does not. Measured on that table: key-down is 142 / 630 / 636 us across the three fixtures — flat from 1 MiB to 8 MiB, which is the dump being gone, and render flat at ~110 us throughout. What remains of key-down's step at 1 MiB is the linear scan indexOf refuses to index for a terminal; that is now a ponytail waiver naming its own price (615 us against 140 us) and the threading through paneOff/panePos/paneLineStart it would cost, to be done the day 0.6 ms shows up next to something anybody can feel.
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig118
1 files changed, 109 insertions, 9 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 5e50e15d..9f13832f 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -19,34 +19,134 @@ const EditSnap = pardes.EditSnap;
const Ovl = pardes.Ovl;
const modal = @import("modal.zig");
+/// The memo behind `shellRows`. ONE entry for the editor, because the motion
+/// surface is built for the pane the cursor is in and a second pane asking
+/// would only double a multi-megabyte buffer for a slot it is about to lose
+/// again. gpa-owned rather than scratch-arena: the whole point is to outlive
+/// the update that built it.
+///
+/// LIFETIME, the part that would rot silently: `rows` is handed out to
+/// callers, so the buffers are freed in exactly two places — `sweep`, at the
+/// TOP of an update before any handler can be holding them, and `reset` when
+/// the editor goes away. Everything that notices the entry has gone bad
+/// (output arrived, the grid reflowed, the pane died, another pane wants the
+/// slot) only marks it `stale`; nothing frees mid-update. That is the same
+/// guarantee the scratch arena gave, spelled out.
+pub const RowsCache = struct {
+ /// whose grid this describes; null = the slot is free
+ pane: ?*const Pane = null,
+ /// the rows joined by '\n' — `flatSurface` hands this back verbatim
+ /// instead of rebuilding the join on every keystroke
+ text: []const u8 = &.{},
+ /// slices INTO `text`, absolute grid rows from 0
+ rows: [][]const u8 = &.{},
+ /// `text` is a prefix of this: blanking a prompt row shortens the join,
+ /// and the slack is not worth a second allocation to reclaim
+ text_alloc: []u8 = &.{},
+ stale: bool = false,
+
+ pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void {
+ if (c.text_alloc.len > 0) gpa.free(c.text_alloc);
+ if (c.rows.len > 0) gpa.free(c.rows);
+ c.* = .{};
+ }
+
+ /// Free a stale entry. Called at the top of `update`, and nowhere else.
+ pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void {
+ if (c.stale) c.reset(gpa);
+ }
+
+ /// `pane`'s grid moved: the entry no longer describes it.
+ pub fn markStale(c: *RowsCache, pane: *const Pane) void {
+ if (c.pane == pane) c.stale = true;
+ }
+
+ /// `pane` is being destroyed. Drop the pointer now — a freed pane's
+ /// address can come back from the allocator as a different pane, and an
+ /// entry still naming it would answer for the wrong grid — but leave the
+ /// buffers to the next sweep, as ever.
+ pub fn dropPane(c: *RowsCache, pane: *const Pane) void {
+ if (c.pane != pane) return;
+ c.pane = null;
+ c.stale = true;
+ }
+};
+
+const Rows = struct {
+ text_alloc: []u8,
+ text: []const u8,
+ rows: [][]const u8,
+};
+
/// A terminal's shell rows as the surface sees them: the WHOLE
/// history+active grid, prompt rows blanked (OSC 133), absolute grid rows
/// from 0. The raw material the motion surface is composed from — the
/// edit buffer is NOT applied here, so it is also what seeding the buffer
-/// reads. Scratch-arena backed.
+/// reads.
/// ghostty's dump trims the grid's trailing blank rows; ONE of them is
/// kept back, the row the cursor sits on below the last line of output.
/// That row is a file's final newline: without it the surface would have
/// one line fewer than the same text in a document, and every motion and
/// linewise edit at the bottom would diverge.
-/// ponytail: O(scrollback) dump+scan per keystroke — window it around the
-/// viewport if a multi-MB scrollback ever makes navigation lag.
+///
+/// Building it is O(scrollback) — a dump of the whole history — and a
+/// keystroke asks for it once or twice, so the result is memoized against the
+/// pane until its grid changes. A pane sitting on 16 MiB of agent transcript
+/// paid that dump per press of `j` before the cache; now it pays it once per
+/// chunk of output.
pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
- const arena = p.scratch.allocator();
- const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ const c = &p.shell_rows;
+ if (!c.stale and c.pane == pane) return c.rows;
+ if (c.pane != null) {
+ // Another pane holds the slot. Take it for the NEXT update (the sweep
+ // frees what is there) and answer this one from scratch: whoever owns
+ // the live entry may still be holding the rows it handed out.
+ c.stale = true;
+ return (try buildRows(p.scratch.allocator(), p, pane)).rows;
+ }
+ const built = try buildRows(p.gpa, p, pane);
+ c.* = .{
+ .pane = pane,
+ .text = built.text,
+ .rows = built.rows,
+ .text_alloc = built.text_alloc,
+ };
+ return c.rows;
+}
+
+fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
+ const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
// split yields one more item than delimiters; the extra final slot is the
// cursor row retained below.
- const rows = try arena.alloc([]const u8, std.mem.count(u8, full, "\n") + 2);
+ const n_rows = std.mem.count(u8, full, "\n") + 2;
+ const rows = try alloc.alloc([]const u8, n_rows);
+ errdefer alloc.free(rows);
+ // Blanking a prompt row only ever SHORTENS it and the retained cursor row
+ // adds one separator, so the dump's length plus one bounds the join.
+ const text = try alloc.alloc(u8, full.len + 1);
+ errdefer alloc.free(text);
+ var at: usize = 0;
var n: usize = 0;
var it = std.mem.splitScalar(u8, full, '\n');
while (it.next()) |raw| {
const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
- rows[n] = if (is_prompt) "" else raw;
+ const shown = if (is_prompt) "" else raw;
+ if (n > 0) {
+ text[at] = '\n';
+ at += 1;
+ }
+ @memcpy(text[at..][0..shown.len], shown);
+ rows[n] = text[at..][0..shown.len];
+ at += shown.len;
n += 1;
}
- rows[n] = "";
- return rows;
+ text[at] = '\n';
+ at += 1;
+ rows[n] = text[at..][0..0];
+ n += 1;
+ std.debug.assert(n == n_rows);
+ return .{ .text_alloc = text, .text = text[0..at], .rows = rows };
}
/// The body a terminal renders: the viewport's shell rows (prompt rows blanked