Skip to content

Commit f5524ef

Browse files
emil-eclaude
authored andcommitted
Fix scrollback correctness: rebuild_pending flag + unified erase path
Replace the semantically incorrect surgical trim logic with a unified scrollback-validity mechanism: - Rename `resize_pending` and `scroll_cleared` flags to a single `rebuild_pending` bool. Both `terminal.resize()` and a new CSI 3J scanner in `fnWriteInput` set this flag; only the redraw pass resets it and owns all associated state (scrollback_in_buffer, first row hash). - Scan raw vtWrite input for CSI 3J (`\x1B[3J`). libghostty clears its scrollback on this sequence but provides no callback. The flag is necessary because CSI 3J followed by enough new output to restore the same scrollback depth is invisible to both count-based and hash-based detection. - Replace four scattered erase paths (resize_pending block, rotation hash mismatch, surgical trim fallback, scrollback_cleared check) with a single `scrollback_stale` local that collects all invalidation signals and erases once. The rotation hash check is skipped when the flag is already set (avoiding a redundant viewport scroll). - Remove the surgical trim (delete top N lines when libghostty scrollback shrinks). It assumed rows shrink only by eviction with the rest intact, which is wrong for CSI 3J and for reflow on resize. A full erase is correct in all cases and no more expensive in practice. - Add ERT test `ghostel-test-scrollback-csi3j-then-refill` covering the previously undetectable scenario: CSI 3J followed by enough new output to restore the original scrollback depth must not leave stale rows. Closes #160. Supersedes #166. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fdfb68f commit f5524ef

4 files changed

Lines changed: 125 additions & 83 deletions

File tree

src/module.zig

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ fn fnWriteInput(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*
165165
// pair split across two writes — chunk A ending with \r, chunk B
166166
// starting with \n — is not mis-normalized into \r\r\n. The final
167167
// value is persisted back for the next call. An empty input
168+
168169
// round-trips the flag unchanged.
169170
var seg_start: usize = 0;
170171
var prev_was_cr: bool = term.last_input_was_cr;
@@ -183,6 +184,23 @@ fn fnWriteInput(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*
183184
}
184185
term.last_input_was_cr = prev_was_cr;
185186

187+
// Detect CSI 3 J (erase scrollback). libghostty processes it and clears
188+
// its scrollback, but provides no notification. Set rebuild_pending so the
189+
// next redraw erases the Emacs buffer and re-fetches from libghostty.
190+
// The flag is necessary because CSI 3 J followed by enough new output to
191+
// restore the same scrollback depth is undetectable by count or hash alone.
192+
//
193+
// This is a stateless substring scan over a single write, so it misses:
194+
// (1) sequences split across two writes (ESC in one, "[3J" in the next);
195+
// (2) non-canonical forms — parameter padding like `\x1B[03J`, or the
196+
// 8-bit C1 form `\x9B3J`.
197+
// libghostty's stateful VT parser still clears its scrollback in those
198+
// cases, so the `libghostty_sb < scrollback_in_buffer` shrink check in
199+
// redraw() is the fallback that catches them.
200+
if (std.mem.indexOf(u8, raw, "\x1B[3J") != null) {
201+
term.rebuild_pending = true;
202+
}
203+
186204
// Scan for OSC sequences that libghostty-vt discards (7, 51, 52, 133).
187205
// One pass, dispatched by code in document order.
188206
dispatchPostWriteOscs(env, term, raw);
@@ -624,7 +642,7 @@ fn fnSetSize(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*any
624642
return env.nil();
625643
};
626644
// Reflow invalidates the materialized scrollback — terminal.resize()
627-
// sets resize_pending so the next redraw() erases and rebuilds under
645+
// sets rebuild_pending so the next redraw() erases and rebuilds under
628646
// inhibit-redisplay, avoiding a visible blank frame.
629647

630648
return env.nil();

src/render.zig

Lines changed: 46 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,6 @@ fn insertScrollbackRange(
819819
return inserted;
820820
}
821821

822-
823822
/// Convert a terminal column to an Emacs character offset by iterating
824823
/// the row's cells. Returns `true` and positions point on success;
825824
/// `false` if the cell data is unavailable (caller should fall back to
@@ -891,8 +890,8 @@ fn positionCursorByCell(env: emacs.Env, term: *Terminal, cx: u16, cy: u16) bool
891890
/// On each call we:
892891
/// 1. Force libghostty's viewport to the bottom (active screen).
893892
/// 2. Poll `getTotalRows()` against `term.scrollback_in_buffer` to
894-
/// detect rows that scrolled off the top (append to buffer) or
895-
/// rows evicted by libghostty's scrollback cap (trim from buffer).
893+
/// detect rows that scrolled off the top of the viewport and promoted them
894+
/// to the scrollback part of the buffer
896895
/// 3. Render the viewport into the tail of the buffer, anchored at
897896
/// the line that follows the last scrollback row.
898897
///
@@ -936,17 +935,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
936935
return;
937936
}
938937

