detached e6deef9

fix(tui): keep the live viewport aligned while streaming (#208)

DrawLog rescans for addressable rows when the first changed row has
already scrolled above the viewport. That rescan reset lastChanged and
re-applied the appended-row extension only when it found no change of its
own. A frame that grew by an implicit trailing row while an older row
changed elsewhere therefore never emitted that row. The trailing row is
what advances the terminal, so logViewportTop ended up one row short and
left stale content pinned at the top of the screen, which is visible
during fast streaming with frequent tool events.

Apply the growth extension after the rescan too.

Recovery repaints also replayed rows that were already retained in
scrollback. writeFull now starts at the earlier of the new viewport top
and the previous one, so those rows are no longer duplicated while every
row visible in the previous frame is still written. Repaints whose
skipped prefix contains an inline-image escape keep the previous full
replay, because SeqDeleteKittyImages would otherwise drop an image that
still sits in scrollback.
commit
e6deef9af8f6196e5912120362a705252d86ae2d
author
bnema · · 2026-09-14 16:17 UTC
committer
GitHub · · 2026-09-14 16:17 UTC
parent
58133ea
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +200 -4
M packages/tui/render.go +45 -4
@@ -683,7 +683,39 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
683683 w.WriteString(MoveTo(1, 1))
684684 }
685685 }
686- for idx, line := range lines {
686+ // The initial paint emits the whole logical buffer so the terminal's
687+ // retained scrollback receives the full transcript. Recovery repaints
688+ // (cache invalidation, structural shrink) happen while an agent is
689+ // streaming, after earlier rows have already scrolled above the
690+ // viewport. Replaying that prefix pushes retained history back into
691+ // scrollback and duplicates it, so start from the new viewport top.
692+ // Never skip a row that was visible (or never emitted) in the previous
693+ // frame: if the tail starts below the old viewport top, emit from the
694+ // old top instead so no visible content is lost.
695+ //
696+ // The clear above deletes Kitty images, so an inline image can only be
697+ // kept by re-emitting its escape. When the skipped prefix carries an
698+ // image escape, fall back to the full replay rather than delete a
699+ // retained offscreen image without replacement. Image frames keep the
700+ // older duplicate-history tradeoff in exchange for a safe repaint.
701+ emit := lines
702+ if clear && !purgeScrollback && len(lines) > r.rows {
703+ start := len(lines) - r.rows
704+ if start > r.logViewportTop {
705+ start = r.logViewportTop
706+ }
707+ skippedHasImage := false
708+ for _, line := range lines[:start] {
709+ if containsImageEscape(line) {
710+ skippedHasImage = true
711+ break
712+ }
713+ }
714+ if !skippedHasImage {
715+ emit = lines[start:]
716+ }
717+ }
718+ for idx, line := range emit {
687719 if idx > 0 {
688720 w.WriteString("\r\n")
689721 }
@@ -936,9 +968,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
936968 }
937969 // A newly appended blank row compares equal to the implicit empty
938970 // row past the old slice, but it still has to advance the terminal.
939- if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
940- firstChanged = len(r.logLines)
941- lastChanged = len(lines) - 1
971+ // Mirror the growth rule applied before this rescan: extend
972+ // lastChanged whenever the logical buffer grew, regardless of
973+ // whether the rescan already found a visible change. Otherwise an
974+ // appended blank bottom-margin row is never emitted and the
975+ // viewport fails to scroll, shifting the whole frame up by a row.
976+ if len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
977+ if firstChanged == -1 {
978+ firstChanged = len(r.logLines)
979+ }
980+ if lastChanged < len(lines)-1 {
981+ lastChanged = len(lines) - 1
982+ }
942983 }
943984 }
944985 }
@@ -683,7 +683,39 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
683 w.WriteString(MoveTo(1, 1))683 w.WriteString(MoveTo(1, 1))
684 }684 }
685 }685 }
686- for idx, line := range lines {686+ // The initial paint emits the whole logical buffer so the terminal's
687+ // retained scrollback receives the full transcript. Recovery repaints
688+ // (cache invalidation, structural shrink) happen while an agent is
689+ // streaming, after earlier rows have already scrolled above the
690+ // viewport. Replaying that prefix pushes retained history back into
691+ // scrollback and duplicates it, so start from the new viewport top.
692+ // Never skip a row that was visible (or never emitted) in the previous
693+ // frame: if the tail starts below the old viewport top, emit from the
694+ // old top instead so no visible content is lost.
695+ //
696+ // The clear above deletes Kitty images, so an inline image can only be
697+ // kept by re-emitting its escape. When the skipped prefix carries an
698+ // image escape, fall back to the full replay rather than delete a
699+ // retained offscreen image without replacement. Image frames keep the
700+ // older duplicate-history tradeoff in exchange for a safe repaint.
701+ emit := lines
702+ if clear && !purgeScrollback && len(lines) > r.rows {
703+ start := len(lines) - r.rows
704+ if start > r.logViewportTop {
705+ start = r.logViewportTop
706+ }
707+ skippedHasImage := false
708+ for _, line := range lines[:start] {
709+ if containsImageEscape(line) {
710+ skippedHasImage = true
711+ break
712+ }
713+ }
714+ if !skippedHasImage {
715+ emit = lines[start:]
716+ }
717+ }
718+ for idx, line := range emit {
687 if idx > 0 {719 if idx > 0 {
688 w.WriteString("\r\n")720 w.WriteString("\r\n")
689 }721 }
@@ -936,9 +968,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
936 }968 }
937 // A newly appended blank row compares equal to the implicit empty969 // A newly appended blank row compares equal to the implicit empty
938 // row past the old slice, but it still has to advance the terminal.970 // row past the old slice, but it still has to advance the terminal.
939- if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {971+ // Mirror the growth rule applied before this rescan: extend
940- firstChanged = len(r.logLines)972+ // lastChanged whenever the logical buffer grew, regardless of
941- lastChanged = len(lines) - 1973+ // whether the rescan already found a visible change. Otherwise an
974+ // appended blank bottom-margin row is never emitted and the
975+ // viewport fails to scroll, shifting the whole frame up by a row.
976+ if len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
977+ if firstChanged == -1 {
978+ firstChanged = len(r.logLines)
979+ }
980+ if lastChanged < len(lines)-1 {
981+ lastChanged = len(lines) - 1
982+ }
942 }983 }
943 }984 }
944 }985 }
M packages/tui/render_test.go +155 -0
@@ -275,6 +275,161 @@ func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
275275 }
276276 }
277277
278+// TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport is a
279+// regression for the streaming-render corruption where an offscreen
280+// (already scrolled above the viewport) chat row changes while a new
281+// chat row is appended. The renderer must not replay the inaccessible
282+// row, but it must still emit the appended row and let the trailing
283+// bottom-margin blank scroll the viewport so the new tail is visible.
284+//
285+// Previously the rescan triggered by the inaccessible change reset
286+// firstChanged/lastChanged, and the appended-blank extension was only
287+// re-applied when the rescan found no visible change at all. Because the
288+// rescan did find the visible shift, lastChanged stayed short, the
289+// implicit blank bottom margin was never emitted, and the viewport failed
290+// to advance, leaving the frame shifted by one row.
291+func TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport(t *testing.T) {
292+ t.Setenv("TERM_PROGRAM", "")
293+ var buf bytes.Buffer
294+ r := NewRenderer(&buf)
295+ r.Resize(50, 7)
296+
297+ bottom := []string{"STATUS", "editor"}
298+ chat := []string{"row-00", "row-01", "row-02", "row-03", "row-04", "row-05"}
299+ r.DrawLog(chat, bottom, 1, 2)
300+ if want := 2; r.logViewportTop != want {
301+ t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want)
302+ }
303+
304+ // Repeated growth exercises the fix more than once: each round mutates
305+ // a row that has already scrolled above the viewport and appends one
306+ // new chat row, which grows the logical buffer by exactly one row.
307+ appended := []string{"new-00", "new-01"}
308+ for round := 0; round < len(appended); round++ {
309+ buf.Reset()
310+ mutated := append([]string(nil), chat...)
311+ mutated[round] = mutated[round] + "-mutated"
312+ chat = append(mutated, appended[round])
313+ r.DrawLog(chat, bottom, 1, 2)
314+ got := buf.String()
315+
316+ if strings.Contains(got, "-mutated") {
317+ t.Fatalf("round %d replayed an inaccessible mutated row: %q", round, got)
318+ }
319+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
320+ t.Fatalf("round %d cleared retained scrollback: %q", round, got)
321+ }
322+ if !strings.Contains(got, appended[round]) {
323+ t.Fatalf("round %d did not emit appended row %q: %q", round, appended[round], got)
324+ }
325+ if want := len(chat) + len(bottom) + 1 - r.rows; r.logViewportTop != want {
326+ t.Fatalf("round %d viewport top = %d, want %d (blank tail did not scroll)", round, r.logViewportTop, want)
327+ }
328+ if want := len(chat) + len(bottom) - 1; r.logHardwareRow != want {
329+ t.Fatalf("round %d hardware row = %d, want cursor row %d", round, r.logHardwareRow, want)
330+ }
331+ }
332+}
333+
334+// TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback is a
335+// regression for a bottom-band shrink while the transcript is taller than
336+// the viewport. The recovery path must repaint the cleared visible screen
337+// with only the new visible tail; replaying the full logical buffer pushes
338+// transcript rows that are already retained in scrollback back into it,
339+// duplicating history.
340+func TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback(t *testing.T) {
341+ t.Setenv("TERM_PROGRAM", "ghostty")
342+ var buf bytes.Buffer
343+ r := NewRenderer(&buf)
344+ r.Resize(50, 4)
345+
346+ chat := []string{"T1", "T2", "T3", "T4", "T5"}
347+ r.DrawLog(chat, []string{"b1", "b2", "b3", "b4", "b5"}, -1, 0)
348+ buf.Reset()
349+
350+ r.DrawLog(chat, []string{"d1", "d2"}, -1, 0)
351+ got := buf.String()
352+
353+ if strings.Contains(got, SeqClearScrollback) {
354+ t.Fatalf("bottom shrink purged retained scrollback: %q", got)
355+ }
356+ if !strings.Contains(got, SeqClearScreenNoHome) {
357+ t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got)
358+ }
359+ for _, dup := range []string{"T1", "T2", "T3", "T4"} {
360+ if strings.Contains(got, dup) {
361+ t.Fatalf("bottom shrink replayed retained transcript row %q: %q", dup, got)
362+ }
363+ }
364+ for _, want := range []string{"T5", "d1", "d2"} {
365+ if !strings.Contains(got, want) {
366+ t.Fatalf("bottom shrink repaint missing visible row %q: %q", want, got)
367+ }
368+ }
369+ if want := 4; r.logViewportTop != want {
370+ t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
371+ }
372+ if want := 7; r.logHardwareRow != want {
373+ t.Fatalf("hardware row = %d, want %d", r.logHardwareRow, want)
374+ }
375+}
376+
377+// TestDrawLogInvalidationGrowthKeepsHistory guards the scrollback-safe
378+// recovery trim. When the previous frame fit the viewport, nothing has
379+// scrolled into retained scrollback yet, so a following recovery repaint
380+// with a taller buffer must still emit the leading rows (letting them
381+// scroll into history) instead of dropping them.
382+func TestDrawLogInvalidationGrowthKeepsHistory(t *testing.T) {
383+ t.Setenv("TERM_PROGRAM", "")
384+ var buf bytes.Buffer
385+ r := NewRenderer(&buf)
386+ r.Resize(50, 4)
387+
388+ r.DrawLog([]string{"T1", "T2"}, []string{"editor"}, -1, 0)
389+ if want := 0; r.logViewportTop != want {
390+ t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want)
391+ }
392+ buf.Reset()
393+
394+ r.Invalidate()
395+ r.DrawLog([]string{"T1", "T2", "T3", "T4", "T5", "T6"}, []string{"editor"}, -1, 0)
396+ got := buf.String()
397+ for _, want := range []string{"T1", "T2", "T3", "T4", "T5", "T6"} {
398+ if !strings.Contains(got, want) {
399+ t.Fatalf("recovery growth dropped history row %q: %q", want, got)
400+ }
401+ }
402+}
403+
404+// TestDrawLogBottomShrinkKeepsRetainedOffscreenImage is a regression for the
405+// scrollback-safe recovery trim deleting a retained offscreen image. The
406+// clear step deletes Kitty images, so the repaint must not skip an image
407+// escape in the prefix without re-emitting it; image frames fall back to the
408+// full replay instead.
409+func TestDrawLogBottomShrinkKeepsRetainedOffscreenImage(t *testing.T) {
410+ t.Setenv("TERM_PROGRAM", "ghostty")
411+ var buf bytes.Buffer
412+ r := NewRenderer(&buf)
413+ r.Resize(50, 4)
414+
415+ image := "\x1b_Ga=T;payload\x1b\\"
416+ r.DrawLog([]string{image, "a", "b", "c"}, []string{"d", "e"}, -1, 0)
417+ buf.Reset()
418+
419+ r.DrawLog([]string{image, "a", "b", "c"}, []string{"d"}, -1, 0)
420+ got := buf.String()
421+
422+ if !strings.Contains(got, SeqDeleteKittyImages) {
423+ t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got)
424+ }
425+ if !strings.Contains(got, image) {
426+ t.Fatalf("bottom shrink deleted a retained offscreen image without re-emitting it: %q", got)
427+ }
428+ if !strings.Contains(got, "d") {
429+ t.Fatalf("bottom shrink repaint missing bottom row: %q", got)
430+ }
431+}
432+
278433 // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
279434 // cache invalidations, which can happen during an active turn independently
280435 // of an inaccessible changed row.
@@ -275,6 +275,161 @@ func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
275 }275 }
276 }276 }
277 277
278+// TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport is a
279+// regression for the streaming-render corruption where an offscreen
280+// (already scrolled above the viewport) chat row changes while a new
281+// chat row is appended. The renderer must not replay the inaccessible
282+// row, but it must still emit the appended row and let the trailing
283+// bottom-margin blank scroll the viewport so the new tail is visible.
284+//
285+// Previously the rescan triggered by the inaccessible change reset
286+// firstChanged/lastChanged, and the appended-blank extension was only
287+// re-applied when the rescan found no visible change at all. Because the
288+// rescan did find the visible shift, lastChanged stayed short, the
289+// implicit blank bottom margin was never emitted, and the viewport failed
290+// to advance, leaving the frame shifted by one row.
291+func TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport(t *testing.T) {
292+ t.Setenv("TERM_PROGRAM", "")
293+ var buf bytes.Buffer
294+ r := NewRenderer(&buf)
295+ r.Resize(50, 7)
296+
297+ bottom := []string{"STATUS", "editor"}
298+ chat := []string{"row-00", "row-01", "row-02", "row-03", "row-04", "row-05"}
299+ r.DrawLog(chat, bottom, 1, 2)
300+ if want := 2; r.logViewportTop != want {
301+ t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want)
302+ }
303+
304+ // Repeated growth exercises the fix more than once: each round mutates
305+ // a row that has already scrolled above the viewport and appends one
306+ // new chat row, which grows the logical buffer by exactly one row.
307+ appended := []string{"new-00", "new-01"}
308+ for round := 0; round < len(appended); round++ {
309+ buf.Reset()
310+ mutated := append([]string(nil), chat...)
311+ mutated[round] = mutated[round] + "-mutated"
312+ chat = append(mutated, appended[round])
313+ r.DrawLog(chat, bottom, 1, 2)
314+ got := buf.String()
315+
316+ if strings.Contains(got, "-mutated") {
317+ t.Fatalf("round %d replayed an inaccessible mutated row: %q", round, got)
318+ }
319+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
320+ t.Fatalf("round %d cleared retained scrollback: %q", round, got)
321+ }
322+ if !strings.Contains(got, appended[round]) {
323+ t.Fatalf("round %d did not emit appended row %q: %q", round, appended[round], got)
324+ }
325+ if want := len(chat) + len(bottom) + 1 - r.rows; r.logViewportTop != want {
326+ t.Fatalf("round %d viewport top = %d, want %d (blank tail did not scroll)", round, r.logViewportTop, want)
327+ }
328+ if want := len(chat) + len(bottom) - 1; r.logHardwareRow != want {
329+ t.Fatalf("round %d hardware row = %d, want cursor row %d", round, r.logHardwareRow, want)
330+ }
331+ }
332+}
333+
334+// TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback is a
335+// regression for a bottom-band shrink while the transcript is taller than
336+// the viewport. The recovery path must repaint the cleared visible screen
337+// with only the new visible tail; replaying the full logical buffer pushes
338+// transcript rows that are already retained in scrollback back into it,
339+// duplicating history.
340+func TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback(t *testing.T) {
341+ t.Setenv("TERM_PROGRAM", "ghostty")
342+ var buf bytes.Buffer
343+ r := NewRenderer(&buf)
344+ r.Resize(50, 4)
345+
346+ chat := []string{"T1", "T2", "T3", "T4", "T5"}
347+ r.DrawLog(chat, []string{"b1", "b2", "b3", "b4", "b5"}, -1, 0)
348+ buf.Reset()
349+
350+ r.DrawLog(chat, []string{"d1", "d2"}, -1, 0)
351+ got := buf.String()
352+
353+ if strings.Contains(got, SeqClearScrollback) {
354+ t.Fatalf("bottom shrink purged retained scrollback: %q", got)
355+ }
356+ if !strings.Contains(got, SeqClearScreenNoHome) {
357+ t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got)
358+ }
359+ for _, dup := range []string{"T1", "T2", "T3", "T4"} {
360+ if strings.Contains(got, dup) {
361+ t.Fatalf("bottom shrink replayed retained transcript row %q: %q", dup, got)
362+ }
363+ }
364+ for _, want := range []string{"T5", "d1", "d2"} {
365+ if !strings.Contains(got, want) {
366+ t.Fatalf("bottom shrink repaint missing visible row %q: %q", want, got)
367+ }
368+ }
369+ if want := 4; r.logViewportTop != want {
370+ t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
371+ }
372+ if want := 7; r.logHardwareRow != want {
373+ t.Fatalf("hardware row = %d, want %d", r.logHardwareRow, want)
374+ }
375+}
376+
377+// TestDrawLogInvalidationGrowthKeepsHistory guards the scrollback-safe
378+// recovery trim. When the previous frame fit the viewport, nothing has
379+// scrolled into retained scrollback yet, so a following recovery repaint
380+// with a taller buffer must still emit the leading rows (letting them
381+// scroll into history) instead of dropping them.
382+func TestDrawLogInvalidationGrowthKeepsHistory(t *testing.T) {
383+ t.Setenv("TERM_PROGRAM", "")
384+ var buf bytes.Buffer
385+ r := NewRenderer(&buf)
386+ r.Resize(50, 4)
387+
388+ r.DrawLog([]string{"T1", "T2"}, []string{"editor"}, -1, 0)
389+ if want := 0; r.logViewportTop != want {
390+ t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want)
391+ }
392+ buf.Reset()
393+
394+ r.Invalidate()
395+ r.DrawLog([]string{"T1", "T2", "T3", "T4", "T5", "T6"}, []string{"editor"}, -1, 0)
396+ got := buf.String()
397+ for _, want := range []string{"T1", "T2", "T3", "T4", "T5", "T6"} {
398+ if !strings.Contains(got, want) {
399+ t.Fatalf("recovery growth dropped history row %q: %q", want, got)
400+ }
401+ }
402+}
403+
404+// TestDrawLogBottomShrinkKeepsRetainedOffscreenImage is a regression for the
405+// scrollback-safe recovery trim deleting a retained offscreen image. The
406+// clear step deletes Kitty images, so the repaint must not skip an image
407+// escape in the prefix without re-emitting it; image frames fall back to the
408+// full replay instead.
409+func TestDrawLogBottomShrinkKeepsRetainedOffscreenImage(t *testing.T) {
410+ t.Setenv("TERM_PROGRAM", "ghostty")
411+ var buf bytes.Buffer
412+ r := NewRenderer(&buf)
413+ r.Resize(50, 4)
414+
415+ image := "\x1b_Ga=T;payload\x1b\\"
416+ r.DrawLog([]string{image, "a", "b", "c"}, []string{"d", "e"}, -1, 0)
417+ buf.Reset()
418+
419+ r.DrawLog([]string{image, "a", "b", "c"}, []string{"d"}, -1, 0)
420+ got := buf.String()
421+
422+ if !strings.Contains(got, SeqDeleteKittyImages) {
423+ t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got)
424+ }
425+ if !strings.Contains(got, image) {
426+ t.Fatalf("bottom shrink deleted a retained offscreen image without re-emitting it: %q", got)
427+ }
428+ if !strings.Contains(got, "d") {
429+ t.Fatalf("bottom shrink repaint missing bottom row: %q", got)
430+ }
431+}
432+
278 // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for433 // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
279 // cache invalidations, which can happen during an active turn independently434 // cache invalidations, which can happen during an active turn independently
280 // of an inaccessible changed row.435 // of an inaccessible changed row.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close