detached 6470487

tui: /jump to scroll to past turns, render cache for long transcripts

/jump:
- new slash command; opens a picker listing every user turn in
  the current session (timestamp relative, tool count badge, first
  line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put
  that turn's user-message header at the top row. non-destructive,
  transcript untouched
- runes extend a live filter; backspace shortens. '/jump <text>'
  pre-applies the filter; exactly-one-match auto-jumps without
  showing the picker
- while parked on a past turn the scroll-up note reads 'viewing
  turn N of M \u00b7 pgdn to catch up' instead of the generic row
  count. scrolling back to the tail (or starting a new turn, or
  /clear) resets the parked state automatically
- view.go: new MessageAnchor type + BuildWithAnchors so the dialog
  can resolve msgIdx -> first rendered row

perf for long transcripts (the whole ui stutters on ~50 messages):
- view.renderCache: per-message memoisation keyed by (fnv1a of
  role+content, width, expandAll). finalised messages never change
  so the cache hit rate is ~100% after the first render. streaming
  partials and in-flight tool-call views stay uncached by design
- BuildWithAnchors now pre-sums line counts and allocates
  in a single make() instead of 50 appends with log2(N) backing-
  array memcpys
- truncateToWidth fast path: byte-length <= cols implies cell-width
  <= cols, so we skip the rune-width loop entirely. covers the huge
  majority of lines in a session
- cache purged on /clear, /compact completion, and session swap
  (applySessionSelection); resize invalidates implicitly via the
  width key. LRU eviction at 4x message count caps memory

impact: a 50-msg / 2000-line transcript went from unresponsive-
while-typing to drawing in well under a frame. measured locally
with go-perf traces; no change to correctness.
commit
64704875d2d3da3ea7e8b92ba267a72f606065cf
author
patriceckhart · · 2026-04-18 10:22 UTC
parent
7141ebd
signature
unsigned
refs
tag: v0.0.7
6 files · +651 -21
M README.md +7 -0
@@ -163,6 +163,7 @@ type `/` in the tui to open the autocomplete popup. available commands:
163163 | `/logout [provider]` | clear credentials for `anthropic`, `openai`, or all when omitted |
164164 | `/model` | pick a model from a list (or `/model <id>` to set directly) |
165165 | `/sessions` | resume a previous session for this directory |
166+| `/jump` | scroll the chat to a previous turn (or `/jump <text>` to filter) |
166167 | `/compact` | summarize the transcript into one message to free up context |
167168 | `/lock` | confine tools to the current directory |
168169 | `/unlock` | allow tools to touch paths outside again |
@@ -173,6 +174,12 @@ type `/` in the tui to open the autocomplete popup. available commands:
173174
174175 shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. pick one with `↑/↓`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.
175176
177+### `/jump`
178+
179+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. `↑/↓` 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.
180+
181+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.
182+
176183 ### `/compact`
177184
178185 sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%.
@@ -163,6 +163,7 @@ type `/` in the tui to open the autocomplete popup. available commands:
163 | `/logout [provider]` | clear credentials for `anthropic`, `openai`, or all when omitted |163 | `/logout [provider]` | clear credentials for `anthropic`, `openai`, or all when omitted |
164 | `/model` | pick a model from a list (or `/model <id>` to set directly) |164 | `/model` | pick a model from a list (or `/model <id>` to set directly) |
165 | `/sessions` | resume a previous session for this directory |165 | `/sessions` | resume a previous session for this directory |
166+| `/jump` | scroll the chat to a previous turn (or `/jump <text>` to filter) |
166 | `/compact` | summarize the transcript into one message to free up context |167 | `/compact` | summarize the transcript into one message to free up context |
167 | `/lock` | confine tools to the current directory |168 | `/lock` | confine tools to the current directory |
168 | `/unlock` | allow tools to touch paths outside again |169 | `/unlock` | allow tools to touch paths outside again |
@@ -173,6 +174,12 @@ type `/` in the tui to open the autocomplete popup. available commands:
173 174
174 shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. pick one with `↑/↓`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.175 shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. pick one with `↑/↓`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.
175 176
177+### `/jump`
178+
179+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. `↑/↓` 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.
180+
181+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.
182+
176 ### `/compact`183 ### `/compact`
177 184
178 sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%.185 sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%.
M internal/agent/modes/interactive.go +149 -5
@@ -125,8 +125,16 @@ type Interactive struct {
125125 dialog *loginDialog
126126 modelDialog *modelDialog
127127 sessionDialog *sessionDialog
128+ jumpDialog *jumpDialog
128129 suggest *slashSuggester
129130 spin *spinner
131+
132+ // parkedTurn is the 1-based turn number the viewport is currently
133+ // scrolled to by /jump. 0 = not parked, showing the tail as usual.
134+ // Rendered as a muted footer at the bottom of the chat so users
135+ // don't forget they're looking at history.
136+ parkedTurn int
137+ parkedTotal int
130138 }
131139
132140 // NewInteractive constructs an Interactive from cfg.
@@ -144,6 +152,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
144152 dialog: newLoginDialog(),
145153 modelDialog: newModelDialog(),
146154 sessionDialog: newSessionDialog(),
155+ jumpDialog: newJumpDialog(),
147156 suggest: newSlashSuggester(),
148157 spin: newSpinner(),
149158 }
@@ -283,7 +292,7 @@ func (i *Interactive) Run(ctx context.Context) error {
283292 case <-i.dirty:
284293 requestRedraw()
285294 case <-tick.C:
286- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {
295+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() {
287296 drainPending()
288297 requestRedraw()
289298 }
@@ -316,12 +325,19 @@ func (i *Interactive) chatPage() int {
316325 }
317326
318327 // scrollBy adjusts the scroll offset. Positive = up (into history).
328+// Clearing the parked-turn label when we're back at the bottom means
329+// the "viewing turn N" footer goes away automatically as soon as you
330+// scroll back to the live tail.
319331 func (i *Interactive) scrollBy(delta int) {
320332 i.mu.Lock()
321333 i.scrollOffset += delta
322334 if i.scrollOffset < 0 {
323335 i.scrollOffset = 0
324336 }
337+ if i.scrollOffset == 0 {
338+ i.parkedTurn = 0
339+ i.parkedTotal = 0
340+ }
325341 i.mu.Unlock()
326342 i.invalidate()
327343 }
@@ -330,6 +346,8 @@ func (i *Interactive) scrollBy(delta int) {
330346 func (i *Interactive) scrollToBottom() {
331347 i.mu.Lock()
332348 i.scrollOffset = 0
349+ i.parkedTurn = 0
350+ i.parkedTotal = 0
333351 i.mu.Unlock()
334352 i.invalidate()
335353 }
@@ -407,6 +425,8 @@ func (i *Interactive) redraw() {
407425 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
408426 case i.sessionDialog.Active():
409427 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
428+ case i.jumpDialog.Active():
429+ dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
410430 }
411431
412432 // Slash-command autocomplete: popup above the status line, only
@@ -499,10 +519,18 @@ func (i *Interactive) redraw() {
499519 }
500520
501521 // A tiny "scrolled up" indicator in the top-right of the chat pane
502- // so you know you're not at the bottom.
522+ // so you know you're not at the bottom. When the viewport was
523+ // parked by /jump we include the turn number so the user remembers
524+ // they're reading history rather than the live conversation.
503525 if i.scrollOffset > 0 && len(visibleChat) > 0 {
504- note := i.cfg.Theme.FG256(i.cfg.Theme.Muted,
505- fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset))
526+ var text string
527+ if i.parkedTurn > 0 && i.parkedTotal > 0 {
528+ text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
529+ i.parkedTurn, i.parkedTotal, i.scrollOffset)
530+ } else {
531+ text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
532+ }
533+ note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
506534 visibleChat = append([]string{note}, visibleChat...)
507535 if len(visibleChat) > chatRows {
508536 visibleChat = visibleChat[:chatRows]
@@ -578,6 +606,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
578606 }
579607 return false
580608 }
609+ if i.jumpDialog.Active() {
610+ if k.Kind == tui.KeyCtrlC {
611+ i.jumpDialog.Close()
612+ return false
613+ }
614+ act := i.jumpDialog.HandleKey(k)
615+ if act.Select {
616+ i.applyJumpSelection(act.MessageIdx, act.TurnNo)
617+ }
618+ return false
619+ }
581620
582621 // Global keys.
583622 switch k.Kind {
@@ -752,6 +791,10 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
752791 i.statusErr = ""
753792 i.statusOK = ""
754793 i.helpBlock = nil
794+ i.parkedTurn = 0
795+ i.parkedTotal = 0
796+ i.scrollOffset = 0
797+ i.view.InvalidateRenderCache()
755798 i.mu.Unlock()
756799 case "/help":
757800 i.mu.Lock()
@@ -779,6 +822,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
779822 }
780823 case "/sessions":
781824 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
825+ case "/jump":
826+ i.openJumpDialog(parts[1:])
782827 case "/compact":
783828 i.runCompact(ctx, false)
784829 case "/lock":
@@ -898,6 +943,96 @@ func (i *Interactive) startOAuthFlow(provider string) {
898943 // applyModelSelection switches the active model (and provider, if the
899944 // new model belongs to a different one). It rebuilds the underlying
900945 // client when needed so the provider wire-protocol matches.
946+// openJumpDialog builds a /jump picker from the current transcript.
947+// If the user typed "/jump foo" with a filter and it matches exactly
948+// one turn, jump there directly without showing the dialog.
949+func (i *Interactive) openJumpDialog(args []string) {
950+ if i.view == nil || len(i.view.Messages) == 0 {
951+ i.mu.Lock()
952+ i.statusErr = "nothing to jump to \u2014 the session is empty"
953+ i.mu.Unlock()
954+ return
955+ }
956+ filter := strings.TrimSpace(strings.Join(args, " "))
957+ i.jumpDialog.Open(i.view.Messages, filter)
958+ // Shortcut: with a filter argument that matches exactly one turn,
959+ // jump immediately and skip the picker.
960+ if filter != "" {
961+ if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
962+ t := tgts[0]
963+ i.jumpDialog.Close()
964+ i.applyJumpSelection(t.MessageIdx, t.TurnNo)
965+ }
966+ }
967+}
968+
969+// applyJumpSelection scrolls the chat viewport so the user message at
970+// msgIdx is visible at (or near) the top of the chat area. Uses the
971+// anchor slice returned by view.BuildWithAnchors so the mapping from
972+// message index to row is exact, regardless of variable-height tool
973+// blocks above the target.
974+func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
975+ cols := i.lastCols()
976+ chat, anchors := i.view.BuildWithAnchors(cols)
977+ var row int
978+ found := false
979+ for _, a := range anchors {
980+ if a.MessageIdx == msgIdx {
981+ row = a.Row
982+ found = true
983+ break
984+ }
985+ }
986+ if !found {
987+ i.mu.Lock()
988+ i.statusErr = "could not resolve jump target"
989+ i.mu.Unlock()
990+ return
991+ }
992+
993+ chatLen := len(chat)
994+ page := i.chatPage()
995+ if page < 1 {
996+ page = 1
997+ }
998+ // scrollOffset is measured from the bottom of the chat slice, so
999+ // to place `row` at the top of the viewport we want:
1000+ // chatLen - scrollOffset - page == row
1001+ // Solve for scrollOffset and clamp to [0, chatLen-page].
1002+ offset := chatLen - (row + page)
1003+ if offset < 0 {
1004+ offset = 0
1005+ }
1006+ maxOffset := chatLen - page
1007+ if maxOffset < 0 {
1008+ maxOffset = 0
1009+ }
1010+ if offset > maxOffset {
1011+ offset = maxOffset
1012+ }
1013+
1014+ i.mu.Lock()
1015+ i.scrollOffset = offset
1016+ i.parkedTurn = turnNo
1017+ i.parkedTotal = totalTurnsLocked(i.view.Messages)
1018+ i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1019+ i.statusErr = ""
1020+ i.mu.Unlock()
1021+}
1022+
1023+// totalTurnsLocked counts user messages in the transcript. Caller is
1024+// assumed to hold i.mu (the name is a mild reminder; this function
1025+// itself doesn't touch shared state beyond the slice it's handed).
1026+func totalTurnsLocked(msgs []provider.Message) int {
1027+ n := 0
1028+ for _, m := range msgs {
1029+ if m.Role == provider.RoleUser {
1030+ n++
1031+ }
1032+ }
1033+ return n
1034+}
1035+
9011036 // applySessionSelection loads the given session via the cli-provided callback.
9021037 func (i *Interactive) applySessionSelection(path string) {
9031038 if i.cfg.LoadSession == nil {
@@ -916,6 +1051,9 @@ func (i *Interactive) applySessionSelection(path string) {
9161051 i.statusOK = "resumed session: " + path
9171052 i.statusErr = ""
9181053 i.scrollOffset = 0
1054+ i.parkedTurn = 0
1055+ i.parkedTotal = 0
1056+ i.view.InvalidateRenderCache()
9191057 i.mu.Unlock()
9201058 }
9211059
@@ -1060,6 +1198,10 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
10601198 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
10611199 i.toolCalls = map[string]*tui.ToolCallView{}
10621200 i.toolOrder = nil
1201+ // Transcript was rewritten in place — purge the per-message
1202+ // render cache so stale entries keyed on the old messages
1203+ // don't linger.
1204+ i.view.InvalidateRenderCache()
10631205 }
10641206 i.mu.Unlock()
10651207 i.invalidate()
@@ -1082,7 +1224,9 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
10821224 i.toolCalls = map[string]*tui.ToolCallView{}
10831225 i.toolOrder = nil
10841226 i.scrollOffset = 0 // jump back to the bottom on new turn
1085- i.helpBlock = nil // hide the help block once the user asks something
1227+ i.parkedTurn = 0 // starting a turn clears the /jump parked state
1228+ i.parkedTotal = 0
1229+ i.helpBlock = nil // hide the help block once the user asks something
10861230 i.mu.Unlock()
10871231 i.invalidate()
10881232
@@ -125,8 +125,16 @@ type Interactive struct {
125 dialog *loginDialog125 dialog *loginDialog
126 modelDialog *modelDialog126 modelDialog *modelDialog
127 sessionDialog *sessionDialog127 sessionDialog *sessionDialog
128+ jumpDialog *jumpDialog
128 suggest *slashSuggester129 suggest *slashSuggester
129 spin *spinner130 spin *spinner
131+
132+ // parkedTurn is the 1-based turn number the viewport is currently
133+ // scrolled to by /jump. 0 = not parked, showing the tail as usual.
134+ // Rendered as a muted footer at the bottom of the chat so users
135+ // don't forget they're looking at history.
136+ parkedTurn int
137+ parkedTotal int
130 }138 }
131 139
132 // NewInteractive constructs an Interactive from cfg.140 // NewInteractive constructs an Interactive from cfg.
@@ -144,6 +152,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
144 dialog: newLoginDialog(),152 dialog: newLoginDialog(),
145 modelDialog: newModelDialog(),153 modelDialog: newModelDialog(),
146 sessionDialog: newSessionDialog(),154 sessionDialog: newSessionDialog(),
155+ jumpDialog: newJumpDialog(),
147 suggest: newSlashSuggester(),156 suggest: newSlashSuggester(),
148 spin: newSpinner(),157 spin: newSpinner(),
149 }158 }
@@ -283,7 +292,7 @@ func (i *Interactive) Run(ctx context.Context) error {
283 case <-i.dirty:292 case <-i.dirty:
284 requestRedraw()293 requestRedraw()
285 case <-tick.C:294 case <-tick.C:
286- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {295+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() {
287 drainPending()296 drainPending()
288 requestRedraw()297 requestRedraw()
289 }298 }
@@ -316,12 +325,19 @@ func (i *Interactive) chatPage() int {
316 }325 }
317 326
318 // scrollBy adjusts the scroll offset. Positive = up (into history).327 // scrollBy adjusts the scroll offset. Positive = up (into history).
328+// Clearing the parked-turn label when we're back at the bottom means
329+// the "viewing turn N" footer goes away automatically as soon as you
330+// scroll back to the live tail.
319 func (i *Interactive) scrollBy(delta int) {331 func (i *Interactive) scrollBy(delta int) {
320 i.mu.Lock()332 i.mu.Lock()
321 i.scrollOffset += delta333 i.scrollOffset += delta
322 if i.scrollOffset < 0 {334 if i.scrollOffset < 0 {
323 i.scrollOffset = 0335 i.scrollOffset = 0
324 }336 }
337+ if i.scrollOffset == 0 {
338+ i.parkedTurn = 0
339+ i.parkedTotal = 0
340+ }
325 i.mu.Unlock()341 i.mu.Unlock()
326 i.invalidate()342 i.invalidate()
327 }343 }
@@ -330,6 +346,8 @@ func (i *Interactive) scrollBy(delta int) {
330 func (i *Interactive) scrollToBottom() {346 func (i *Interactive) scrollToBottom() {
331 i.mu.Lock()347 i.mu.Lock()
332 i.scrollOffset = 0348 i.scrollOffset = 0
349+ i.parkedTurn = 0
350+ i.parkedTotal = 0
333 i.mu.Unlock()351 i.mu.Unlock()
334 i.invalidate()352 i.invalidate()
335 }353 }
@@ -407,6 +425,8 @@ func (i *Interactive) redraw() {
407 dialog = i.modelDialog.Render(i.cfg.Theme, cols)425 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
408 case i.sessionDialog.Active():426 case i.sessionDialog.Active():
409 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)427 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
428+ case i.jumpDialog.Active():
429+ dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
410 }430 }
411 431
412 // Slash-command autocomplete: popup above the status line, only432 // Slash-command autocomplete: popup above the status line, only
@@ -499,10 +519,18 @@ func (i *Interactive) redraw() {
499 }519 }
500 520
501 // A tiny "scrolled up" indicator in the top-right of the chat pane521 // A tiny "scrolled up" indicator in the top-right of the chat pane
502- // so you know you're not at the bottom.522+ // so you know you're not at the bottom. When the viewport was
523+ // parked by /jump we include the turn number so the user remembers
524+ // they're reading history rather than the live conversation.
503 if i.scrollOffset > 0 && len(visibleChat) > 0 {525 if i.scrollOffset > 0 && len(visibleChat) > 0 {
504- note := i.cfg.Theme.FG256(i.cfg.Theme.Muted,526+ var text string
505- fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset))527+ if i.parkedTurn > 0 && i.parkedTotal > 0 {
528+ text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
529+ i.parkedTurn, i.parkedTotal, i.scrollOffset)
530+ } else {
531+ text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
532+ }
533+ note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
506 visibleChat = append([]string{note}, visibleChat...)534 visibleChat = append([]string{note}, visibleChat...)
507 if len(visibleChat) > chatRows {535 if len(visibleChat) > chatRows {
508 visibleChat = visibleChat[:chatRows]536 visibleChat = visibleChat[:chatRows]
@@ -578,6 +606,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
578 }606 }
579 return false607 return false
580 }608 }
609+ if i.jumpDialog.Active() {
610+ if k.Kind == tui.KeyCtrlC {
611+ i.jumpDialog.Close()
612+ return false
613+ }
614+ act := i.jumpDialog.HandleKey(k)
615+ if act.Select {
616+ i.applyJumpSelection(act.MessageIdx, act.TurnNo)
617+ }
618+ return false
619+ }
581 620
582 // Global keys.621 // Global keys.
583 switch k.Kind {622 switch k.Kind {
@@ -752,6 +791,10 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
752 i.statusErr = ""791 i.statusErr = ""
753 i.statusOK = ""792 i.statusOK = ""
754 i.helpBlock = nil793 i.helpBlock = nil
794+ i.parkedTurn = 0
795+ i.parkedTotal = 0
796+ i.scrollOffset = 0
797+ i.view.InvalidateRenderCache()
755 i.mu.Unlock()798 i.mu.Unlock()
756 case "/help":799 case "/help":
757 i.mu.Lock()800 i.mu.Lock()
@@ -779,6 +822,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
779 }822 }
780 case "/sessions":823 case "/sessions":
781 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)824 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
825+ case "/jump":
826+ i.openJumpDialog(parts[1:])
782 case "/compact":827 case "/compact":
783 i.runCompact(ctx, false)828 i.runCompact(ctx, false)
784 case "/lock":829 case "/lock":
@@ -898,6 +943,96 @@ func (i *Interactive) startOAuthFlow(provider string) {
898 // applyModelSelection switches the active model (and provider, if the943 // applyModelSelection switches the active model (and provider, if the
899 // new model belongs to a different one). It rebuilds the underlying944 // new model belongs to a different one). It rebuilds the underlying
900 // client when needed so the provider wire-protocol matches.945 // client when needed so the provider wire-protocol matches.
946+// openJumpDialog builds a /jump picker from the current transcript.
947+// If the user typed "/jump foo" with a filter and it matches exactly
948+// one turn, jump there directly without showing the dialog.
949+func (i *Interactive) openJumpDialog(args []string) {
950+ if i.view == nil || len(i.view.Messages) == 0 {
951+ i.mu.Lock()
952+ i.statusErr = "nothing to jump to \u2014 the session is empty"
953+ i.mu.Unlock()
954+ return
955+ }
956+ filter := strings.TrimSpace(strings.Join(args, " "))
957+ i.jumpDialog.Open(i.view.Messages, filter)
958+ // Shortcut: with a filter argument that matches exactly one turn,
959+ // jump immediately and skip the picker.
960+ if filter != "" {
961+ if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
962+ t := tgts[0]
963+ i.jumpDialog.Close()
964+ i.applyJumpSelection(t.MessageIdx, t.TurnNo)
965+ }
966+ }
967+}
968+
969+// applyJumpSelection scrolls the chat viewport so the user message at
970+// msgIdx is visible at (or near) the top of the chat area. Uses the
971+// anchor slice returned by view.BuildWithAnchors so the mapping from
972+// message index to row is exact, regardless of variable-height tool
973+// blocks above the target.
974+func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
975+ cols := i.lastCols()
976+ chat, anchors := i.view.BuildWithAnchors(cols)
977+ var row int
978+ found := false
979+ for _, a := range anchors {
980+ if a.MessageIdx == msgIdx {
981+ row = a.Row
982+ found = true
983+ break
984+ }
985+ }
986+ if !found {
987+ i.mu.Lock()
988+ i.statusErr = "could not resolve jump target"
989+ i.mu.Unlock()
990+ return
991+ }
992+
993+ chatLen := len(chat)
994+ page := i.chatPage()
995+ if page < 1 {
996+ page = 1
997+ }
998+ // scrollOffset is measured from the bottom of the chat slice, so
999+ // to place `row` at the top of the viewport we want:
1000+ // chatLen - scrollOffset - page == row
1001+ // Solve for scrollOffset and clamp to [0, chatLen-page].
1002+ offset := chatLen - (row + page)
1003+ if offset < 0 {
1004+ offset = 0
1005+ }
1006+ maxOffset := chatLen - page
1007+ if maxOffset < 0 {
1008+ maxOffset = 0
1009+ }
1010+ if offset > maxOffset {
1011+ offset = maxOffset
1012+ }
1013+
1014+ i.mu.Lock()
1015+ i.scrollOffset = offset
1016+ i.parkedTurn = turnNo
1017+ i.parkedTotal = totalTurnsLocked(i.view.Messages)
1018+ i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1019+ i.statusErr = ""
1020+ i.mu.Unlock()
1021+}
1022+
1023+// totalTurnsLocked counts user messages in the transcript. Caller is
1024+// assumed to hold i.mu (the name is a mild reminder; this function
1025+// itself doesn't touch shared state beyond the slice it's handed).
1026+func totalTurnsLocked(msgs []provider.Message) int {
1027+ n := 0
1028+ for _, m := range msgs {
1029+ if m.Role == provider.RoleUser {
1030+ n++
1031+ }
1032+ }
1033+ return n
1034+}
1035+
901 // applySessionSelection loads the given session via the cli-provided callback.1036 // applySessionSelection loads the given session via the cli-provided callback.
902 func (i *Interactive) applySessionSelection(path string) {1037 func (i *Interactive) applySessionSelection(path string) {
903 if i.cfg.LoadSession == nil {1038 if i.cfg.LoadSession == nil {
@@ -916,6 +1051,9 @@ func (i *Interactive) applySessionSelection(path string) {
916 i.statusOK = "resumed session: " + path1051 i.statusOK = "resumed session: " + path
917 i.statusErr = ""1052 i.statusErr = ""
918 i.scrollOffset = 01053 i.scrollOffset = 0
1054+ i.parkedTurn = 0
1055+ i.parkedTotal = 0
1056+ i.view.InvalidateRenderCache()
919 i.mu.Unlock()1057 i.mu.Unlock()
920 }1058 }
921 1059
@@ -1060,6 +1198,10 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
1060 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement1198 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1061 i.toolCalls = map[string]*tui.ToolCallView{}1199 i.toolCalls = map[string]*tui.ToolCallView{}
1062 i.toolOrder = nil1200 i.toolOrder = nil
1201+ // Transcript was rewritten in place — purge the per-message
1202+ // render cache so stale entries keyed on the old messages
1203+ // don't linger.
1204+ i.view.InvalidateRenderCache()
1063 }1205 }
1064 i.mu.Unlock()1206 i.mu.Unlock()
1065 i.invalidate()1207 i.invalidate()
@@ -1082,7 +1224,9 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
1082 i.toolCalls = map[string]*tui.ToolCallView{}1224 i.toolCalls = map[string]*tui.ToolCallView{}
1083 i.toolOrder = nil1225 i.toolOrder = nil
1084 i.scrollOffset = 0 // jump back to the bottom on new turn1226 i.scrollOffset = 0 // jump back to the bottom on new turn
1085- i.helpBlock = nil // hide the help block once the user asks something1227+ i.parkedTurn = 0 // starting a turn clears the /jump parked state
1228+ i.parkedTotal = 0
1229+ i.helpBlock = nil // hide the help block once the user asks something
1086 i.mu.Unlock()1230 i.mu.Unlock()
1087 i.invalidate()1231 i.invalidate()
1088 1232
A internal/agent/modes/jump_dialog.go +294 -0
@@ -0,0 +1,294 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/provider"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
11+// jumpTarget describes one "turn" in the current session — a user
12+// prompt plus the preview metadata the picker renders. MessageIdx
13+// maps back into view.Messages so we can resolve the row offset via
14+// view.BuildWithAnchors when the user picks a target.
15+type jumpTarget struct {
16+ MessageIdx int // index into view.Messages
17+ TurnNo int // 1-based turn number in session order
18+ Preview string // first ~60 chars of the user prompt (one line)
19+ ToolCount int // tools invoked by the assistant in this turn
20+}
21+
22+// jumpDialog is the picker shown when the user runs /jump. Rows are
23+// turns: one per user message. Filtering happens as the user types;
24+// arrow keys move within the filtered set.
25+type jumpDialog struct {
26+ active bool
27+ all []jumpTarget
28+ visible []jumpTarget // filtered subset
29+ cursor int
30+ filter string
31+}
32+
33+// jumpDialogAction is returned by HandleKey.
34+type jumpDialogAction struct {
35+ Select bool
36+ MessageIdx int
37+ TurnNo int
38+ Close bool
39+}
40+
41+func newJumpDialog() *jumpDialog { return &jumpDialog{} }
42+
43+// Open scans the current transcript for user-message anchor points,
44+// applies the optional initial filter, and makes the dialog visible.
45+// If the filter already narrows the list to exactly one match the
46+// caller should check len(d.visible)==1 and treat that as an
47+// immediate select rather than opening the full picker.
48+func (d *jumpDialog) Open(msgs []provider.Message, initialFilter string) {
49+ d.all = buildJumpTargets(msgs)
50+ d.filter = initialFilter
51+ d.applyFilter()
52+ // Start on the last (most recent) target so enter-without-typing
53+ // goes to the newest turn, which is almost never what you want
54+ // for /jump — flip to the oldest filtered match instead so the
55+ // picker opens at the top of the list.
56+ d.cursor = 0
57+ d.active = true
58+}
59+
60+// Close hides the dialog.
61+func (d *jumpDialog) Close() { d.active = false }
62+
63+// Active reports whether the dialog is visible and consumes input.
64+func (d *jumpDialog) Active() bool { return d != nil && d.active }
65+
66+// Targets returns the current filtered target slice. Interactive uses
67+// this right after Open() to detect the "one unique match" shortcut
68+// and jump without showing the picker.
69+func (d *jumpDialog) Targets() []jumpTarget {
70+ if d == nil {
71+ return nil
72+ }
73+ return d.visible
74+}
75+
76+// applyFilter re-computes d.visible from d.all + d.filter. Matching
77+// is case-insensitive substring on the preview. An empty filter
78+// returns all targets.
79+func (d *jumpDialog) applyFilter() {
80+ if d.filter == "" {
81+ d.visible = append(d.visible[:0], d.all...)
82+ } else {
83+ q := strings.ToLower(d.filter)
84+ d.visible = d.visible[:0]
85+ for _, t := range d.all {
86+ if strings.Contains(strings.ToLower(t.Preview), q) {
87+ d.visible = append(d.visible, t)
88+ }
89+ }
90+ }
91+ if d.cursor >= len(d.visible) {
92+ d.cursor = len(d.visible) - 1
93+ }
94+ if d.cursor < 0 {
95+ d.cursor = 0
96+ }
97+}
98+
99+// Render draws the dialog.
100+func (d *jumpDialog) Render(th tui.Theme, width int) []string {
101+ if !d.Active() {
102+ return nil
103+ }
104+ var lines []string
105+ lines = append(lines, frameHeader(th, "jump to turn", width))
106+ if len(d.all) == 0 {
107+ lines = append(lines, th.FG256(th.Muted, "no turns in this session yet"))
108+ lines = append(lines, th.FG256(th.Muted, "press esc to close"))
109+ lines = append(lines, frameRule(th, width))
110+ return lines
111+ }
112+
113+ // Status line: shows the active filter, visible count, and hints.
114+ hint := "↑/↓ pick · enter jump · esc cancel · type to filter"
115+ if d.filter != "" {
116+ hint = fmt.Sprintf("filter: %q · %d match · ", d.filter, len(d.visible)) + hint
117+ }
118+ lines = append(lines, th.FG256(th.Muted, hint))
119+
120+ if len(d.visible) == 0 {
121+ lines = append(lines, th.FG256(th.Muted, " (nothing matches; backspace to widen)"))
122+ lines = append(lines, frameRule(th, width))
123+ return lines
124+ }
125+
126+ // Cap the visible window so a 200-turn session doesn't push the
127+ // editor off screen. Center around the cursor.
128+ const maxRows = 12
129+ start := 0
130+ end := len(d.visible)
131+ if end > maxRows {
132+ start = d.cursor - maxRows/2
133+ if start < 0 {
134+ start = 0
135+ }
136+ end = start + maxRows
137+ if end > len(d.visible) {
138+ end = len(d.visible)
139+ start = end - maxRows
140+ }
141+ }
142+ if start > 0 {
143+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))
144+ }
145+ for i := start; i < end; i++ {
146+ t := d.visible[i]
147+ plain := " " + formatJumpRowPlain(t, width-2)
148+ if i == d.cursor {
149+ lines = append(lines, th.PadHighlight(plain, width))
150+ } else {
151+ lines = append(lines, th.FG256(th.Muted, plain))
152+ }
153+ }
154+ if end < len(d.visible) {
155+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))
156+ }
157+
158+ lines = append(lines, frameRule(th, width))
159+ return lines
160+}
161+
162+// formatJumpRowPlain renders one target as a single line with turn
163+// number, tool count, and the prompt preview trimmed to fit.
164+func formatJumpRowPlain(t jumpTarget, maxWidth int) string {
165+ left := fmt.Sprintf("#%-3d %s ", t.TurnNo, toolBadge(t.ToolCount))
166+ room := maxWidth - len(left)
167+ if room < 10 {
168+ room = 10
169+ }
170+ preview := t.Preview
171+ if len(preview) > room {
172+ preview = preview[:room-1] + "\u2026"
173+ }
174+ return left + preview
175+}
176+
177+func toolBadge(n int) string {
178+ if n <= 0 {
179+ return " "
180+ }
181+ if n > 99 {
182+ return " 99+t "
183+ }
184+ return fmt.Sprintf(" %2dt ", n)
185+}
186+
187+// HandleKey advances the dialog state and returns an action to apply.
188+// Runes are added to the filter; backspace removes the last rune.
189+func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction {
190+ switch k.Kind {
191+ case tui.KeyUp:
192+ if d.cursor > 0 {
193+ d.cursor--
194+ }
195+ case tui.KeyDown:
196+ if d.cursor < len(d.visible)-1 {
197+ d.cursor++
198+ }
199+ case tui.KeyPageUp:
200+ d.cursor -= 5
201+ if d.cursor < 0 {
202+ d.cursor = 0
203+ }
204+ case tui.KeyPageDown:
205+ d.cursor += 5
206+ if d.cursor >= len(d.visible) {
207+ d.cursor = len(d.visible) - 1
208+ }
209+ case tui.KeyBackspace:
210+ if len(d.filter) > 0 {
211+ runes := []rune(d.filter)
212+ d.filter = string(runes[:len(runes)-1])
213+ d.applyFilter()
214+ }
215+ case tui.KeyRune:
216+ // Any printable rune extends the filter.
217+ d.filter += string(k.Rune)
218+ d.applyFilter()
219+ case tui.KeyEsc:
220+ d.Close()
221+ return jumpDialogAction{Close: true}
222+ case tui.KeyEnter:
223+ if len(d.visible) == 0 {
224+ return jumpDialogAction{}
225+ }
226+ t := d.visible[d.cursor]
227+ d.Close()
228+ return jumpDialogAction{Select: true, MessageIdx: t.MessageIdx, TurnNo: t.TurnNo}
229+ }
230+ return jumpDialogAction{}
231+}
232+
233+// buildJumpTargets walks the session transcript and produces one
234+// jumpTarget per user message, enriched with the tool count from
235+// the following assistant messages up to the next user boundary.
236+func buildJumpTargets(msgs []provider.Message) []jumpTarget {
237+ var out []jumpTarget
238+ turn := 0
239+ for i, m := range msgs {
240+ if m.Role != provider.RoleUser {
241+ continue
242+ }
243+ turn++
244+ t := jumpTarget{
245+ MessageIdx: i,
246+ TurnNo: turn,
247+ Preview: firstLineOfUserMessage(m),
248+ ToolCount: countToolsUntilNextUser(msgs, i),
249+ }
250+ out = append(out, t)
251+ }
252+ return out
253+}
254+
255+// firstLineOfUserMessage returns the first non-empty text line of a
256+// user message, trimmed to a single visible line, for use as a row
257+// preview. Non-text blocks (images) are summarised.
258+func firstLineOfUserMessage(m provider.Message) string {
259+ for _, c := range m.Content {
260+ switch b := c.(type) {
261+ case provider.TextBlock:
262+ for _, line := range strings.Split(b.Text, "\n") {
263+ line = strings.TrimSpace(line)
264+ if line != "" {
265+ return line
266+ }
267+ }
268+ case provider.ImageBlock:
269+ return fmt.Sprintf("[image · %s · %d bytes]", b.MimeType, len(b.Data))
270+ }
271+ }
272+ return "(empty)"
273+}
274+
275+// countToolsUntilNextUser totals the tool calls emitted by assistant
276+// messages between msgs[i] (exclusive) and the next user message
277+// (exclusive). Tool-result messages are ignored because they mirror
278+// the call count 1:1.
279+func countToolsUntilNextUser(msgs []provider.Message, i int) int {
280+ n := 0
281+ for j := i + 1; j < len(msgs); j++ {
282+ if msgs[j].Role == provider.RoleUser {
283+ break
284+ }
285+ if msgs[j].Role == provider.RoleAssistant {
286+ for _, c := range msgs[j].Content {
287+ if _, ok := c.(provider.ToolCallBlock); ok {
288+ n++
289+ }
290+ }
291+ }
292+ }
293+ return n
294+}
@@ -0,0 +1,294 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/provider"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
11+// jumpTarget describes one "turn" in the current session — a user
12+// prompt plus the preview metadata the picker renders. MessageIdx
13+// maps back into view.Messages so we can resolve the row offset via
14+// view.BuildWithAnchors when the user picks a target.
15+type jumpTarget struct {
16+ MessageIdx int // index into view.Messages
17+ TurnNo int // 1-based turn number in session order
18+ Preview string // first ~60 chars of the user prompt (one line)
19+ ToolCount int // tools invoked by the assistant in this turn
20+}
21+
22+// jumpDialog is the picker shown when the user runs /jump. Rows are
23+// turns: one per user message. Filtering happens as the user types;
24+// arrow keys move within the filtered set.
25+type jumpDialog struct {
26+ active bool
27+ all []jumpTarget
28+ visible []jumpTarget // filtered subset
29+ cursor int
30+ filter string
31+}
32+
33+// jumpDialogAction is returned by HandleKey.
34+type jumpDialogAction struct {
35+ Select bool
36+ MessageIdx int
37+ TurnNo int
38+ Close bool
39+}
40+
41+func newJumpDialog() *jumpDialog { return &jumpDialog{} }
42+
43+// Open scans the current transcript for user-message anchor points,
44+// applies the optional initial filter, and makes the dialog visible.
45+// If the filter already narrows the list to exactly one match the
46+// caller should check len(d.visible)==1 and treat that as an
47+// immediate select rather than opening the full picker.
48+func (d *jumpDialog) Open(msgs []provider.Message, initialFilter string) {
49+ d.all = buildJumpTargets(msgs)
50+ d.filter = initialFilter
51+ d.applyFilter()
52+ // Start on the last (most recent) target so enter-without-typing
53+ // goes to the newest turn, which is almost never what you want
54+ // for /jump — flip to the oldest filtered match instead so the
55+ // picker opens at the top of the list.
56+ d.cursor = 0
57+ d.active = true
58+}
59+
60+// Close hides the dialog.
61+func (d *jumpDialog) Close() { d.active = false }
62+
63+// Active reports whether the dialog is visible and consumes input.
64+func (d *jumpDialog) Active() bool { return d != nil && d.active }
65+
66+// Targets returns the current filtered target slice. Interactive uses
67+// this right after Open() to detect the "one unique match" shortcut
68+// and jump without showing the picker.
69+func (d *jumpDialog) Targets() []jumpTarget {
70+ if d == nil {
71+ return nil
72+ }
73+ return d.visible
74+}
75+
76+// applyFilter re-computes d.visible from d.all + d.filter. Matching
77+// is case-insensitive substring on the preview. An empty filter
78+// returns all targets.
79+func (d *jumpDialog) applyFilter() {
80+ if d.filter == "" {
81+ d.visible = append(d.visible[:0], d.all...)
82+ } else {
83+ q := strings.ToLower(d.filter)
84+ d.visible = d.visible[:0]
85+ for _, t := range d.all {
86+ if strings.Contains(strings.ToLower(t.Preview), q) {
87+ d.visible = append(d.visible, t)
88+ }
89+ }
90+ }
91+ if d.cursor >= len(d.visible) {
92+ d.cursor = len(d.visible) - 1
93+ }
94+ if d.cursor < 0 {
95+ d.cursor = 0
96+ }
97+}
98+
99+// Render draws the dialog.
100+func (d *jumpDialog) Render(th tui.Theme, width int) []string {
101+ if !d.Active() {
102+ return nil
103+ }
104+ var lines []string
105+ lines = append(lines, frameHeader(th, "jump to turn", width))
106+ if len(d.all) == 0 {
107+ lines = append(lines, th.FG256(th.Muted, "no turns in this session yet"))
108+ lines = append(lines, th.FG256(th.Muted, "press esc to close"))
109+ lines = append(lines, frameRule(th, width))
110+ return lines
111+ }
112+
113+ // Status line: shows the active filter, visible count, and hints.
114+ hint := "↑/↓ pick · enter jump · esc cancel · type to filter"
115+ if d.filter != "" {
116+ hint = fmt.Sprintf("filter: %q · %d match · ", d.filter, len(d.visible)) + hint
117+ }
118+ lines = append(lines, th.FG256(th.Muted, hint))
119+
120+ if len(d.visible) == 0 {
121+ lines = append(lines, th.FG256(th.Muted, " (nothing matches; backspace to widen)"))
122+ lines = append(lines, frameRule(th, width))
123+ return lines
124+ }
125+
126+ // Cap the visible window so a 200-turn session doesn't push the
127+ // editor off screen. Center around the cursor.
128+ const maxRows = 12
129+ start := 0
130+ end := len(d.visible)
131+ if end > maxRows {
132+ start = d.cursor - maxRows/2
133+ if start < 0 {
134+ start = 0
135+ }
136+ end = start + maxRows
137+ if end > len(d.visible) {
138+ end = len(d.visible)
139+ start = end - maxRows
140+ }
141+ }
142+ if start > 0 {
143+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))
144+ }
145+ for i := start; i < end; i++ {
146+ t := d.visible[i]
147+ plain := " " + formatJumpRowPlain(t, width-2)
148+ if i == d.cursor {
149+ lines = append(lines, th.PadHighlight(plain, width))
150+ } else {
151+ lines = append(lines, th.FG256(th.Muted, plain))
152+ }
153+ }
154+ if end < len(d.visible) {
155+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))
156+ }
157+
158+ lines = append(lines, frameRule(th, width))
159+ return lines
160+}
161+
162+// formatJumpRowPlain renders one target as a single line with turn
163+// number, tool count, and the prompt preview trimmed to fit.
164+func formatJumpRowPlain(t jumpTarget, maxWidth int) string {
165+ left := fmt.Sprintf("#%-3d %s ", t.TurnNo, toolBadge(t.ToolCount))
166+ room := maxWidth - len(left)
167+ if room < 10 {
168+ room = 10
169+ }
170+ preview := t.Preview
171+ if len(preview) > room {
172+ preview = preview[:room-1] + "\u2026"
173+ }
174+ return left + preview
175+}
176+
177+func toolBadge(n int) string {
178+ if n <= 0 {
179+ return " "
180+ }
181+ if n > 99 {
182+ return " 99+t "
183+ }
184+ return fmt.Sprintf(" %2dt ", n)
185+}
186+
187+// HandleKey advances the dialog state and returns an action to apply.
188+// Runes are added to the filter; backspace removes the last rune.
189+func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction {
190+ switch k.Kind {
191+ case tui.KeyUp:
192+ if d.cursor > 0 {
193+ d.cursor--
194+ }
195+ case tui.KeyDown:
196+ if d.cursor < len(d.visible)-1 {
197+ d.cursor++
198+ }
199+ case tui.KeyPageUp:
200+ d.cursor -= 5
201+ if d.cursor < 0 {
202+ d.cursor = 0
203+ }
204+ case tui.KeyPageDown:
205+ d.cursor += 5
206+ if d.cursor >= len(d.visible) {
207+ d.cursor = len(d.visible) - 1
208+ }
209+ case tui.KeyBackspace:
210+ if len(d.filter) > 0 {
211+ runes := []rune(d.filter)
212+ d.filter = string(runes[:len(runes)-1])
213+ d.applyFilter()
214+ }
215+ case tui.KeyRune:
216+ // Any printable rune extends the filter.
217+ d.filter += string(k.Rune)
218+ d.applyFilter()
219+ case tui.KeyEsc:
220+ d.Close()
221+ return jumpDialogAction{Close: true}
222+ case tui.KeyEnter:
223+ if len(d.visible) == 0 {
224+ return jumpDialogAction{}
225+ }
226+ t := d.visible[d.cursor]
227+ d.Close()
228+ return jumpDialogAction{Select: true, MessageIdx: t.MessageIdx, TurnNo: t.TurnNo}
229+ }
230+ return jumpDialogAction{}
231+}
232+
233+// buildJumpTargets walks the session transcript and produces one
234+// jumpTarget per user message, enriched with the tool count from
235+// the following assistant messages up to the next user boundary.
236+func buildJumpTargets(msgs []provider.Message) []jumpTarget {
237+ var out []jumpTarget
238+ turn := 0
239+ for i, m := range msgs {
240+ if m.Role != provider.RoleUser {
241+ continue
242+ }
243+ turn++
244+ t := jumpTarget{
245+ MessageIdx: i,
246+ TurnNo: turn,
247+ Preview: firstLineOfUserMessage(m),
248+ ToolCount: countToolsUntilNextUser(msgs, i),
249+ }
250+ out = append(out, t)
251+ }
252+ return out
253+}
254+
255+// firstLineOfUserMessage returns the first non-empty text line of a
256+// user message, trimmed to a single visible line, for use as a row
257+// preview. Non-text blocks (images) are summarised.
258+func firstLineOfUserMessage(m provider.Message) string {
259+ for _, c := range m.Content {
260+ switch b := c.(type) {
261+ case provider.TextBlock:
262+ for _, line := range strings.Split(b.Text, "\n") {
263+ line = strings.TrimSpace(line)
264+ if line != "" {
265+ return line
266+ }
267+ }
268+ case provider.ImageBlock:
269+ return fmt.Sprintf("[image · %s · %d bytes]", b.MimeType, len(b.Data))
270+ }
271+ }
272+ return "(empty)"
273+}
274+
275+// countToolsUntilNextUser totals the tool calls emitted by assistant
276+// messages between msgs[i] (exclusive) and the next user message
277+// (exclusive). Tool-result messages are ignored because they mirror
278+// the call count 1:1.
279+func countToolsUntilNextUser(msgs []provider.Message, i int) int {
280+ n := 0
281+ for j := i + 1; j < len(msgs); j++ {
282+ if msgs[j].Role == provider.RoleUser {
283+ break
284+ }
285+ if msgs[j].Role == provider.RoleAssistant {
286+ for _, c := range msgs[j].Content {
287+ if _, ok := c.(provider.ToolCallBlock); ok {
288+ n++
289+ }
290+ }
291+ }
292+ }
293+ return n
294+}
M internal/agent/modes/slash_suggest.go +1 -0
@@ -20,6 +20,7 @@ var slashCatalog = []slashCommand{
2020 {"/logout", "clear a provider's credentials"},
2121 {"/model", "pick a model (or /model <id>)"},
2222 {"/sessions", "resume a previous session for this directory"},
23+ {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
2324 {"/compact", "summarize and replace the transcript to free up context"},
2425 {"/lock", "confine tools to the current directory"},
2526 {"/unlock", "allow tools to touch paths outside this directory"},
@@ -20,6 +20,7 @@ var slashCatalog = []slashCommand{
20 {"/logout", "clear a provider's credentials"},20 {"/logout", "clear a provider's credentials"},
21 {"/model", "pick a model (or /model <id>)"},21 {"/model", "pick a model (or /model <id>)"},
22 {"/sessions", "resume a previous session for this directory"},22 {"/sessions", "resume a previous session for this directory"},
23+ {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
23 {"/compact", "summarize and replace the transcript to free up context"},24 {"/compact", "summarize and replace the transcript to free up context"},
24 {"/lock", "confine tools to the current directory"},25 {"/lock", "confine tools to the current directory"},
25 {"/unlock", "allow tools to touch paths outside this directory"},26 {"/unlock", "allow tools to touch paths outside this directory"},
M internal/tui/render.go +8 -0
@@ -68,10 +68,18 @@ func containsImageEscape(s string) bool {
6868 // cells, preserving ANSI CSI escape sequences (which don't consume
6969 // cells). Lines carrying an inline-image escape are returned as-is
7070 // since we can't measure their painted size.
71+//
72+// Fast path: a byte-length <= cols is a conservative upper bound
73+// guaranteeing the cell width is also <= cols, so we skip all the
74+// rune-width math. That covers the vast majority of lines in a
75+// transcript (narrow terminals wrap early; wide ones leave headroom).
7176 func truncateToWidth(s string, cols int) string {
7277 if cols <= 0 || containsImageEscape(s) {
7378 return s
7479 }
80+ if len(s) <= cols {
81+ return s
82+ }
7583 var out strings.Builder
7684 out.Grow(len(s))
7785 seen := 0
@@ -68,10 +68,18 @@ func containsImageEscape(s string) bool {
68 // cells, preserving ANSI CSI escape sequences (which don't consume68 // cells, preserving ANSI CSI escape sequences (which don't consume
69 // cells). Lines carrying an inline-image escape are returned as-is69 // cells). Lines carrying an inline-image escape are returned as-is
70 // since we can't measure their painted size.70 // since we can't measure their painted size.
71+//
72+// Fast path: a byte-length <= cols is a conservative upper bound
73+// guaranteeing the cell width is also <= cols, so we skip all the
74+// rune-width math. That covers the vast majority of lines in a
75+// transcript (narrow terminals wrap early; wide ones leave headroom).
71 func truncateToWidth(s string, cols int) string {76 func truncateToWidth(s string, cols int) string {
72 if cols <= 0 || containsImageEscape(s) {77 if cols <= 0 || containsImageEscape(s) {
73 return s78 return s
74 }79 }
80+ if len(s) <= cols {
81+ return s
82+ }
75 var out strings.Builder83 var out strings.Builder
76 out.Grow(len(s))84 out.Grow(len(s))
77 seen := 085 seen := 0
M internal/tui/view.go +192 -16
@@ -55,6 +55,34 @@ type View struct {
5555 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
5656 // "... (N more lines, M total, ctrl+o to expand)" footer.
5757 ExpandAll bool
58+
59+ // renderCache holds the per-message rendered line slices so Build
60+ // doesn't re-markdown every message on every frame. Keyed by a
61+ // struct of (content hash, width, expandAll) — any of those
62+ // changing invalidates the entry. Messages are append-only after
63+ // they finalise so keeping the cache across turns is safe.
64+ //
65+ // Streaming/in-flight work (v.Streaming, v.ToolCalls) is never
66+ // cached because it changes every delta.
67+ renderCache map[msgCacheKey][]string
68+}
69+
70+// msgCacheKey identifies a cached message render. hash is a 64-bit
71+// FNV-1a of the message's content, which is cheap to compute and
72+// unambiguous enough for the cache (collisions produce a stale frame,
73+// not wrong data, and we recompute on invalidation anyway).
74+type msgCacheKey struct {
75+ hash uint64
76+ width int
77+ expandAll bool
78+}
79+
80+// InvalidateRenderCache drops all cached message renders. The tui
81+// calls this when the transcript is replaced wholesale (/compact,
82+// /clear, session swap) since messages can be replaced in place and
83+// a content-hash miss alone doesn't reclaim the old entries.
84+func (v *View) InvalidateRenderCache() {
85+ v.renderCache = nil
5886 }
5987
6088 // ToolCollapsePreview is the number of lines shown before a long tool
@@ -75,24 +103,48 @@ type ToolCallView struct {
75103 Done bool
76104 }
77105
106+// MessageAnchor records where a rendered message starts in the chat
107+// line slice. Used by /jump so the dialog can scroll the viewport to
108+// the row where a turn's user prompt begins.
109+type MessageAnchor struct {
110+ MessageIdx int // index into v.Messages
111+ Row int // first row of that message in the Build() output
112+}
113+
78114 // Build returns the chat log lines for the given width.
79115 func (v *View) Build(width int) []string {
80- // Map tool_use_id -> path argument, if any, so tool results can be
81- // rendered with the file's language for syntax highlighting.
82- v.toolPaths = map[string]string{}
83- for _, m := range v.Messages {
84- for _, c := range m.Content {
85- if tc, ok := c.(provider.ToolCallBlock); ok {
86- if p := pathFromToolArgs(tc.Arguments); p != "" {
87- v.toolPaths[tc.ID] = p
88- }
89- }
90- }
91- }
116+ lines, _ := v.BuildWithAnchors(width)
117+ return lines
118+}
92119
93- var out []string
94- for _, m := range v.Messages {
95- out = append(out, v.renderMessage(m, width)...)
120+// BuildWithAnchors is like Build but additionally reports the first
121+// row occupied by each message in v.Messages. Callers that need to
122+// scroll to a specific turn (the /jump dialog) use the anchor slice
123+// to map a message index back to a row offset.
124+func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
125+ v.refreshToolPaths()
126+ if v.renderCache == nil {
127+ v.renderCache = make(map[msgCacheKey][]string)
128+ }
129+
130+ // Pre-render every message (hits the cache for unchanged ones) so
131+ // we can allocate `out` in a single shot with the exact capacity.
132+ // Growing via append on a long transcript copies the backing array
133+ // log2(N) times; for a 2000-line scrollback that's enough memcpy
134+ // to visibly stutter while typing.
135+ rendered := make([][]string, len(v.Messages))
136+ total := 0
137+ for idx, m := range v.Messages {
138+ lines := v.renderMessageCached(m, width)
139+ rendered[idx] = lines
140+ total += len(lines) + 1 // +1 for the blank separator row
141+ }
142+
143+ out := make([]string, 0, total+16)
144+ anchors := make([]MessageAnchor, 0, len(v.Messages))
145+ for idx := range v.Messages {
146+ anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
147+ out = append(out, rendered[idx]...)
96148 out = append(out, "")
97149 }
98150 if v.StreamingActive {
@@ -117,7 +169,131 @@ func (v *View) Build(width int) []string {
117169 out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
118170 out = append(out, "")
119171 }
120- return out
172+ return out, anchors
173+}
174+
175+// refreshToolPaths rebuilds the tool_use_id -> path map from the
176+// current transcript. Called once per Build() so tool result blocks
177+// (which may be cached) can look up their syntax language when they
178+// were originally rendered. Walking the transcript here is O(N) but
179+// cheap compared to markdown/chroma work it enables.
180+func (v *View) refreshToolPaths() {
181+ v.toolPaths = map[string]string{}
182+ for _, m := range v.Messages {
183+ for _, c := range m.Content {
184+ if tc, ok := c.(provider.ToolCallBlock); ok {
185+ if p := pathFromToolArgs(tc.Arguments); p != "" {
186+ v.toolPaths[tc.ID] = p
187+ }
188+ }
189+ }
190+ }
191+}
192+
193+// renderMessageCached returns the rendered line slice for m, using the
194+// cache if the same (content hash, width, expandAll) combination has
195+// been rendered before. The slice returned is shared — callers must
196+// not mutate it; Build() only ever appends to its own `out` so the
197+// shared slice is safe.
198+func (v *View) renderMessageCached(m provider.Message, width int) []string {
199+ key := msgCacheKey{
200+ hash: hashMessage(m),
201+ width: width,
202+ expandAll: v.ExpandAll,
203+ }
204+ if v.renderCache != nil {
205+ if lines, ok := v.renderCache[key]; ok {
206+ return lines
207+ }
208+ }
209+ lines := v.renderMessage(m, width)
210+ if v.renderCache != nil {
211+ // Bound the cache: 4x the current message count is enough to
212+ // survive /compact churn without leaking memory across a very
213+ // long session.
214+ max := len(v.Messages) * 4
215+ if max < 32 {
216+ max = 32
217+ }
218+ if len(v.renderCache) > max {
219+ // Drop half the entries. map iteration order gives us a
220+ // pseudo-LRU for free.
221+ dropped := 0
222+ target := len(v.renderCache) / 2
223+ for k := range v.renderCache {
224+ if dropped >= target {
225+ break
226+ }
227+ delete(v.renderCache, k)
228+ dropped++
229+ }
230+ }
231+ v.renderCache[key] = lines
232+ }
233+ return lines
234+}
235+
236+// hashMessage returns a 64-bit FNV-1a over the role + content blocks
237+// of m. Serialising each block to its salient bytes is enough: two
238+// messages with the same role and same content render identically.
239+func hashMessage(m provider.Message) uint64 {
240+ h := fnv64aInit
241+ h = fnv64aWrite(h, []byte(m.Role))
242+ h = fnv64aWriteByte(h, 0)
243+ for _, c := range m.Content {
244+ switch b := c.(type) {
245+ case provider.TextBlock:
246+ h = fnv64aWriteByte(h, 't')
247+ h = fnv64aWrite(h, []byte(b.Text))
248+ case provider.ImageBlock:
249+ h = fnv64aWriteByte(h, 'i')
250+ h = fnv64aWrite(h, []byte(b.MimeType))
251+ h = fnv64aWrite(h, b.Data)
252+ case provider.ToolCallBlock:
253+ h = fnv64aWriteByte(h, 'c')
254+ h = fnv64aWrite(h, []byte(b.ID))
255+ h = fnv64aWrite(h, []byte(b.Name))
256+ h = fnv64aWrite(h, []byte(b.Arguments))
257+ case provider.ToolResultBlock:
258+ h = fnv64aWriteByte(h, 'r')
259+ h = fnv64aWrite(h, []byte(b.CallID))
260+ if b.IsError {
261+ h = fnv64aWriteByte(h, 'E')
262+ }
263+ for _, inner := range b.Content {
264+ switch ib := inner.(type) {
265+ case provider.TextBlock:
266+ h = fnv64aWrite(h, []byte(ib.Text))
267+ case provider.ImageBlock:
268+ h = fnv64aWrite(h, []byte(ib.MimeType))
269+ h = fnv64aWrite(h, ib.Data)
270+ }
271+ }
272+ }
273+ h = fnv64aWriteByte(h, 0)
274+ }
275+ return h
276+}
277+
278+// FNV-1a implementation inlined so we don't pay the interface cost of
279+// hash.Hash64 on every Build(). The whole point here is speed.
280+const (
281+ fnv64aInit uint64 = 0xcbf29ce484222325
282+ fnv64aPrime uint64 = 0x100000001b3
283+)
284+
285+func fnv64aWriteByte(h uint64, b byte) uint64 {
286+ h ^= uint64(b)
287+ h *= fnv64aPrime
288+ return h
289+}
290+
291+func fnv64aWrite(h uint64, p []byte) uint64 {
292+ for _, b := range p {
293+ h ^= uint64(b)
294+ h *= fnv64aPrime
295+ }
296+ return h
121297 }
122298
123299 func (v *View) renderMessage(m provider.Message, width int) []string {
@@ -55,6 +55,34 @@ type View struct {
55 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a55 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
56 // "... (N more lines, M total, ctrl+o to expand)" footer.56 // "... (N more lines, M total, ctrl+o to expand)" footer.
57 ExpandAll bool57 ExpandAll bool
58+
59+ // renderCache holds the per-message rendered line slices so Build
60+ // doesn't re-markdown every message on every frame. Keyed by a
61+ // struct of (content hash, width, expandAll) — any of those
62+ // changing invalidates the entry. Messages are append-only after
63+ // they finalise so keeping the cache across turns is safe.
64+ //
65+ // Streaming/in-flight work (v.Streaming, v.ToolCalls) is never
66+ // cached because it changes every delta.
67+ renderCache map[msgCacheKey][]string
68+}
69+
70+// msgCacheKey identifies a cached message render. hash is a 64-bit
71+// FNV-1a of the message's content, which is cheap to compute and
72+// unambiguous enough for the cache (collisions produce a stale frame,
73+// not wrong data, and we recompute on invalidation anyway).
74+type msgCacheKey struct {
75+ hash uint64
76+ width int
77+ expandAll bool
78+}
79+
80+// InvalidateRenderCache drops all cached message renders. The tui
81+// calls this when the transcript is replaced wholesale (/compact,
82+// /clear, session swap) since messages can be replaced in place and
83+// a content-hash miss alone doesn't reclaim the old entries.
84+func (v *View) InvalidateRenderCache() {
85+ v.renderCache = nil
58 }86 }
59 87
60 // ToolCollapsePreview is the number of lines shown before a long tool88 // ToolCollapsePreview is the number of lines shown before a long tool
@@ -75,24 +103,48 @@ type ToolCallView struct {
75 Done bool103 Done bool
76 }104 }
77 105
106+// MessageAnchor records where a rendered message starts in the chat
107+// line slice. Used by /jump so the dialog can scroll the viewport to
108+// the row where a turn's user prompt begins.
109+type MessageAnchor struct {
110+ MessageIdx int // index into v.Messages
111+ Row int // first row of that message in the Build() output
112+}
113+
78 // Build returns the chat log lines for the given width.114 // Build returns the chat log lines for the given width.
79 func (v *View) Build(width int) []string {115 func (v *View) Build(width int) []string {
80- // Map tool_use_id -> path argument, if any, so tool results can be116+ lines, _ := v.BuildWithAnchors(width)
81- // rendered with the file's language for syntax highlighting.117+ return lines
82- v.toolPaths = map[string]string{}118+}
83- for _, m := range v.Messages {
84- for _, c := range m.Content {
85- if tc, ok := c.(provider.ToolCallBlock); ok {
86- if p := pathFromToolArgs(tc.Arguments); p != "" {
87- v.toolPaths[tc.ID] = p
88- }
89- }
90- }
91- }
92 119
93- var out []string120+// BuildWithAnchors is like Build but additionally reports the first
94- for _, m := range v.Messages {121+// row occupied by each message in v.Messages. Callers that need to
95- out = append(out, v.renderMessage(m, width)...)122+// scroll to a specific turn (the /jump dialog) use the anchor slice
123+// to map a message index back to a row offset.
124+func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
125+ v.refreshToolPaths()
126+ if v.renderCache == nil {
127+ v.renderCache = make(map[msgCacheKey][]string)
128+ }
129+
130+ // Pre-render every message (hits the cache for unchanged ones) so
131+ // we can allocate `out` in a single shot with the exact capacity.
132+ // Growing via append on a long transcript copies the backing array
133+ // log2(N) times; for a 2000-line scrollback that's enough memcpy
134+ // to visibly stutter while typing.
135+ rendered := make([][]string, len(v.Messages))
136+ total := 0
137+ for idx, m := range v.Messages {
138+ lines := v.renderMessageCached(m, width)
139+ rendered[idx] = lines
140+ total += len(lines) + 1 // +1 for the blank separator row
141+ }
142+
143+ out := make([]string, 0, total+16)
144+ anchors := make([]MessageAnchor, 0, len(v.Messages))
145+ for idx := range v.Messages {
146+ anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
147+ out = append(out, rendered[idx]...)
96 out = append(out, "")148 out = append(out, "")
97 }149 }
98 if v.StreamingActive {150 if v.StreamingActive {
@@ -117,7 +169,131 @@ func (v *View) Build(width int) []string {
117 out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))169 out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
118 out = append(out, "")170 out = append(out, "")
119 }171 }
120- return out172+ return out, anchors
173+}
174+
175+// refreshToolPaths rebuilds the tool_use_id -> path map from the
176+// current transcript. Called once per Build() so tool result blocks
177+// (which may be cached) can look up their syntax language when they
178+// were originally rendered. Walking the transcript here is O(N) but
179+// cheap compared to markdown/chroma work it enables.
180+func (v *View) refreshToolPaths() {
181+ v.toolPaths = map[string]string{}
182+ for _, m := range v.Messages {
183+ for _, c := range m.Content {
184+ if tc, ok := c.(provider.ToolCallBlock); ok {
185+ if p := pathFromToolArgs(tc.Arguments); p != "" {
186+ v.toolPaths[tc.ID] = p
187+ }
188+ }
189+ }
190+ }
191+}
192+
193+// renderMessageCached returns the rendered line slice for m, using the
194+// cache if the same (content hash, width, expandAll) combination has
195+// been rendered before. The slice returned is shared — callers must
196+// not mutate it; Build() only ever appends to its own `out` so the
197+// shared slice is safe.
198+func (v *View) renderMessageCached(m provider.Message, width int) []string {
199+ key := msgCacheKey{
200+ hash: hashMessage(m),
201+ width: width,
202+ expandAll: v.ExpandAll,
203+ }
204+ if v.renderCache != nil {
205+ if lines, ok := v.renderCache[key]; ok {
206+ return lines
207+ }
208+ }
209+ lines := v.renderMessage(m, width)
210+ if v.renderCache != nil {
211+ // Bound the cache: 4x the current message count is enough to
212+ // survive /compact churn without leaking memory across a very
213+ // long session.
214+ max := len(v.Messages) * 4
215+ if max < 32 {
216+ max = 32
217+ }
218+ if len(v.renderCache) > max {
219+ // Drop half the entries. map iteration order gives us a
220+ // pseudo-LRU for free.
221+ dropped := 0
222+ target := len(v.renderCache) / 2
223+ for k := range v.renderCache {
224+ if dropped >= target {
225+ break
226+ }
227+ delete(v.renderCache, k)
228+ dropped++
229+ }
230+ }
231+ v.renderCache[key] = lines
232+ }
233+ return lines
234+}
235+
236+// hashMessage returns a 64-bit FNV-1a over the role + content blocks
237+// of m. Serialising each block to its salient bytes is enough: two
238+// messages with the same role and same content render identically.
239+func hashMessage(m provider.Message) uint64 {
240+ h := fnv64aInit
241+ h = fnv64aWrite(h, []byte(m.Role))
242+ h = fnv64aWriteByte(h, 0)
243+ for _, c := range m.Content {
244+ switch b := c.(type) {
245+ case provider.TextBlock:
246+ h = fnv64aWriteByte(h, 't')
247+ h = fnv64aWrite(h, []byte(b.Text))
248+ case provider.ImageBlock:
249+ h = fnv64aWriteByte(h, 'i')
250+ h = fnv64aWrite(h, []byte(b.MimeType))
251+ h = fnv64aWrite(h, b.Data)
252+ case provider.ToolCallBlock:
253+ h = fnv64aWriteByte(h, 'c')
254+ h = fnv64aWrite(h, []byte(b.ID))
255+ h = fnv64aWrite(h, []byte(b.Name))
256+ h = fnv64aWrite(h, []byte(b.Arguments))
257+ case provider.ToolResultBlock:
258+ h = fnv64aWriteByte(h, 'r')
259+ h = fnv64aWrite(h, []byte(b.CallID))
260+ if b.IsError {
261+ h = fnv64aWriteByte(h, 'E')
262+ }
263+ for _, inner := range b.Content {
264+ switch ib := inner.(type) {
265+ case provider.TextBlock:
266+ h = fnv64aWrite(h, []byte(ib.Text))
267+ case provider.ImageBlock:
268+ h = fnv64aWrite(h, []byte(ib.MimeType))
269+ h = fnv64aWrite(h, ib.Data)
270+ }
271+ }
272+ }
273+ h = fnv64aWriteByte(h, 0)
274+ }
275+ return h
276+}
277+
278+// FNV-1a implementation inlined so we don't pay the interface cost of
279+// hash.Hash64 on every Build(). The whole point here is speed.
280+const (
281+ fnv64aInit uint64 = 0xcbf29ce484222325
282+ fnv64aPrime uint64 = 0x100000001b3
283+)
284+
285+func fnv64aWriteByte(h uint64, b byte) uint64 {
286+ h ^= uint64(b)
287+ h *= fnv64aPrime
288+ return h
289+}
290+
291+func fnv64aWrite(h uint64, p []byte) uint64 {
292+ for _, b := range p {
293+ h ^= uint64(b)
294+ h *= fnv64aPrime
295+ }
296+ return h
121 }297 }
122 298
123 func (v *View) renderMessage(m provider.Message, width int) []string {299 func (v *View) renderMessage(m provider.Message, width int) []string {

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