939-
// ---- Deferred resize buffer reset ----------------------------------------
940-
// terminal.resize() sets resize_pending instead of immediately erasing the
941-
// buffer, so the old frame stays visible until this redraw replaces it
942-
// (the caller binds inhibit-redisplay around us). Erase now and force a
943-
// full rebuild of scrollback + viewport.
944-
if (term.resize_pending) {
945-
env.eraseBuffer();
946-
term.resize_pending = false;
947-
force_full = true;
948-
}
949-
950938
// Resolve default colors once — used for both the scrollback append
951939
// path and the viewport render path. These always succeed (the
952940
// render state always has resolved default colors), so batching is safe.
@@ -964,45 +952,62 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
964952
_ = gt.c.ghostty_render_state_get_multi(term.render_state, color_keys.len, &color_keys, @ptrCast(&color_values), null);
965953
}
966954

967-
// ---- Scrollback rotation detection ------------------------------------
968-
// When libghostty's scrollback is at its byte cap, sustained writes
969-
// evict the oldest rows and push new ones, so the row at scrollback
970-
// index 0 changes underneath us. The normal delta-sync below tracks
971-
// `total_rows` deltas, but those don't capture content rotation —
972-
// if the count is unchanged (or even shrinking) the trim path would
973-
// remove our top rows under the *assumption* they match the rows
974-
// libghostty just evicted, which isn't true after rotation.
955+
// ---- Scrollback validity ------------------------------------------------
956+
// Two signals can invalidate buffered scrollback and are collected here;
957+
// a third (rotation) is checked below.
975958
//
976-
// Detect rotation by hashing the first scrollback row whenever
977-
// writes have happened since the last redraw and we have scrollback.
978-
// A change means the top row is no longer the row we materialized
979-
// → wipe the buffer and let the delta-sync below re-fetch everything
980-
// fresh from libghostty.
959+
// rebuild_pending: set by terminal.resize() and by the CSI 3 J scanner
960+
// in vtWrite. Defers the Emacs-buffer erase into the
961+
// redraw pass where inhibit-redisplay prevents a
962+
// visible blank frame.
981963
//
982-
// When the hash matches (no rotation), stash it in `cached_row0_hash`
983-
// and reuse at end-of-redraw. Promotion / insert-at-tail don't shift
984-
// row 0, so the start-of-redraw hash is still valid. Trim and
985-
// rotation-erase invalidate the cache.
964+
// rotation: checked below via a row-0 hash; only computed when
965+
// rebuild_pending hasn't already fired.
966+
var scrollback_stale = term.rebuild_pending;
967+
term.rebuild_pending = false;
968+
969+
// ---- Rotation detection ------------------------------------------------
970+
// When libghostty's scrollback cap is saturated, sustained writes evict
971+
// the oldest rows. total_rows doesn't change, so the delta-sync below
972+
// would see nothing to do — detect the churn by hashing the first
973+
// scrollback row and comparing to the value sampled at the last redraw.
974+
//
975+
// Skip when scrollback is already known to be stale: the viewport scroll
976+
// that sampling requires is wasted work if we are about to erase anyway.
977+
// On a hash match, stash the value for reuse at end-of-redraw (promotion
978+
// and insert-at-tail don't shift row 0, so it stays valid).
986979
var cached_row0_hash: ?u64 = null;
987-
if (term.wrote_since_redraw and term.scrollback_in_buffer > 0 and term.first_scrollback_row_hash != 0) {
980+
if (!scrollback_stale and
981+
term.wrote_since_redraw and
982+
term.scrollback_in_buffer > 0 and
983+
term.first_scrollback_row_hash != 0)
984+
{
988985
const new_hash = computeFirstScrollbackRowHash(term);
989986
// computeFirstScrollbackRowHash scrolled libghostty's viewport to
990-
// sample row 0 and the defer restored the offset, but the render
991-
// state may now be stale — refresh it before continuing.
987+
// sample row 0; the render state is now stale — refresh it.
992988
if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return;
993989
if (new_hash != term.first_scrollback_row_hash) {
994-
// Rotation detected — erase the buffer entirely and force a
995-
// full viewport render. The delta-sync below will then see
996-
// libghostty_sb - 0 = libghostty_sb and refetch everything.
997-
env.eraseBuffer();
998-
term.scrollback_in_buffer = 0;
999-
term.first_scrollback_row_hash = 0;
1000-
force_full = true;
990+
scrollback_stale = true;
1001991
} else {
1002992
cached_row0_hash = new_hash;
1003993
}
1004994
}
1005995

996+
// Compare counts: scrollback shrinking means the buffered rows are no
997+
// longer valid (CSI 3 J or resize would have set rebuild_pending, but
998+
// this catches any other unexpected reduction).
999+
const total_rows = term.getTotalRows();
1000+
const libghostty_sb: usize = if (total_rows > term.rows) total_rows - term.rows else 0;
1001+
if (libghostty_sb < term.scrollback_in_buffer) scrollback_stale = true;
1002+
1003+
// If scrollback is stale for any reason, erase it completely.
1004+
if (scrollback_stale) {
1005+
env.eraseBuffer();
1006+
term.scrollback_in_buffer = 0;
1007+
term.first_scrollback_row_hash = 0;
1008+
force_full = true;
1009+
}
1010+
10061011
// ---- Scrollback sync ---------------------------------------------------
10071012
// libghostty stores scrollback + active screen in a single row space.
10081013
// The rows "above" the viewport are scrollback; our invariant is that
@@ -1012,8 +1017,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
10121017
// by walking from point-min and reusing point() after any insert/trim
10131018
// touches it. forwardLine is O(scrollback) so doing it twice would
10141019
// double the per-redraw cost in long-running sessions.
1015-
const total_rows = term.getTotalRows();
1016-
const libghostty_sb: usize = if (total_rows > term.rows) total_rows - term.rows else 0;
10171020

10181021
// Walk to the current viewport start (line scrollback_in_buffer + 1).
10191022
env.gotoCharN(1);
@@ -1102,31 +1105,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void {
11021105
term.scrollViewport(gt.SCROLL_BOTTOM, 0);
11031106
if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return;
11041107
}
1105-
} else if (libghostty_sb < term.scrollback_in_buffer) {
1106-
// libghostty's scrollback cap evicted the oldest rows — trim the
1107-
// same number of lines from the top of the buffer. Trim shifts
1108-
// row 0 so the start-of-redraw hash is stale.
1109-
cached_row0_hash = null;
1110-
const delta = term.scrollback_in_buffer - libghostty_sb;
1111-
env.gotoCharN(1);
1112-
if (env.forwardLine(@as(i64, @intCast(delta))) == 0) {
1113-
env.deleteRegion(env.makeInteger(1), env.point());
1114-
term.scrollback_in_buffer -= delta;
1115-
// After the delete, the new viewport start has shifted down.
1116-
// forwardLine is already at the right line (point-min + delta
1117-
// lines was the next surviving row, now line 1+scrollback_in_buffer).
1118-
// Recompute by walking the remaining scrollback rows.
1119-
} else {
1120-
// Ran off the end — buffer is out of sync; rebuild from scratch.
1121-
env.eraseBuffer();
1122-
term.scrollback_in_buffer = 0;
1123-
force_full = true;
1124-
}
1125-
env.gotoCharN(1);
1126-
if (term.scrollback_in_buffer > 0) {
1127-
_ = env.forwardLine(@as(i64, @intCast(term.scrollback_in_buffer)));
1128-
}
1129-
viewport_start_int = env.extractInteger(env.point());
11301108
}
11311109

