detached d66bd89

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
383383
384384 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.
385385
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.
387387
388388 ### `/btw`
389389
@@ -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 {
555555 // don't forget they're looking at history.
556556 parkedTurn int
557557 parkedTotal int
558+ jumpRow *int // pending target, applied after the picker closes and layout is known
558559
559560 // inputHistoryIndex is -1 when not browsing history. When the
560561 // editor is empty, Up/Down can walk previous user prompts without
@@ -756,8 +757,8 @@ func (i *Interactive) Run(ctx context.Context) error {
756757 // selection over the wheel-speed boost, so we no longer turn it
757758 // on automatically. Wheel events fall through to the terminal's
758759 // 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
761762 // chat as normal terminal flow/scrollback and redraws only the live
762763 // input/status block on normal typing.
763764 _, _ = 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 {
773774 // inactive TUI visible above it. Do not erase scrollback: users should
774775 // still be able to review the session after closing zot.
775776 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()
776778 i.applyInputCursorColor()
777779
778780 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -1303,6 +1305,7 @@ func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
13031305 // scrollToBottom pins the view to the latest content.
13041306 func (i *Interactive) scrollToBottom() {
13051307 i.mu.Lock()
1308+ i.jumpRow = nil
13061309 i.scrollOffset = 0
13071310 i.parkedTurn = 0
13081311 i.parkedTotal = 0
@@ -1441,7 +1444,7 @@ func (i *Interactive) redraw() {
14411444 // from the chat below, instead of the diff path leaving stale
14421445 // dialog content behind. Equivalent to the user pressing ctrl+l.
14431446 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 {
14451448 // An overlay (dialog or slash/file popup) just closed, so the
14461449 // bottom band shrinks. On terminals where we can drop
14471450 // scrollback, a full Clear is the simplest way to guarantee
@@ -1643,7 +1646,11 @@ func (i *Interactive) redraw() {
16431646 // let a shrinking chatRows pull the window toward the tail, which
16441647 // read as the viewport jumping to the bottom whenever the agent
16451648 // 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 {
16471654 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
16481655 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
16491656 }
@@ -1820,8 +1827,11 @@ func (i *Interactive) redraw() {
18201827 cursorRow = -1
18211828 cursorCol = 0
18221829 }
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+ }
18251835 }
18261836
18271837 func hasImageEscape(line string) bool {
@@ -5303,8 +5313,13 @@ func (i *Interactive) openJumpDialog(args []string) {
53035313 // message index to row is exact, regardless of variable-height tool
53045314 // blocks above the target.
53055315 func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
5316+ i.mu.Lock()
5317+ defer i.mu.Unlock()
53065318 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)
53085323 var row int
53095324 found := false
53105325 for _, a := range anchors {
@@ -5315,40 +5330,19 @@ func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
53155330 }
53165331 }
53175332 if !found {
5318- i.mu.Lock()
53195333 i.statusErr = "could not resolve jump target"
5320- i.mu.Unlock()
53215334 return
53225335 }
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))
53435338 }
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
53475342 i.parkedTurn = turnNo
53485343 i.parkedTotal = totalTurnsLocked(i.view.Messages)
53495344 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
53505345 i.statusErr = ""
5351- i.mu.Unlock()
53525346 }
53535347
53545348 // 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 int556 parkedTurn int
557 parkedTotal int557 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 the560 // inputHistoryIndex is -1 when not browsing history. When the
560 // editor is empty, Up/Down can walk previous user prompts without561 // 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 it757 // selection over the wheel-speed boost, so we no longer turn it
757 // on automatically. Wheel events fall through to the terminal's758 // 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 not760+ // Keep the live log on the terminal's main screen. Only explicit
760- // enter the alternate-screen buffer (CSI ?1049h). The renderer emits761+ // history browsing uses the alternate screen. The renderer emits
761 // chat as normal terminal flow/scrollback and redraws only the live762 // 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 should774 // 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 rate780 // 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 = 01309 i.scrollOffset = 0
1307 i.parkedTurn = 01310 i.parkedTurn = 0
1308 i.parkedTotal = 01311 i.parkedTotal = 0
@@ -1441,7 +1444,7 @@ func (i *Interactive) redraw() {
1441 // from the chat below, instead of the diff path leaving stale1444 // 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) > 01446 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 the1448 // An overlay (dialog or slash/file popup) just closed, so the
1446 // bottom band shrinks. On terminals where we can drop1449 // bottom band shrinks. On terminals where we can drop
1447 // scrollback, a full Clear is the simplest way to guarantee1450 // 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, which1646 // let a shrinking chatRows pull the window toward the tail, which
1644 // read as the viewport jumping to the bottom whenever the agent1647 // 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 = -11827 cursorRow = -1
1821 cursorCol = 01828 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 tool5313 // 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 int5323 var row int
5309 found := false5324 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 return5334 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 = offset5341+ i.jumpRow = &row
5347 i.parkedTurn = turnNo5342 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 is5348 // 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 {
4747 logHardwareRow int
4848 logInit bool
4949
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+
5054 // keepScrollback is true when we must NOT emit \x1b[3J
5155 // (erase-in-display 3, "clear scrollback").
5256 //
@@ -96,6 +100,9 @@ func NewRenderer(out io.Writer) *Renderer {
96100 // background affects every row, so cached frame state is invalidated.
97101 func (r *Renderer) SetTheme(th Theme) {
98102 r.theme = th
103+ if r.history != nil {
104+ r.history.SetTheme(th)
105+ }
99106 r.Invalidate()
100107 }
101108
@@ -112,6 +119,11 @@ func (r *Renderer) ResetScrollRegion() {
112119 }
113120
114121 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+ }
115127 if cols != r.cols || rows != r.rows {
116128 r.cols = cols
117129 r.rows = rows
@@ -122,7 +134,7 @@ func (r *Renderer) Resize(cols, rows int) {
122134 r.logViewportTop = 0
123135 r.logHardwareRow = 0
124136 r.logInit = false
125- if r.out != nil {
137+ if r.out != nil && r.history == nil {
126138 if r.keepScrollback {
127139 // A resize is a discrete user action, like Ctrl+L: the
128140 // old-width frame must be fully purged or the in-place
@@ -151,6 +163,10 @@ func (r *Renderer) Resize(cols, rows int) {
151163 // expand/collapse), because terminal scrollback cannot be edited
152164 // reliably once printed.
153165 func (r *Renderer) Clear() {
166+ if r.history != nil {
167+ r.history.Invalidate()
168+ return
169+ }
154170 r.prev = nil
155171 r.logChat = nil
156172 r.logBottom = nil
@@ -201,6 +217,10 @@ func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
201217 // whole terminal first. Useful when the cached diff is unreliable but a
202218 // visible full-screen flash would be too distracting.
203219 func (r *Renderer) Invalidate() {
220+ if r.history != nil {
221+ r.history.Invalidate()
222+ return
223+ }
204224 r.prev = nil
205225 r.logLines = nil
206226 }
@@ -412,13 +432,36 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
412432 r.cursorCol = cursorCol
413433 }
414434
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+
415459 // 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.
421463 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
464+ r.CloseHistory()
422465 if r.cols == 0 || r.rows == 0 {
423466 return
424467 }
@@ -47,6 +47,10 @@ type Renderer struct {
47 logHardwareRow int47 logHardwareRow int
48 logInit bool48 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[3J54 // 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 = th102 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 = cols128 r.cols = cols
117 r.rows = rows129 r.rows = rows
@@ -122,7 +134,7 @@ func (r *Renderer) Resize(cols, rows int) {
122 r.logViewportTop = 0134 r.logViewportTop = 0
123 r.logHardwareRow = 0135 r.logHardwareRow = 0
124 r.logInit = false136 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: the139 // A resize is a discrete user action, like Ctrl+L: the
128 // old-width frame must be fully purged or the in-place140 // 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 edited163 // 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 = nil170 r.prev = nil
155 r.logChat = nil171 r.logChat = nil
156 r.logBottom = nil172 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 a217 // 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 = nil224 r.prev = nil
205 r.logLines = nil225 r.logLines = nil
206 }226 }
@@ -412,13 +432,36 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
412 r.cursorCol = cursorCol432 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 terminal459 // 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 once460+// 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 return466 return
424 }467 }

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