detached 76ca012

tui: replay scrollback on transcript rewrites

commit
76ca012170c4d7a7678c85d07e80bf74ffadf4a6
author
patriceckhart · · 2026-05-04 12:33 UTC
parent
7ff6942
signature
unsigned
2 files · +24 -13
M internal/agent/modes/interactive.go +7 -1
@@ -373,7 +373,7 @@ func (i *Interactive) Run(ctx context.Context) error {
373373 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
374374 // chat as normal terminal flow/scrollback and redraws only the live
375375 // input/status block on normal typing.
376- _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScrollback + tui.SeqClearScreen + tui.MoveTo(1, 1)))
376+ _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreen + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
377377 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
378378
379379 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -1606,8 +1606,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16061606 case tui.KeyCtrlO:
16071607 // Toggle expansion of collapsed tool results. Affects every tool
16081608 // call in the transcript — press again to re-collapse.
1609+ // In main-screen scrollback mode this changes already-emitted
1610+ // transcript rows, so do a full clear+replay instead of trying
1611+ // to edit old scrollback in place.
16091612 i.mu.Lock()
16101613 i.view.ExpandAll = !i.view.ExpandAll
1614+ if i.rend != nil {
1615+ i.rend.Clear()
1616+ }
16111617 i.mu.Unlock()
16121618 i.invalidate()
16131619 return false
@@ -373,7 +373,7 @@ func (i *Interactive) Run(ctx context.Context) error {
373 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits373 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
374 // chat as normal terminal flow/scrollback and redraws only the live374 // chat as normal terminal flow/scrollback and redraws only the live
375 // input/status block on normal typing.375 // input/status block on normal typing.
376- _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScrollback + tui.SeqClearScreen + tui.MoveTo(1, 1)))376+ _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreen + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
377 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))377 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
378 378
379 // Streaming pacer: drains buffered text deltas at a steady rate379 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -1606,8 +1606,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1606 case tui.KeyCtrlO:1606 case tui.KeyCtrlO:
1607 // Toggle expansion of collapsed tool results. Affects every tool1607 // Toggle expansion of collapsed tool results. Affects every tool
1608 // call in the transcript — press again to re-collapse.1608 // call in the transcript — press again to re-collapse.
1609+ // In main-screen scrollback mode this changes already-emitted
1610+ // transcript rows, so do a full clear+replay instead of trying
1611+ // to edit old scrollback in place.
1609 i.mu.Lock()1612 i.mu.Lock()
1610 i.view.ExpandAll = !i.view.ExpandAll1613 i.view.ExpandAll = !i.view.ExpandAll
1614+ if i.rend != nil {
1615+ i.rend.Clear()
1616+ }
1611 i.mu.Unlock()1617 i.mu.Unlock()
1612 i.invalidate()1618 i.invalidate()
1613 return false1619 return false
M internal/tui/render.go +17 -12
@@ -68,14 +68,18 @@ func (r *Renderer) Resize(cols, rows int) {
6868 }
6969 }
7070
71-// Clear forces a full repaint on the next Draw and clears the screen.
71+// Clear forces a full repaint on the next Draw and clears the screen
72+// plus scrollback. In main-screen flow mode this is required whenever
73+// already-emitted transcript layout changes (for example ctrl+o
74+// expand/collapse), because terminal scrollback cannot be edited
75+// reliably once printed.
7276 func (r *Renderer) Clear() {
7377 r.prev = nil
7478 r.logChat = nil
7579 r.logBottom = nil
7680 r.logInit = false
7781 r.logCursorR = 0
78- _, _ = io.WriteString(r.out, SeqClearScreen)
82+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
7983 }
8084
8185 // Invalidate forces a full repaint on the next Draw without clearing the
@@ -293,6 +297,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
293297 // immediately by the live input/status block. No bottom padding,
294298 // no footer layout.
295299 w.WriteString(SeqClearScreen)
300+ w.WriteString(SeqClearScrollback)
296301 w.WriteString(MoveTo(1, 1))
297302 for _, line := range chatFrame {
298303 w.WriteString("\x1b[0m")
@@ -333,16 +338,16 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
333338 }
334339 } else {
335340 // Transcript changed in place (streaming text grows within the
336- // last assistant block, markdown finalises/reflows, /clear or a
337- // session load replaces content). Do not append a second full copy
338- // to terminal scrollback. We are currently at the top of the old
339- // bottom block, so the old chat block is immediately above us;
340- // move up to its top, erase it in place, and write the replacement
341- // chat before the live bottom block.
342- if len(r.logChat) > 0 {
343- w.WriteString("\x1b[" + itoa(len(r.logChat)) + "A")
344- }
345- eraseRows(&w, len(r.logChat))
341+ // last assistant block, markdown finalises/reflows, ctrl+o
342+ // expand/collapse, /clear, session load). In main-screen flow
343+ // mode old chat lives in terminal scrollback; trying to move up
344+ // and rewrite it is unreliable, especially once images or native
345+ // scrolling are involved. Use the safe path: clear visible screen
346+ // + scrollback, then replay the current transcript once.
347+ w.WriteString(SeqDeleteKittyImages)
348+ w.WriteString(SeqClearScreen)
349+ w.WriteString(SeqClearScrollback)
350+ w.WriteString(MoveTo(1, 1))
346351 for _, line := range chatFrame {
347352 w.WriteString("\x1b[0m")
348353 w.WriteString(SeqClearLine)
@@ -68,14 +68,18 @@ func (r *Renderer) Resize(cols, rows int) {
68 }68 }
69 }69 }
70 70
71-// Clear forces a full repaint on the next Draw and clears the screen.71+// Clear forces a full repaint on the next Draw and clears the screen
72+// plus scrollback. In main-screen flow mode this is required whenever
73+// already-emitted transcript layout changes (for example ctrl+o
74+// expand/collapse), because terminal scrollback cannot be edited
75+// reliably once printed.
72 func (r *Renderer) Clear() {76 func (r *Renderer) Clear() {
73 r.prev = nil77 r.prev = nil
74 r.logChat = nil78 r.logChat = nil
75 r.logBottom = nil79 r.logBottom = nil
76 r.logInit = false80 r.logInit = false
77 r.logCursorR = 081 r.logCursorR = 0
78- _, _ = io.WriteString(r.out, SeqClearScreen)82+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
79 }83 }
80 84
81 // Invalidate forces a full repaint on the next Draw without clearing the85 // Invalidate forces a full repaint on the next Draw without clearing the
@@ -293,6 +297,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
293 // immediately by the live input/status block. No bottom padding,297 // immediately by the live input/status block. No bottom padding,
294 // no footer layout.298 // no footer layout.
295 w.WriteString(SeqClearScreen)299 w.WriteString(SeqClearScreen)
300+ w.WriteString(SeqClearScrollback)
296 w.WriteString(MoveTo(1, 1))301 w.WriteString(MoveTo(1, 1))
297 for _, line := range chatFrame {302 for _, line := range chatFrame {
298 w.WriteString("\x1b[0m")303 w.WriteString("\x1b[0m")
@@ -333,16 +338,16 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
333 }338 }
334 } else {339 } else {
335 // Transcript changed in place (streaming text grows within the340 // Transcript changed in place (streaming text grows within the
336- // last assistant block, markdown finalises/reflows, /clear or a341+ // last assistant block, markdown finalises/reflows, ctrl+o
337- // session load replaces content). Do not append a second full copy342+ // expand/collapse, /clear, session load). In main-screen flow
338- // to terminal scrollback. We are currently at the top of the old343+ // mode old chat lives in terminal scrollback; trying to move up
339- // bottom block, so the old chat block is immediately above us;344+ // and rewrite it is unreliable, especially once images or native
340- // move up to its top, erase it in place, and write the replacement345+ // scrolling are involved. Use the safe path: clear visible screen
341- // chat before the live bottom block.346+ // + scrollback, then replay the current transcript once.
342- if len(r.logChat) > 0 {347+ w.WriteString(SeqDeleteKittyImages)
343- w.WriteString("\x1b[" + itoa(len(r.logChat)) + "A")348+ w.WriteString(SeqClearScreen)
344- }349+ w.WriteString(SeqClearScrollback)
345- eraseRows(&w, len(r.logChat))350+ w.WriteString(MoveTo(1, 1))
346 for _, line := range chatFrame {351 for _, line := range chatFrame {
347 w.WriteString("\x1b[0m")352 w.WriteString("\x1b[0m")
348 w.WriteString(SeqClearLine)353 w.WriteString(SeqClearLine)

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