11321110
// Check dirty state — cells are only redrawn when dirty, but cursor

src/terminal.zig

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,10 @@ scrollback_in_buffer: usize = 0,
4242
/// activity" from "writes happened but total_rows plateaued".
4343
wrote_since_redraw: bool = false,
4444

45-
/// Set by `resize`, cleared at the start of `redraw`. When true, the
46-
/// next redraw will erase the buffer and force a full rebuild. This
47-
/// defers the buffer erasure from the synchronous resize call (which
48-
/// would leave the buffer visibly empty until the timer-driven redraw)
49-
/// into the redraw pass where `inhibit-redisplay` prevents flicker.
50-
resize_pending: bool = false,
45+
/// When true, the next redraw erases the Emacs buffer,
46+
/// resets scrollback state, and forces a full rebuild. The erase is deferred
47+
/// so `inhibit-redisplay` can prevent a visible blank frame.
48+
rebuild_pending: bool = false,
5149

5250
/// True iff the last byte of the previous `fnWriteInput` input was
5351
/// `\r`. Carries the bare-LF detection state across write-input calls
@@ -224,20 +222,16 @@ pub fn vtWrite(self: *Self, data: []const u8) void {
224222

225223
/// Resize the terminal.
226224
///
227-
/// Resets `scrollback_in_buffer` because libghostty reflows wrapped rows
228-
/// on resize and the row count above the viewport no longer matches what
229-
/// we have in the Emacs buffer. Sets `resize_pending` so the next
230-
/// `redraw()` erases the buffer under `inhibit-redisplay` and rebuilds
231-
/// scrollback from scratch — avoiding a visible blank frame.
225+
/// Also sets `rebuild_pending` so the next `redraw()` erases the Emacs buffer
226+
/// under `inhibit-redisplay` and rebuilds scrollback from scratch — avoiding
227+
/// a visible blank frame between the resize and the first repaint.
232228
pub fn resize(self: *Self, cols: u16, rows: u16) !void {
233229
if (gt.c.ghostty_terminal_resize(self.terminal, cols, rows, 1, 1) != gt.SUCCESS) {
234230
return error.ResizeFailed;
235231
}
236232
self.cols = cols;
237233
self.rows = rows;
238-
self.scrollback_in_buffer = 0;
239-
self.first_scrollback_row_hash = 0;
240-
self.resize_pending = true;
234+
self.rebuild_pending = true;
241235
self.last_input_was_cr = false;
242236
}
243237

test/ghostel-test.el

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,58 @@ scrolling libghostty's viewport."
501501
(should-not (string-match-p "line [0-9]" content)))))
502502
(kill-buffer buf))))
503503

