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 { | |||
| 373 | 373 | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | |
| 374 | 374 | // chat as normal terminal flow/scrollback and redraws only the live | |
| 375 | 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 | 377 | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | |
| 378 | 378 | ||
| 379 | 379 | // 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 | 1606 | case tui.KeyCtrlO: | |
| 1607 | 1607 | // Toggle expansion of collapsed tool results. Affects every tool | |
| 1608 | 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 | 1612 | i.mu.Lock() | |
| 1610 | 1613 | i.view.ExpandAll = !i.view.ExpandAll | |
| 1614 | + | if i.rend != nil { | |
| 1615 | + | i.rend.Clear() | |
| 1616 | + | } | |
| 1611 | 1617 | i.mu.Unlock() | |
| 1612 | 1618 | i.invalidate() | |
| 1613 | 1619 | return false | |
| @@ -373,7 +373,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 373 | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | 373 | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | ||
| 374 | // chat as normal terminal flow/scrollback and redraws only the live | 374 | // 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 rate | 379 | // 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 tool | 1607 | // 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.ExpandAll | 1613 | 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 false | 1619 | return false | ||
M internal/tui/render.go +17 -12
| @@ -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 | 76 | func (r *Renderer) Clear() { | |
| 73 | 77 | r.prev = nil | |
| 74 | 78 | r.logChat = nil | |
| 75 | 79 | r.logBottom = nil | |
| 76 | 80 | r.logInit = false | |
| 77 | 81 | 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 | 85 | // 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 | 297 | // immediately by the live input/status block. No bottom padding, | |
| 294 | 298 | // no footer layout. | |
| 295 | 299 | w.WriteString(SeqClearScreen) | |
| 300 | + | w.WriteString(SeqClearScrollback) | |
| 296 | 301 | w.WriteString(MoveTo(1, 1)) | |
| 297 | 302 | for _, line := range chatFrame { | |
| 298 | 303 | w.WriteString("\x1b[0m") | |
| @@ -333,16 +338,16 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 333 | 338 | } | |
| 334 | 339 | } else { | |
| 335 | 340 | // 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)) | |
| 346 | 351 | for _, line := range chatFrame { | |
| 347 | 352 | w.WriteString("\x1b[0m") | |
| 348 | 353 | 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 = nil | 77 | r.prev = nil | ||
| 74 | r.logChat = nil | 78 | r.logChat = nil | ||
| 75 | r.logBottom = nil | 79 | r.logBottom = nil | ||
| 76 | r.logInit = false | 80 | r.logInit = false | ||
| 77 | r.logCursorR = 0 | 81 | 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 the | 85 | // 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 the | 340 | // Transcript changed in place (streaming text grows within the | ||
| 336 | - | // last assistant block, markdown finalises/reflows, /clear or a | 341 | + | // last assistant block, markdown finalises/reflows, ctrl+o |
| 337 | - | // session load replaces content). Do not append a second full copy | 342 | + | // expand/collapse, /clear, session load). In main-screen flow |
| 338 | - | // to terminal scrollback. We are currently at the top of the old | 343 | + | // 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 replacement | 345 | + | // 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) | ||