fix: restore jump navigation in the transcript
Fixes #194
- commit
- d66bd89c1f965966155287ff580c046e82dc19f8
- author
- patriceckhart · · 2026-09-21 10:18 UTC
- parent
- 2e387ff
- signature
- unsigned
- refs
- tag: v0.3.83
5 files · +226 -40
M README.md +1 -1
| @@ -383,7 +383,7 @@ Five ops on the current session. `/session` alone opens a picker; each is also r | |||
| 383 | 383 | ||
| 384 | 384 | Opens a turn picker for the current session, one row per user prompt, each showing the turn number, how many tools that turn invoked, and the first line of the prompt. `up`/`down` to pick, `enter` to jump, `esc` to cancel. Any printable rune while the picker is open extends a filter; backspace narrows it back. `/jump <text>` pre-applies the filter; if exactly one turn matches, zot jumps straight there without showing the picker. | |
| 385 | 385 | ||
| 386 | - | Jumping is non-destructive. The transcript is untouched, the viewport just scrolls so the chosen turn is at the top. A muted line at the top of the chat reads `viewing turn N of M, pgdn to catch up`. Scroll back to the bottom with `pgdn` (or keep scrolling with the arrow keys) and the indicator goes away. | |
| 386 | + | Jumping is non-destructive. The transcript and future model context are untouched. Zot opens a temporary history view with the chosen turn at or near the top and a `viewing turn N of M` indicator. Use `pgup` and `pgdn` to browse. Reaching the bottom restores the live view, including output received while browsing. History browsing uses the terminal's alternate screen rather than trying to reposition native scrollback. The normal live view still uses native terminal scrollback. Use `/session fork` instead if you want to branch from an earlier turn. | |
| 387 | 387 | ||
| 388 | 388 | ### `/btw` | |
| 389 | 389 | ||
| @@ -383,7 +383,7 @@ Five ops on the current session. `/session` alone opens a picker; each is also r | |||||
| 383 | 383 | ||||
| 384 | Opens a turn picker for the current session, one row per user prompt, each showing the turn number, how many tools that turn invoked, and the first line of the prompt. `up`/`down` to pick, `enter` to jump, `esc` to cancel. Any printable rune while the picker is open extends a filter; backspace narrows it back. `/jump <text>` pre-applies the filter; if exactly one turn matches, zot jumps straight there without showing the picker. | 384 | Opens a turn picker for the current session, one row per user prompt, each showing the turn number, how many tools that turn invoked, and the first line of the prompt. `up`/`down` to pick, `enter` to jump, `esc` to cancel. Any printable rune while the picker is open extends a filter; backspace narrows it back. `/jump <text>` pre-applies the filter; if exactly one turn matches, zot jumps straight there without showing the picker. | ||
| 385 | 385 | ||||
| 386 | - | Jumping is non-destructive. The transcript is untouched, the viewport just scrolls so the chosen turn is at the top. A muted line at the top of the chat reads `viewing turn N of M, pgdn to catch up`. Scroll back to the bottom with `pgdn` (or keep scrolling with the arrow keys) and the indicator goes away. | 386 | + | Jumping is non-destructive. The transcript and future model context are untouched. Zot opens a temporary history view with the chosen turn at or near the top and a `viewing turn N of M` indicator. Use `pgup` and `pgdn` to browse. Reaching the bottom restores the live view, including output received while browsing. History browsing uses the terminal's alternate screen rather than trying to reposition native scrollback. The normal live view still uses native terminal scrollback. Use `/session fork` instead if you want to branch from an earlier turn. |
| 387 | 387 | ||||
| 388 | ### `/btw` | 388 | ### `/btw` | ||
| 389 | 389 | ||||
M packages/agent/modes/interactive.go +27 -33
| @@ -555,6 +555,7 @@ type Interactive struct { | |||
| 555 | 555 | // don't forget they're looking at history. | |
| 556 | 556 | parkedTurn int | |
| 557 | 557 | parkedTotal int | |
| 558 | + | jumpRow *int // pending target, applied after the picker closes and layout is known | |
| 558 | 559 | ||
| 559 | 560 | // inputHistoryIndex is -1 when not browsing history. When the | |
| 560 | 561 | // editor is empty, Up/Down can walk previous user prompts without | |
| @@ -756,8 +757,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 756 | 757 | // selection over the wheel-speed boost, so we no longer turn it | |
| 757 | 758 | // on automatically. Wheel events fall through to the terminal's | |
| 758 | 759 | // own scrollback handler. | |
| 759 | - | // Keep zot on the terminal's main screen. We intentionally do not | |
| 760 | - | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | |
| 760 | + | // Keep the live log on the terminal's main screen. Only explicit | |
| 761 | + | // history browsing uses the alternate screen. The renderer emits | |
| 761 | 762 | // chat as normal terminal flow/scrollback and redraws only the live | |
| 762 | 763 | // input/status block on normal typing. | |
| 763 | 764 | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) | |
| @@ -773,6 +774,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 773 | 774 | // inactive TUI visible above it. Do not erase scrollback: users should | |
| 774 | 775 | // still be able to review the session after closing zot. | |
| 775 | 776 | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.SeqShowCursor)) | |
| 777 | + | defer i.rend.CloseHistory() | |
| 776 | 778 | i.applyInputCursorColor() | |
| 777 | 779 | ||
| 778 | 780 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| @@ -1303,6 +1305,7 @@ func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int { | |||
| 1303 | 1305 | // scrollToBottom pins the view to the latest content. | |
| 1304 | 1306 | func (i *Interactive) scrollToBottom() { | |
| 1305 | 1307 | i.mu.Lock() | |
| 1308 | + | i.jumpRow = nil | |
| 1306 | 1309 | i.scrollOffset = 0 | |
| 1307 | 1310 | i.parkedTurn = 0 | |
| 1308 | 1311 | i.parkedTotal = 0 | |
| @@ -1441,7 +1444,7 @@ func (i *Interactive) redraw() { | |||
| 1441 | 1444 | // from the chat below, instead of the diff path leaving stale | |
| 1442 | 1445 | // dialog content behind. Equivalent to the user pressing ctrl+l. | |
| 1443 | 1446 | overlayOpen := len(dialog) > 0 || len(suggest) > 0 | |
| 1444 | - | if i.rend != nil && i.prevOverlayOpen && !overlayOpen { | |
| 1447 | + | if i.rend != nil && i.prevOverlayOpen && !overlayOpen && i.jumpRow == nil { | |
| 1445 | 1448 | // An overlay (dialog or slash/file popup) just closed, so the | |
| 1446 | 1449 | // bottom band shrinks. On terminals where we can drop | |
| 1447 | 1450 | // scrollback, a full Clear is the simplest way to guarantee | |
| @@ -1643,7 +1646,11 @@ func (i *Interactive) redraw() { | |||
| 1643 | 1646 | // let a shrinking chatRows pull the window toward the tail, which | |
| 1644 | 1647 | // read as the viewport jumping to the bottom whenever the agent | |
| 1645 | 1648 | // streamed text or a tool call grew the bottom band. | |
| 1646 | - | if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | |
| 1649 | + | if i.jumpRow != nil { | |
| 1650 | + | i.scrollOffset = max(0, len(chat)-*i.jumpRow-chatRows) | |
| 1651 | + | i.jumpRow = nil | |
| 1652 | + | i.prevScrollOffset = i.scrollOffset | |
| 1653 | + | } else if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | |
| 1647 | 1654 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | |
| 1648 | 1655 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | |
| 1649 | 1656 | } | |
| @@ -1820,8 +1827,11 @@ func (i *Interactive) redraw() { | |||
| 1820 | 1827 | cursorRow = -1 | |
| 1821 | 1828 | cursorCol = 0 | |
| 1822 | 1829 | } | |
| 1823 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 1824 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 1830 | + | if i.scrollOffset > 0 { | |
| 1831 | + | i.rend.DrawHistory(visibleChat, bottom, cursorRow, cursorCol) | |
| 1832 | + | } else { | |
| 1833 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 1834 | + | } | |
| 1825 | 1835 | } | |
| 1826 | 1836 | ||
| 1827 | 1837 | func hasImageEscape(line string) bool { | |
| @@ -5303,8 +5313,13 @@ func (i *Interactive) openJumpDialog(args []string) { | |||
| 5303 | 5313 | // message index to row is exact, regardless of variable-height tool | |
| 5304 | 5314 | // blocks above the target. | |
| 5305 | 5315 | func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | |
| 5316 | + | i.mu.Lock() | |
| 5317 | + | defer i.mu.Unlock() | |
| 5306 | 5318 | cols := i.lastCols() | |
| 5307 | - | chat, anchors := i.view.BuildWithAnchors(cols) | |
| 5319 | + | // Older resumed messages otherwise have zero-row placeholder anchors. | |
| 5320 | + | i.view.TailLimit = 0 | |
| 5321 | + | i.chatCacheValid = false | |
| 5322 | + | _, anchors := i.view.BuildWithAnchors(cols) | |
| 5308 | 5323 | var row int | |
| 5309 | 5324 | found := false | |
| 5310 | 5325 | for _, a := range anchors { | |
| @@ -5315,40 +5330,19 @@ func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | |||
| 5315 | 5330 | } | |
| 5316 | 5331 | } | |
| 5317 | 5332 | if !found { | |
| 5318 | - | i.mu.Lock() | |
| 5319 | 5333 | i.statusErr = "could not resolve jump target" | |
| 5320 | - | i.mu.Unlock() | |
| 5321 | 5334 | return | |
| 5322 | 5335 | } | |
| 5323 | - | ||
| 5324 | - | chatLen := len(chat) | |
| 5325 | - | page := i.chatPage() | |
| 5326 | - | if page < 1 { | |
| 5327 | - | page = 1 | |
| 5328 | - | } | |
| 5329 | - | // scrollOffset is measured from the bottom of the chat slice, so | |
| 5330 | - | // to place `row` at the top of the viewport we want: | |
| 5331 | - | // chatLen - scrollOffset - page == row | |
| 5332 | - | // Solve for scrollOffset and clamp to [0, chatLen-page]. | |
| 5333 | - | offset := chatLen - (row + page) | |
| 5334 | - | if offset < 0 { | |
| 5335 | - | offset = 0 | |
| 5336 | - | } | |
| 5337 | - | maxOffset := chatLen - page | |
| 5338 | - | if maxOffset < 0 { | |
| 5339 | - | maxOffset = 0 | |
| 5340 | - | } | |
| 5341 | - | if offset > maxOffset { | |
| 5342 | - | offset = maxOffset | |
| 5336 | + | if i.updateInfo.Available { | |
| 5337 | + | row += len(renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)) | |
| 5343 | 5338 | } | |
| 5344 | - | ||
| 5345 | - | i.mu.Lock() | |
| 5346 | - | i.scrollOffset = offset | |
| 5339 | + | // Resolve against the final chat and bottom-band heights on redraw, | |
| 5340 | + | // not the previous picker layout or the approximate keyboard page size. | |
| 5341 | + | i.jumpRow = &row | |
| 5347 | 5342 | i.parkedTurn = turnNo | |
| 5348 | 5343 | i.parkedTotal = totalTurnsLocked(i.view.Messages) | |
| 5349 | 5344 | i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo) | |
| 5350 | 5345 | i.statusErr = "" | |
| 5351 | - | i.mu.Unlock() | |
| 5352 | 5346 | } | |
| 5353 | 5347 | ||
| 5354 | 5348 | // totalTurnsLocked counts user messages in the transcript. Caller is | |
| @@ -555,6 +555,7 @@ type Interactive struct { | |||||
| 555 | // don't forget they're looking at history. | 555 | // don't forget they're looking at history. | ||
| 556 | parkedTurn int | 556 | parkedTurn int | ||
| 557 | parkedTotal int | 557 | parkedTotal int | ||
| 558 | + | jumpRow *int // pending target, applied after the picker closes and layout is known | |||
| 558 | 559 | ||||
| 559 | // inputHistoryIndex is -1 when not browsing history. When the | 560 | // inputHistoryIndex is -1 when not browsing history. When the | ||
| 560 | // editor is empty, Up/Down can walk previous user prompts without | 561 | // editor is empty, Up/Down can walk previous user prompts without | ||
| @@ -756,8 +757,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 756 | // selection over the wheel-speed boost, so we no longer turn it | 757 | // selection over the wheel-speed boost, so we no longer turn it | ||
| 757 | // on automatically. Wheel events fall through to the terminal's | 758 | // on automatically. Wheel events fall through to the terminal's | ||
| 758 | // own scrollback handler. | 759 | // own scrollback handler. | ||
| 759 | - | // Keep zot on the terminal's main screen. We intentionally do not | 760 | + | // Keep the live log on the terminal's main screen. Only explicit |
| 760 | - | // enter the alternate-screen buffer (CSI ?1049h). The renderer emits | 761 | + | // history browsing uses the alternate screen. The renderer emits |
| 761 | // chat as normal terminal flow/scrollback and redraws only the live | 762 | // chat as normal terminal flow/scrollback and redraws only the live | ||
| 762 | // input/status block on normal typing. | 763 | // input/status block on normal typing. | ||
| 763 | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) | 764 | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) | ||
| @@ -773,6 +774,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 773 | // inactive TUI visible above it. Do not erase scrollback: users should | 774 | // inactive TUI visible above it. Do not erase scrollback: users should | ||
| 774 | // still be able to review the session after closing zot. | 775 | // still be able to review the session after closing zot. | ||
| 775 | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.SeqShowCursor)) | 776 | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.SeqShowCursor)) | ||
| 777 | + | defer i.rend.CloseHistory() | |||
| 776 | i.applyInputCursorColor() | 778 | i.applyInputCursorColor() | ||
| 777 | 779 | ||||
| 778 | // Streaming pacer: drains buffered text deltas at a steady rate | 780 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
| @@ -1303,6 +1305,7 @@ func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int { | |||||
| 1303 | // scrollToBottom pins the view to the latest content. | 1305 | // scrollToBottom pins the view to the latest content. | ||
| 1304 | func (i *Interactive) scrollToBottom() { | 1306 | func (i *Interactive) scrollToBottom() { | ||
| 1305 | i.mu.Lock() | 1307 | i.mu.Lock() | ||
| 1308 | + | i.jumpRow = nil | |||
| 1306 | i.scrollOffset = 0 | 1309 | i.scrollOffset = 0 | ||
| 1307 | i.parkedTurn = 0 | 1310 | i.parkedTurn = 0 | ||
| 1308 | i.parkedTotal = 0 | 1311 | i.parkedTotal = 0 | ||
| @@ -1441,7 +1444,7 @@ func (i *Interactive) redraw() { | |||||
| 1441 | // from the chat below, instead of the diff path leaving stale | 1444 | // from the chat below, instead of the diff path leaving stale | ||
| 1442 | // dialog content behind. Equivalent to the user pressing ctrl+l. | 1445 | // dialog content behind. Equivalent to the user pressing ctrl+l. | ||
| 1443 | overlayOpen := len(dialog) > 0 || len(suggest) > 0 | 1446 | overlayOpen := len(dialog) > 0 || len(suggest) > 0 | ||
| 1444 | - | if i.rend != nil && i.prevOverlayOpen && !overlayOpen { | 1447 | + | if i.rend != nil && i.prevOverlayOpen && !overlayOpen && i.jumpRow == nil { |
| 1445 | // An overlay (dialog or slash/file popup) just closed, so the | 1448 | // An overlay (dialog or slash/file popup) just closed, so the | ||
| 1446 | // bottom band shrinks. On terminals where we can drop | 1449 | // bottom band shrinks. On terminals where we can drop | ||
| 1447 | // scrollback, a full Clear is the simplest way to guarantee | 1450 | // scrollback, a full Clear is the simplest way to guarantee | ||
| @@ -1643,7 +1646,11 @@ func (i *Interactive) redraw() { | |||||
| 1643 | // let a shrinking chatRows pull the window toward the tail, which | 1646 | // let a shrinking chatRows pull the window toward the tail, which | ||
| 1644 | // read as the viewport jumping to the bottom whenever the agent | 1647 | // read as the viewport jumping to the bottom whenever the agent | ||
| 1645 | // streamed text or a tool call grew the bottom band. | 1648 | // streamed text or a tool call grew the bottom band. | ||
| 1646 | - | if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | 1649 | + | if i.jumpRow != nil { |
| 1650 | + | i.scrollOffset = max(0, len(chat)-*i.jumpRow-chatRows) | |||
| 1651 | + | i.jumpRow = nil | |||
| 1652 | + | i.prevScrollOffset = i.scrollOffset | |||
| 1653 | + | } else if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | |||
| 1647 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | 1654 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | ||
| 1648 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | 1655 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | ||
| 1649 | } | 1656 | } | ||
| @@ -1820,8 +1827,11 @@ func (i *Interactive) redraw() { | |||||
| 1820 | cursorRow = -1 | 1827 | cursorRow = -1 | ||
| 1821 | cursorCol = 0 | 1828 | cursorCol = 0 | ||
| 1822 | } | 1829 | } | ||
| 1823 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 1830 | + | if i.scrollOffset > 0 { |
| 1824 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 1831 | + | i.rend.DrawHistory(visibleChat, bottom, cursorRow, cursorCol) |
| 1832 | + | } else { | |||
| 1833 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |||
| 1834 | + | } | |||
| 1825 | } | 1835 | } | ||
| 1826 | 1836 | ||||
| 1827 | func hasImageEscape(line string) bool { | 1837 | func hasImageEscape(line string) bool { | ||
| @@ -5303,8 +5313,13 @@ func (i *Interactive) openJumpDialog(args []string) { | |||||
| 5303 | // message index to row is exact, regardless of variable-height tool | 5313 | // message index to row is exact, regardless of variable-height tool | ||
| 5304 | // blocks above the target. | 5314 | // blocks above the target. | ||
| 5305 | func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | 5315 | func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | ||
| 5316 | + | i.mu.Lock() | |||
| 5317 | + | defer i.mu.Unlock() | |||
| 5306 | cols := i.lastCols() | 5318 | cols := i.lastCols() | ||
| 5307 | - | chat, anchors := i.view.BuildWithAnchors(cols) | 5319 | + | // Older resumed messages otherwise have zero-row placeholder anchors. |
| 5320 | + | i.view.TailLimit = 0 | |||
| 5321 | + | i.chatCacheValid = false | |||
| 5322 | + | _, anchors := i.view.BuildWithAnchors(cols) | |||
| 5308 | var row int | 5323 | var row int | ||
| 5309 | found := false | 5324 | found := false | ||
| 5310 | for _, a := range anchors { | 5325 | for _, a := range anchors { | ||
| @@ -5315,40 +5330,19 @@ func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) { | |||||
| 5315 | } | 5330 | } | ||
| 5316 | } | 5331 | } | ||
| 5317 | if !found { | 5332 | if !found { | ||
| 5318 | - | i.mu.Lock() | |||
| 5319 | i.statusErr = "could not resolve jump target" | 5333 | i.statusErr = "could not resolve jump target" | ||
| 5320 | - | i.mu.Unlock() | |||
| 5321 | return | 5334 | return | ||
| 5322 | } | 5335 | } | ||
| 5323 | - | 5336 | + | if i.updateInfo.Available { | |
| 5324 | - | chatLen := len(chat) | 5337 | + | row += len(renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)) |
| 5325 | - | page := i.chatPage() | |||
| 5326 | - | if page < 1 { | |||
| 5327 | - | page = 1 | |||
| 5328 | - | } | |||
| 5329 | - | // scrollOffset is measured from the bottom of the chat slice, so | |||
| 5330 | - | // to place `row` at the top of the viewport we want: | |||
| 5331 | - | // chatLen - scrollOffset - page == row | |||
| 5332 | - | // Solve for scrollOffset and clamp to [0, chatLen-page]. | |||
| 5333 | - | offset := chatLen - (row + page) | |||
| 5334 | - | if offset < 0 { | |||
| 5335 | - | offset = 0 | |||
| 5336 | - | } | |||
| 5337 | - | maxOffset := chatLen - page | |||
| 5338 | - | if maxOffset < 0 { | |||
| 5339 | - | maxOffset = 0 | |||
| 5340 | - | } | |||
| 5341 | - | if offset > maxOffset { | |||
| 5342 | - | offset = maxOffset | |||
| 5343 | } | 5338 | } | ||
| 5344 | - | 5339 | + | // Resolve against the final chat and bottom-band heights on redraw, | |
| 5345 | - | i.mu.Lock() | 5340 | + | // not the previous picker layout or the approximate keyboard page size. |
| 5346 | - | i.scrollOffset = offset | 5341 | + | i.jumpRow = &row |
| 5347 | i.parkedTurn = turnNo | 5342 | i.parkedTurn = turnNo | ||
| 5348 | i.parkedTotal = totalTurnsLocked(i.view.Messages) | 5343 | i.parkedTotal = totalTurnsLocked(i.view.Messages) | ||
| 5349 | i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo) | 5344 | i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo) | ||
| 5350 | i.statusErr = "" | 5345 | i.statusErr = "" | ||
| 5351 | - | i.mu.Unlock() | |||
| 5352 | } | 5346 | } | ||
| 5353 | 5347 | ||||
| 5354 | // totalTurnsLocked counts user messages in the transcript. Caller is | 5348 | // totalTurnsLocked counts user messages in the transcript. Caller is | ||
A packages/agent/modes/jump_render_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "reflect" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestJumpRendersSelectedTurn(t *testing.T) { | |
| 16 | + | for _, tc := range []struct { | |
| 17 | + | name string | |
| 18 | + | tailLimit, turn int | |
| 19 | + | filtered bool | |
| 20 | + | }{ | |
| 21 | + | {"picker", 0, 1, false}, | |
| 22 | + | {"resumed older turn", 80, 10, false}, | |
| 23 | + | {"filtered shortcut", 80, 50, true}, | |
| 24 | + | } { | |
| 25 | + | t.Run(tc.name, func(t *testing.T) { | |
| 26 | + | term := &cleanupTestTerminal{} | |
| 27 | + | agent := core.NewAgent(nil, "test", "", nil) | |
| 28 | + | var msgs []provider.Message | |
| 29 | + | for n := 1; n <= 100; n++ { | |
| 30 | + | msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("prompt-number-%03d", n)}}}) | |
| 31 | + | } | |
| 32 | + | agent.SetMessages(msgs) | |
| 33 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Agent: agent, Sandbox: tools.NewSandbox(t.TempDir())}) | |
| 34 | + | i.rend.Resize(80, 24) | |
| 35 | + | i.view.TailLimit = tc.tailLimit | |
| 36 | + | i.redraw() | |
| 37 | + | targetText := fmt.Sprintf("prompt-number-%03d", tc.turn) | |
| 38 | + | if tc.filtered { | |
| 39 | + | term.Reset() | |
| 40 | + | i.openJumpDialog([]string{targetText}) | |
| 41 | + | } else { | |
| 42 | + | i.openJumpDialog(nil) | |
| 43 | + | i.redraw() | |
| 44 | + | target := i.jumpDialog.Targets()[tc.turn-1] | |
| 45 | + | i.jumpDialog.Close() | |
| 46 | + | term.Reset() | |
| 47 | + | i.applyJumpSelection(target.MessageIdx, target.TurnNo) | |
| 48 | + | } | |
| 49 | + | i.redraw() | |
| 50 | + | output := term.String() | |
| 51 | + | if !strings.Contains(output, tui.SeqAltScreenOn) || !strings.Contains(output, targetText) || !strings.Contains(output, fmt.Sprintf("viewing turn %d of 100", tc.turn)) { | |
| 52 | + | t.Fatalf("selected turn not rendered in history: %q", output) | |
| 53 | + | } | |
| 54 | + | if strings.Contains(output, "prompt-number-100") { | |
| 55 | + | t.Fatal("history still displays the live tail") | |
| 56 | + | } | |
| 57 | + | if !reflect.DeepEqual(agent.Messages(), msgs) { | |
| 58 | + | t.Fatal("jump modified the transcript") | |
| 59 | + | } | |
| 60 | + | // Appending a completed turn must not move the history viewport. | |
| 61 | + | beforeChat := i.cachedChatLocked(80) | |
| 62 | + | top := len(beforeChat) - i.scrollOffset - i.prevChatRows | |
| 63 | + | _, anchors := i.view.BuildWithAnchors(80) | |
| 64 | + | if top != anchors[tc.turn-1].Row { | |
| 65 | + | t.Fatalf("jump top = %d, want target row %d", top, anchors[tc.turn-1].Row) | |
| 66 | + | } | |
| 67 | + | agent.SetMessages(append(msgs, provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new output"}}})) | |
| 68 | + | i.redraw() | |
| 69 | + | afterChat := i.cachedChatLocked(80) | |
| 70 | + | if got := len(afterChat) - i.scrollOffset - i.prevChatRows; got != top { | |
| 71 | + | t.Fatalf("history moved while output arrived: %d to %d", top, got) | |
| 72 | + | } | |
| 73 | + | term.Reset() | |
| 74 | + | for pages := 0; i.scrollOffset > 0 && pages < 100; pages++ { | |
| 75 | + | i.scrollBy(-i.chatPage()) | |
| 76 | + | i.redraw() | |
| 77 | + | } | |
| 78 | + | if i.scrollOffset != 0 { | |
| 79 | + | t.Fatal("page down did not reach the live tail") | |
| 80 | + | } | |
| 81 | + | if !strings.Contains(term.String(), tui.SeqAltScreenOff) || !strings.Contains(term.String(), "new output") { | |
| 82 | + | t.Fatal("return to live tail did not restore main screen with new output") | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "reflect" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestJumpRendersSelectedTurn(t *testing.T) { | |||
| 16 | + | for _, tc := range []struct { | |||
| 17 | + | name string | |||
| 18 | + | tailLimit, turn int | |||
| 19 | + | filtered bool | |||
| 20 | + | }{ | |||
| 21 | + | {"picker", 0, 1, false}, | |||
| 22 | + | {"resumed older turn", 80, 10, false}, | |||
| 23 | + | {"filtered shortcut", 80, 50, true}, | |||
| 24 | + | } { | |||
| 25 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 26 | + | term := &cleanupTestTerminal{} | |||
| 27 | + | agent := core.NewAgent(nil, "test", "", nil) | |||
| 28 | + | var msgs []provider.Message | |||
| 29 | + | for n := 1; n <= 100; n++ { | |||
| 30 | + | msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("prompt-number-%03d", n)}}}) | |||
| 31 | + | } | |||
| 32 | + | agent.SetMessages(msgs) | |||
| 33 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Agent: agent, Sandbox: tools.NewSandbox(t.TempDir())}) | |||
| 34 | + | i.rend.Resize(80, 24) | |||
| 35 | + | i.view.TailLimit = tc.tailLimit | |||
| 36 | + | i.redraw() | |||
| 37 | + | targetText := fmt.Sprintf("prompt-number-%03d", tc.turn) | |||
| 38 | + | if tc.filtered { | |||
| 39 | + | term.Reset() | |||
| 40 | + | i.openJumpDialog([]string{targetText}) | |||
| 41 | + | } else { | |||
| 42 | + | i.openJumpDialog(nil) | |||
| 43 | + | i.redraw() | |||
| 44 | + | target := i.jumpDialog.Targets()[tc.turn-1] | |||
| 45 | + | i.jumpDialog.Close() | |||
| 46 | + | term.Reset() | |||
| 47 | + | i.applyJumpSelection(target.MessageIdx, target.TurnNo) | |||
| 48 | + | } | |||
| 49 | + | i.redraw() | |||
| 50 | + | output := term.String() | |||
| 51 | + | if !strings.Contains(output, tui.SeqAltScreenOn) || !strings.Contains(output, targetText) || !strings.Contains(output, fmt.Sprintf("viewing turn %d of 100", tc.turn)) { | |||
| 52 | + | t.Fatalf("selected turn not rendered in history: %q", output) | |||
| 53 | + | } | |||
| 54 | + | if strings.Contains(output, "prompt-number-100") { | |||
| 55 | + | t.Fatal("history still displays the live tail") | |||
| 56 | + | } | |||
| 57 | + | if !reflect.DeepEqual(agent.Messages(), msgs) { | |||
| 58 | + | t.Fatal("jump modified the transcript") | |||
| 59 | + | } | |||
| 60 | + | // Appending a completed turn must not move the history viewport. | |||
| 61 | + | beforeChat := i.cachedChatLocked(80) | |||
| 62 | + | top := len(beforeChat) - i.scrollOffset - i.prevChatRows | |||
| 63 | + | _, anchors := i.view.BuildWithAnchors(80) | |||
| 64 | + | if top != anchors[tc.turn-1].Row { | |||
| 65 | + | t.Fatalf("jump top = %d, want target row %d", top, anchors[tc.turn-1].Row) | |||
| 66 | + | } | |||
| 67 | + | agent.SetMessages(append(msgs, provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new output"}}})) | |||
| 68 | + | i.redraw() | |||
| 69 | + | afterChat := i.cachedChatLocked(80) | |||
| 70 | + | if got := len(afterChat) - i.scrollOffset - i.prevChatRows; got != top { | |||
| 71 | + | t.Fatalf("history moved while output arrived: %d to %d", top, got) | |||
| 72 | + | } | |||
| 73 | + | term.Reset() | |||
| 74 | + | for pages := 0; i.scrollOffset > 0 && pages < 100; pages++ { | |||
| 75 | + | i.scrollBy(-i.chatPage()) | |||
| 76 | + | i.redraw() | |||
| 77 | + | } | |||
| 78 | + | if i.scrollOffset != 0 { | |||
| 79 | + | t.Fatal("page down did not reach the live tail") | |||
| 80 | + | } | |||
| 81 | + | if !strings.Contains(term.String(), tui.SeqAltScreenOff) || !strings.Contains(term.String(), "new output") { | |||
| 82 | + | t.Fatal("return to live tail did not restore main screen with new output") | |||
| 83 | + | } | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
A packages/tui/history_render_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestHistoryPreservesMainScreen(t *testing.T) { | |
| 10 | + | for _, program := range []string{"", "vscode"} { | |
| 11 | + | t.Run(program, func(t *testing.T) { | |
| 12 | + | t.Setenv("TERM_PROGRAM", program) | |
| 13 | + | var out bytes.Buffer | |
| 14 | + | r := NewRenderer(&out) | |
| 15 | + | r.Resize(80, 24) | |
| 16 | + | chat := []string{"original transcript", "live tail"} | |
| 17 | + | bottom := []string{"editor"} | |
| 18 | + | r.DrawLog(chat, bottom, 0, 2) | |
| 19 | + | out.Reset() | |
| 20 | + | r.DrawHistory([]string{"selected turn"}, bottom, 0, 2) | |
| 21 | + | r.Clear() | |
| 22 | + | r.DrawHistory([]string{"another turn"}, bottom, 0, 2) | |
| 23 | + | if strings.Count(out.String(), SeqAltScreenOn) != 1 || strings.Contains(out.String(), SeqClearScrollback) { | |
| 24 | + | t.Fatalf("history reset native scrollback or re-entered alternate screen: %q", out.String()) | |
| 25 | + | } | |
| 26 | + | if !strings.Contains(out.String(), "selected turn") || !strings.Contains(out.String(), "another turn") { | |
| 27 | + | t.Fatal("history content not rendered") | |
| 28 | + | } | |
| 29 | + | out.Reset() | |
| 30 | + | r.DrawLog(chat, bottom, 0, 2) | |
| 31 | + | if out.String() != SeqAltScreenOff { | |
| 32 | + | t.Fatalf("unchanged main screen was replayed: %q", out.String()) | |
| 33 | + | } | |
| 34 | + | out.Reset() | |
| 35 | + | r.DrawHistory([]string{"history"}, bottom, 0, 2) | |
| 36 | + | out.Reset() | |
| 37 | + | r.DrawLog(append(chat, "new streamed output"), bottom, 0, 2) | |
| 38 | + | if !strings.HasPrefix(out.String(), SeqAltScreenOff) || !strings.Contains(out.String(), "new streamed output") || strings.Contains(out.String(), "original transcript") { | |
| 39 | + | t.Fatalf("return from history did not append new output: %q", out.String()) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestHistoryResizeAndClose(t *testing.T) { | |
| 46 | + | var out bytes.Buffer | |
| 47 | + | r := NewRenderer(&out) | |
| 48 | + | r.Resize(80, 24) | |
| 49 | + | r.DrawLog([]string{"tail"}, []string{"editor"}, 0, 0) | |
| 50 | + | r.DrawHistory([]string{"history"}, []string{"editor"}, 0, 0) | |
| 51 | + | out.Reset() | |
| 52 | + | r.Resize(40, 12) | |
| 53 | + | r.DrawHistory([]string{"resized history"}, []string{"editor"}, 0, 0) | |
| 54 | + | if r.history.cols != 40 || r.history.rows != 12 || !strings.Contains(out.String(), "resized history") || strings.Contains(out.String(), SeqClearScrollback) { | |
| 55 | + | t.Fatalf("history resize failed: %q", out.String()) | |
| 56 | + | } | |
| 57 | + | out.Reset() | |
| 58 | + | r.CloseHistory() | |
| 59 | + | r.CloseHistory() | |
| 60 | + | if out.String() != SeqAltScreenOff { | |
| 61 | + | t.Fatalf("shutdown must restore main screen exactly once: %q", out.String()) | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestHistoryPreservesMainScreen(t *testing.T) { | |||
| 10 | + | for _, program := range []string{"", "vscode"} { | |||
| 11 | + | t.Run(program, func(t *testing.T) { | |||
| 12 | + | t.Setenv("TERM_PROGRAM", program) | |||
| 13 | + | var out bytes.Buffer | |||
| 14 | + | r := NewRenderer(&out) | |||
| 15 | + | r.Resize(80, 24) | |||
| 16 | + | chat := []string{"original transcript", "live tail"} | |||
| 17 | + | bottom := []string{"editor"} | |||
| 18 | + | r.DrawLog(chat, bottom, 0, 2) | |||
| 19 | + | out.Reset() | |||
| 20 | + | r.DrawHistory([]string{"selected turn"}, bottom, 0, 2) | |||
| 21 | + | r.Clear() | |||
| 22 | + | r.DrawHistory([]string{"another turn"}, bottom, 0, 2) | |||
| 23 | + | if strings.Count(out.String(), SeqAltScreenOn) != 1 || strings.Contains(out.String(), SeqClearScrollback) { | |||
| 24 | + | t.Fatalf("history reset native scrollback or re-entered alternate screen: %q", out.String()) | |||
| 25 | + | } | |||
| 26 | + | if !strings.Contains(out.String(), "selected turn") || !strings.Contains(out.String(), "another turn") { | |||
| 27 | + | t.Fatal("history content not rendered") | |||
| 28 | + | } | |||
| 29 | + | out.Reset() | |||
| 30 | + | r.DrawLog(chat, bottom, 0, 2) | |||
| 31 | + | if out.String() != SeqAltScreenOff { | |||
| 32 | + | t.Fatalf("unchanged main screen was replayed: %q", out.String()) | |||
| 33 | + | } | |||
| 34 | + | out.Reset() | |||
| 35 | + | r.DrawHistory([]string{"history"}, bottom, 0, 2) | |||
| 36 | + | out.Reset() | |||
| 37 | + | r.DrawLog(append(chat, "new streamed output"), bottom, 0, 2) | |||
| 38 | + | if !strings.HasPrefix(out.String(), SeqAltScreenOff) || !strings.Contains(out.String(), "new streamed output") || strings.Contains(out.String(), "original transcript") { | |||
| 39 | + | t.Fatalf("return from history did not append new output: %q", out.String()) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestHistoryResizeAndClose(t *testing.T) { | |||
| 46 | + | var out bytes.Buffer | |||
| 47 | + | r := NewRenderer(&out) | |||
| 48 | + | r.Resize(80, 24) | |||
| 49 | + | r.DrawLog([]string{"tail"}, []string{"editor"}, 0, 0) | |||
| 50 | + | r.DrawHistory([]string{"history"}, []string{"editor"}, 0, 0) | |||
| 51 | + | out.Reset() | |||
| 52 | + | r.Resize(40, 12) | |||
| 53 | + | r.DrawHistory([]string{"resized history"}, []string{"editor"}, 0, 0) | |||
| 54 | + | if r.history.cols != 40 || r.history.rows != 12 || !strings.Contains(out.String(), "resized history") || strings.Contains(out.String(), SeqClearScrollback) { | |||
| 55 | + | t.Fatalf("history resize failed: %q", out.String()) | |||
| 56 | + | } | |||
| 57 | + | out.Reset() | |||
| 58 | + | r.CloseHistory() | |||
| 59 | + | r.CloseHistory() | |||
| 60 | + | if out.String() != SeqAltScreenOff { | |||
| 61 | + | t.Fatalf("shutdown must restore main screen exactly once: %q", out.String()) | |||
| 62 | + | } | |||
| 63 | + | } | |||
M packages/tui/render.go +49 -6
| @@ -47,6 +47,10 @@ type Renderer struct { | |||
| 47 | 47 | logHardwareRow int | |
| 48 | 48 | logInit bool | |
| 49 | 49 | ||
| 50 | + | // History uses a separate alternate-screen renderer so browsing does not | |
| 51 | + | // overwrite the main screen or its native scrollback and diff state. | |
| 52 | + | history *Renderer | |
| 53 | + | ||
| 50 | 54 | // keepScrollback is true when we must NOT emit \x1b[3J | |
| 51 | 55 | // (erase-in-display 3, "clear scrollback"). | |
| 52 | 56 | // | |
| @@ -96,6 +100,9 @@ func NewRenderer(out io.Writer) *Renderer { | |||
| 96 | 100 | // background affects every row, so cached frame state is invalidated. | |
| 97 | 101 | func (r *Renderer) SetTheme(th Theme) { | |
| 98 | 102 | r.theme = th | |
| 103 | + | if r.history != nil { | |
| 104 | + | r.history.SetTheme(th) | |
| 105 | + | } | |
| 99 | 106 | r.Invalidate() | |
| 100 | 107 | } | |
| 101 | 108 | ||
| @@ -112,6 +119,11 @@ func (r *Renderer) ResetScrollRegion() { | |||
| 112 | 119 | } | |
| 113 | 120 | ||
| 114 | 121 | func (r *Renderer) Resize(cols, rows int) { | |
| 122 | + | if r.history != nil && (cols != r.cols || rows != r.rows) { | |
| 123 | + | r.history.cols = cols | |
| 124 | + | r.history.rows = rows | |
| 125 | + | r.history.Invalidate() | |
| 126 | + | } | |
| 115 | 127 | if cols != r.cols || rows != r.rows { | |
| 116 | 128 | r.cols = cols | |
| 117 | 129 | r.rows = rows | |
| @@ -122,7 +134,7 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 122 | 134 | r.logViewportTop = 0 | |
| 123 | 135 | r.logHardwareRow = 0 | |
| 124 | 136 | r.logInit = false | |
| 125 | - | if r.out != nil { | |
| 137 | + | if r.out != nil && r.history == nil { | |
| 126 | 138 | if r.keepScrollback { | |
| 127 | 139 | // A resize is a discrete user action, like Ctrl+L: the | |
| 128 | 140 | // old-width frame must be fully purged or the in-place | |
| @@ -151,6 +163,10 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 151 | 163 | // expand/collapse), because terminal scrollback cannot be edited | |
| 152 | 164 | // reliably once printed. | |
| 153 | 165 | func (r *Renderer) Clear() { | |
| 166 | + | if r.history != nil { | |
| 167 | + | r.history.Invalidate() | |
| 168 | + | return | |
| 169 | + | } | |
| 154 | 170 | r.prev = nil | |
| 155 | 171 | r.logChat = nil | |
| 156 | 172 | r.logBottom = nil | |
| @@ -201,6 +217,10 @@ func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback } | |||
| 201 | 217 | // whole terminal first. Useful when the cached diff is unreliable but a | |
| 202 | 218 | // visible full-screen flash would be too distracting. | |
| 203 | 219 | func (r *Renderer) Invalidate() { | |
| 220 | + | if r.history != nil { | |
| 221 | + | r.history.Invalidate() | |
| 222 | + | return | |
| 223 | + | } | |
| 204 | 224 | r.prev = nil | |
| 205 | 225 | r.logLines = nil | |
| 206 | 226 | } | |
| @@ -412,13 +432,36 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 412 | 432 | r.cursorCol = cursorCol | |
| 413 | 433 | } | |
| 414 | 434 | ||
| 435 | + | // DrawHistory displays a bounded history frame without modifying native | |
| 436 | + | // scrollback. Cursor coordinates are relative to bottom, as in DrawLog. | |
| 437 | + | func (r *Renderer) DrawHistory(chat, bottom []string, cursorBottomRow, cursorCol int) { | |
| 438 | + | if r.history == nil { | |
| 439 | + | _, _ = io.WriteString(r.out, SeqAltScreenOn) | |
| 440 | + | r.history = &Renderer{out: r.out, cols: r.cols, rows: r.rows, theme: r.theme} | |
| 441 | + | } | |
| 442 | + | frame := append(append([]string(nil), chat...), bottom...) | |
| 443 | + | cursorRow := cursorBottomRow | |
| 444 | + | if cursorRow >= 0 { | |
| 445 | + | cursorRow += len(chat) | |
| 446 | + | } | |
| 447 | + | r.history.Draw(frame, cursorRow, cursorCol) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // CloseHistory restores the main screen, including its saved cursor. Call on | |
| 451 | + | // shutdown as well as when returning to the live log. | |
| 452 | + | func (r *Renderer) CloseHistory() { | |
| 453 | + | if r.history != nil { | |
| 454 | + | _, _ = io.WriteString(r.out, SeqAltScreenOff) | |
| 455 | + | r.history = nil | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 415 | 459 | // DrawLog renders zot in the terminal's main screen as normal terminal | |
| 416 | - | // flow rather than a fixed full-screen frame. Chat lines are emitted once | |
| 417 | - | // into the host terminal scrollback; the current bottom block (dialogs, | |
| 418 | - | // slash popup, status, editor) is erased and redrawn in place at the end. | |
| 419 | - | // | |
| 420 | - | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | |
| 460 | + | // flow rather than a fixed full-screen frame. Chat lines are emitted into | |
| 461 | + | // native scrollback and the bottom block is redrawn in place at the end. | |
| 462 | + | // Cursor coordinates are relative to bottom, not the full frame. | |
| 421 | 463 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | |
| 464 | + | r.CloseHistory() | |
| 422 | 465 | if r.cols == 0 || r.rows == 0 { | |
| 423 | 466 | return | |
| 424 | 467 | } | |
| @@ -47,6 +47,10 @@ type Renderer struct { | |||||
| 47 | logHardwareRow int | 47 | logHardwareRow int | ||
| 48 | logInit bool | 48 | logInit bool | ||
| 49 | 49 | ||||
| 50 | + | // History uses a separate alternate-screen renderer so browsing does not | |||
| 51 | + | // overwrite the main screen or its native scrollback and diff state. | |||
| 52 | + | history *Renderer | |||
| 53 | + | ||||
| 50 | // keepScrollback is true when we must NOT emit \x1b[3J | 54 | // keepScrollback is true when we must NOT emit \x1b[3J | ||
| 51 | // (erase-in-display 3, "clear scrollback"). | 55 | // (erase-in-display 3, "clear scrollback"). | ||
| 52 | // | 56 | // | ||
| @@ -96,6 +100,9 @@ func NewRenderer(out io.Writer) *Renderer { | |||||
| 96 | // background affects every row, so cached frame state is invalidated. | 100 | // background affects every row, so cached frame state is invalidated. | ||
| 97 | func (r *Renderer) SetTheme(th Theme) { | 101 | func (r *Renderer) SetTheme(th Theme) { | ||
| 98 | r.theme = th | 102 | r.theme = th | ||
| 103 | + | if r.history != nil { | |||
| 104 | + | r.history.SetTheme(th) | |||
| 105 | + | } | |||
| 99 | r.Invalidate() | 106 | r.Invalidate() | ||
| 100 | } | 107 | } | ||
| 101 | 108 | ||||
| @@ -112,6 +119,11 @@ func (r *Renderer) ResetScrollRegion() { | |||||
| 112 | } | 119 | } | ||
| 113 | 120 | ||||
| 114 | func (r *Renderer) Resize(cols, rows int) { | 121 | func (r *Renderer) Resize(cols, rows int) { | ||
| 122 | + | if r.history != nil && (cols != r.cols || rows != r.rows) { | |||
| 123 | + | r.history.cols = cols | |||
| 124 | + | r.history.rows = rows | |||
| 125 | + | r.history.Invalidate() | |||
| 126 | + | } | |||
| 115 | if cols != r.cols || rows != r.rows { | 127 | if cols != r.cols || rows != r.rows { | ||
| 116 | r.cols = cols | 128 | r.cols = cols | ||
| 117 | r.rows = rows | 129 | r.rows = rows | ||
| @@ -122,7 +134,7 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 122 | r.logViewportTop = 0 | 134 | r.logViewportTop = 0 | ||
| 123 | r.logHardwareRow = 0 | 135 | r.logHardwareRow = 0 | ||
| 124 | r.logInit = false | 136 | r.logInit = false | ||
| 125 | - | if r.out != nil { | 137 | + | if r.out != nil && r.history == nil { |
| 126 | if r.keepScrollback { | 138 | if r.keepScrollback { | ||
| 127 | // A resize is a discrete user action, like Ctrl+L: the | 139 | // A resize is a discrete user action, like Ctrl+L: the | ||
| 128 | // old-width frame must be fully purged or the in-place | 140 | // old-width frame must be fully purged or the in-place | ||
| @@ -151,6 +163,10 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 151 | // expand/collapse), because terminal scrollback cannot be edited | 163 | // expand/collapse), because terminal scrollback cannot be edited | ||
| 152 | // reliably once printed. | 164 | // reliably once printed. | ||
| 153 | func (r *Renderer) Clear() { | 165 | func (r *Renderer) Clear() { | ||
| 166 | + | if r.history != nil { | |||
| 167 | + | r.history.Invalidate() | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 154 | r.prev = nil | 170 | r.prev = nil | ||
| 155 | r.logChat = nil | 171 | r.logChat = nil | ||
| 156 | r.logBottom = nil | 172 | r.logBottom = nil | ||
| @@ -201,6 +217,10 @@ func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback } | |||||
| 201 | // whole terminal first. Useful when the cached diff is unreliable but a | 217 | // whole terminal first. Useful when the cached diff is unreliable but a | ||
| 202 | // visible full-screen flash would be too distracting. | 218 | // visible full-screen flash would be too distracting. | ||
| 203 | func (r *Renderer) Invalidate() { | 219 | func (r *Renderer) Invalidate() { | ||
| 220 | + | if r.history != nil { | |||
| 221 | + | r.history.Invalidate() | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 204 | r.prev = nil | 224 | r.prev = nil | ||
| 205 | r.logLines = nil | 225 | r.logLines = nil | ||
| 206 | } | 226 | } | ||
| @@ -412,13 +432,36 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 412 | r.cursorCol = cursorCol | 432 | r.cursorCol = cursorCol | ||
| 413 | } | 433 | } | ||
| 414 | 434 | ||||
| 435 | + | // DrawHistory displays a bounded history frame without modifying native | |||
| 436 | + | // scrollback. Cursor coordinates are relative to bottom, as in DrawLog. | |||
| 437 | + | func (r *Renderer) DrawHistory(chat, bottom []string, cursorBottomRow, cursorCol int) { | |||
| 438 | + | if r.history == nil { | |||
| 439 | + | _, _ = io.WriteString(r.out, SeqAltScreenOn) | |||
| 440 | + | r.history = &Renderer{out: r.out, cols: r.cols, rows: r.rows, theme: r.theme} | |||
| 441 | + | } | |||
| 442 | + | frame := append(append([]string(nil), chat...), bottom...) | |||
| 443 | + | cursorRow := cursorBottomRow | |||
| 444 | + | if cursorRow >= 0 { | |||
| 445 | + | cursorRow += len(chat) | |||
| 446 | + | } | |||
| 447 | + | r.history.Draw(frame, cursorRow, cursorCol) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // CloseHistory restores the main screen, including its saved cursor. Call on | |||
| 451 | + | // shutdown as well as when returning to the live log. | |||
| 452 | + | func (r *Renderer) CloseHistory() { | |||
| 453 | + | if r.history != nil { | |||
| 454 | + | _, _ = io.WriteString(r.out, SeqAltScreenOff) | |||
| 455 | + | r.history = nil | |||
| 456 | + | } | |||
| 457 | + | } | |||
| 458 | + | ||||
| 415 | // DrawLog renders zot in the terminal's main screen as normal terminal | 459 | // DrawLog renders zot in the terminal's main screen as normal terminal | ||
| 416 | - | // flow rather than a fixed full-screen frame. Chat lines are emitted once | 460 | + | // flow rather than a fixed full-screen frame. Chat lines are emitted into |
| 417 | - | // into the host terminal scrollback; the current bottom block (dialogs, | 461 | + | // native scrollback and the bottom block is redrawn in place at the end. |
| 418 | - | // slash popup, status, editor) is erased and redrawn in place at the end. | 462 | + | // Cursor coordinates are relative to bottom, not the full frame. |
| 419 | - | // | |||
| 420 | - | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | |||
| 421 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | 463 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | ||
| 464 | + | r.CloseHistory() | |||
| 422 | if r.cols == 0 || r.rows == 0 { | 465 | if r.cols == 0 || r.rows == 0 { | ||
| 423 | return | 466 | return | ||
| 424 | } | 467 | } | ||