504+
(ert-deftest ghostel-test-scrollback-csi3j-then-refill ()
505+
"CSI 3 J must not leave stale pre-clear rows in the buffer.
506+
507+
Scenario (5-row terminal, 10 before-* rows, CSI 3J, 5 after-* rows,
508+
single redraw):
509+
- After the first redraw: before-00..before-05 are in scrollback (6
510+
rows scrolled off), before-06..before-09 fill the viewport.
511+
- CSI 3J clears libghostty's scrollback; the viewport is unchanged.
512+
- Five new after-* rows scroll before-06..before-09 and after-00 into
513+
libghostty's freshly-cleared scrollback (5 rows); after-01..after-04
514+
are left in the viewport.
515+
- At the next redraw, libghostty_sb (5) < scrollback_in_buffer (6),
516+
so count-based detection triggers a full rebuild. The rebuild_pending
517+
flag (set by the CSI 3J scanner in vtWrite) also fires, ensuring the
518+
erase happens even in scenarios where counts happen to match."
519+
(let ((buf (generate-new-buffer " *ghostel-test-csi3j-refill*")))
520+
(unwind-protect
521+
(with-current-buffer buf
522+
(let* ((term (ghostel--new 5 80 1000))
523+
(inhibit-read-only t))
524+
;; Phase 1: fill scrollback with 10 "before" rows and redraw.
525+
(dotimes (i 10)
526+
(ghostel--write-input term (format "before-%02d\r\n" i)))
527+
(ghostel--redraw term t)
528+
;; Confirm before-00..before-05 are now in the buffer's scrollback
529+
;; and before-06..before-09 are in the viewport.
530+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
531+
(should (string-match-p "before-00" content))
532+
(should (string-match-p "before-05" content))
533+
(should (string-match-p "before-09" content)))
534+
;; Phase 2: CSI 3 J (erase scrollback only) then immediately
535+
;; write 5 "after" rows — no redraw in between. before-06..before-09
536+
;; scroll off into libghostty's freshly-cleared scrollback as the
537+
;; after-* rows push through the viewport.
538+
(ghostel--write-input term "\e[3J")
539+
(dotimes (i 5)
540+
(ghostel--write-input term (format "after-%02d\r\n" i)))
541+
;; Phase 3: single redraw — must rebuild from libghostty.
542+
(ghostel--redraw term t)
543+
(let ((content (buffer-substring-no-properties (point-min) (point-max))))
544+
;; Rows that were in scrollback when CSI 3J fired are gone.
545+
(should-not (string-match-p "before-00" content))
546+
(should-not (string-match-p "before-05" content))
547+
;; Rows that were in the viewport during CSI 3J are now in
548+
;; libghostty's new scrollback and must be present.
549+
(should (string-match-p "before-06" content))
550+
(should (string-match-p "before-09" content))
551+
;; after-00 scrolled into scrollback; after-01..after-04 in viewport.
552+
(should (string-match-p "after-00" content))
553+
(should (string-match-p "after-04" content)))))
554+
(kill-buffer buf))))
555+
504556
;; -----------------------------------------------------------------------
505557
;; Test: SGR styling (bold, color, etc.)
506558
;; -----------------------------------------------------------------------

0 commit comments

Comments
 (0)