mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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.
This commit is contained in:
parent
7141ebd45f
commit
64704875d2
6 changed files with 650 additions and 20 deletions
|
|
@ -163,6 +163,7 @@ type `/` in the tui to open the autocomplete popup. available commands:
|
|||
| `/logout [provider]` | clear credentials for `anthropic`, `openai`, or all when omitted |
|
||||
| `/model` | pick a model from a list (or `/model <id>` to set directly) |
|
||||
| `/sessions` | resume a previous session for this directory |
|
||||
| `/jump` | scroll the chat to a previous turn (or `/jump <text>` to filter) |
|
||||
| `/compact` | summarize the transcript into one message to free up context |
|
||||
| `/lock` | confine tools to the current directory |
|
||||
| `/unlock` | allow tools to touch paths outside again |
|
||||
|
|
@ -173,6 +174,12 @@ type `/` in the tui to open the autocomplete popup. available commands:
|
|||
|
||||
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.
|
||||
|
||||
### `/jump`
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### `/compact`
|
||||
|
||||
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%.
|
||||
|
|
|
|||
|
|
@ -125,8 +125,16 @@ type Interactive struct {
|
|||
dialog *loginDialog
|
||||
modelDialog *modelDialog
|
||||
sessionDialog *sessionDialog
|
||||
jumpDialog *jumpDialog
|
||||
suggest *slashSuggester
|
||||
spin *spinner
|
||||
|
||||
// parkedTurn is the 1-based turn number the viewport is currently
|
||||
// scrolled to by /jump. 0 = not parked, showing the tail as usual.
|
||||
// Rendered as a muted footer at the bottom of the chat so users
|
||||
// don't forget they're looking at history.
|
||||
parkedTurn int
|
||||
parkedTotal int
|
||||
}
|
||||
|
||||
// NewInteractive constructs an Interactive from cfg.
|
||||
|
|
@ -144,6 +152,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
|
|||
dialog: newLoginDialog(),
|
||||
modelDialog: newModelDialog(),
|
||||
sessionDialog: newSessionDialog(),
|
||||
jumpDialog: newJumpDialog(),
|
||||
suggest: newSlashSuggester(),
|
||||
spin: newSpinner(),
|
||||
}
|
||||
|
|
@ -283,7 +292,7 @@ func (i *Interactive) Run(ctx context.Context) error {
|
|||
case <-i.dirty:
|
||||
requestRedraw()
|
||||
case <-tick.C:
|
||||
if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {
|
||||
if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() {
|
||||
drainPending()
|
||||
requestRedraw()
|
||||
}
|
||||
|
|
@ -316,12 +325,19 @@ func (i *Interactive) chatPage() int {
|
|||
}
|
||||
|
||||
// scrollBy adjusts the scroll offset. Positive = up (into history).
|
||||
// Clearing the parked-turn label when we're back at the bottom means
|
||||
// the "viewing turn N" footer goes away automatically as soon as you
|
||||
// scroll back to the live tail.
|
||||
func (i *Interactive) scrollBy(delta int) {
|
||||
i.mu.Lock()
|
||||
i.scrollOffset += delta
|
||||
if i.scrollOffset < 0 {
|
||||
i.scrollOffset = 0
|
||||
}
|
||||
if i.scrollOffset == 0 {
|
||||
i.parkedTurn = 0
|
||||
i.parkedTotal = 0
|
||||
}
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
}
|
||||
|
|
@ -330,6 +346,8 @@ func (i *Interactive) scrollBy(delta int) {
|
|||
func (i *Interactive) scrollToBottom() {
|
||||
i.mu.Lock()
|
||||
i.scrollOffset = 0
|
||||
i.parkedTurn = 0
|
||||
i.parkedTotal = 0
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
}
|
||||
|
|
@ -407,6 +425,8 @@ func (i *Interactive) redraw() {
|
|||
dialog = i.modelDialog.Render(i.cfg.Theme, cols)
|
||||
case i.sessionDialog.Active():
|
||||
dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
|
||||
case i.jumpDialog.Active():
|
||||
dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
|
||||
}
|
||||
|
||||
// Slash-command autocomplete: popup above the status line, only
|
||||
|
|
@ -499,10 +519,18 @@ func (i *Interactive) redraw() {
|
|||
}
|
||||
|
||||
// A tiny "scrolled up" indicator in the top-right of the chat pane
|
||||
// so you know you're not at the bottom.
|
||||
// so you know you're not at the bottom. When the viewport was
|
||||
// parked by /jump we include the turn number so the user remembers
|
||||
// they're reading history rather than the live conversation.
|
||||
if i.scrollOffset > 0 && len(visibleChat) > 0 {
|
||||
note := i.cfg.Theme.FG256(i.cfg.Theme.Muted,
|
||||
fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset))
|
||||
var text string
|
||||
if i.parkedTurn > 0 && i.parkedTotal > 0 {
|
||||
text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
|
||||
i.parkedTurn, i.parkedTotal, i.scrollOffset)
|
||||
} else {
|
||||
text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
|
||||
}
|
||||
note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
|
||||
visibleChat = append([]string{note}, visibleChat...)
|
||||
if len(visibleChat) > chatRows {
|
||||
visibleChat = visibleChat[:chatRows]
|
||||
|
|
@ -578,6 +606,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
|
|||
}
|
||||
return false
|
||||
}
|
||||
if i.jumpDialog.Active() {
|
||||
if k.Kind == tui.KeyCtrlC {
|
||||
i.jumpDialog.Close()
|
||||
return false
|
||||
}
|
||||
act := i.jumpDialog.HandleKey(k)
|
||||
if act.Select {
|
||||
i.applyJumpSelection(act.MessageIdx, act.TurnNo)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Global keys.
|
||||
switch k.Kind {
|
||||
|
|
@ -752,6 +791,10 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
|
|||
i.statusErr = ""
|
||||
i.statusOK = ""
|
||||
i.helpBlock = nil
|
||||
i.parkedTurn = 0
|
||||
i.parkedTotal = 0
|
||||
i.scrollOffset = 0
|
||||
i.view.InvalidateRenderCache()
|
||||
i.mu.Unlock()
|
||||
case "/help":
|
||||
i.mu.Lock()
|
||||
|
|
@ -779,6 +822,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
|
|||
}
|
||||
case "/sessions":
|
||||
i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
|
||||
case "/jump":
|
||||
i.openJumpDialog(parts[1:])
|
||||
case "/compact":
|
||||
i.runCompact(ctx, false)
|
||||
case "/lock":
|
||||
|
|
@ -898,6 +943,96 @@ func (i *Interactive) startOAuthFlow(provider string) {
|
|||
// applyModelSelection switches the active model (and provider, if the
|
||||
// new model belongs to a different one). It rebuilds the underlying
|
||||
// client when needed so the provider wire-protocol matches.
|
||||
// openJumpDialog builds a /jump picker from the current transcript.
|
||||
// If the user typed "/jump foo" with a filter and it matches exactly
|
||||
// one turn, jump there directly without showing the dialog.
|
||||
func (i *Interactive) openJumpDialog(args []string) {
|
||||
if i.view == nil || len(i.view.Messages) == 0 {
|
||||
i.mu.Lock()
|
||||
i.statusErr = "nothing to jump to \u2014 the session is empty"
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
filter := strings.TrimSpace(strings.Join(args, " "))
|
||||
i.jumpDialog.Open(i.view.Messages, filter)
|
||||
// Shortcut: with a filter argument that matches exactly one turn,
|
||||
// jump immediately and skip the picker.
|
||||
if filter != "" {
|
||||
if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
|
||||
t := tgts[0]
|
||||
i.jumpDialog.Close()
|
||||
i.applyJumpSelection(t.MessageIdx, t.TurnNo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyJumpSelection scrolls the chat viewport so the user message at
|
||||
// msgIdx is visible at (or near) the top of the chat area. Uses the
|
||||
// anchor slice returned by view.BuildWithAnchors so the mapping from
|
||||
// message index to row is exact, regardless of variable-height tool
|
||||
// blocks above the target.
|
||||
func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
|
||||
cols := i.lastCols()
|
||||
chat, anchors := i.view.BuildWithAnchors(cols)
|
||||
var row int
|
||||
found := false
|
||||
for _, a := range anchors {
|
||||
if a.MessageIdx == msgIdx {
|
||||
row = a.Row
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
i.mu.Lock()
|
||||
i.statusErr = "could not resolve jump target"
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
chatLen := len(chat)
|
||||
page := i.chatPage()
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
// scrollOffset is measured from the bottom of the chat slice, so
|
||||
// to place `row` at the top of the viewport we want:
|
||||
// chatLen - scrollOffset - page == row
|
||||
// Solve for scrollOffset and clamp to [0, chatLen-page].
|
||||
offset := chatLen - (row + page)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
maxOffset := chatLen - page
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if offset > maxOffset {
|
||||
offset = maxOffset
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
i.scrollOffset = offset
|
||||
i.parkedTurn = turnNo
|
||||
i.parkedTotal = totalTurnsLocked(i.view.Messages)
|
||||
i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
|
||||
i.statusErr = ""
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// totalTurnsLocked counts user messages in the transcript. Caller is
|
||||
// assumed to hold i.mu (the name is a mild reminder; this function
|
||||
// itself doesn't touch shared state beyond the slice it's handed).
|
||||
func totalTurnsLocked(msgs []provider.Message) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if m.Role == provider.RoleUser {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// applySessionSelection loads the given session via the cli-provided callback.
|
||||
func (i *Interactive) applySessionSelection(path string) {
|
||||
if i.cfg.LoadSession == nil {
|
||||
|
|
@ -916,6 +1051,9 @@ func (i *Interactive) applySessionSelection(path string) {
|
|||
i.statusOK = "resumed session: " + path
|
||||
i.statusErr = ""
|
||||
i.scrollOffset = 0
|
||||
i.parkedTurn = 0
|
||||
i.parkedTotal = 0
|
||||
i.view.InvalidateRenderCache()
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -1060,6 +1198,10 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
|
|||
i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
|
||||
i.toolCalls = map[string]*tui.ToolCallView{}
|
||||
i.toolOrder = nil
|
||||
// Transcript was rewritten in place — purge the per-message
|
||||
// render cache so stale entries keyed on the old messages
|
||||
// don't linger.
|
||||
i.view.InvalidateRenderCache()
|
||||
}
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
|
|
@ -1082,7 +1224,9 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
|
|||
i.toolCalls = map[string]*tui.ToolCallView{}
|
||||
i.toolOrder = nil
|
||||
i.scrollOffset = 0 // jump back to the bottom on new turn
|
||||
i.helpBlock = nil // hide the help block once the user asks something
|
||||
i.parkedTurn = 0 // starting a turn clears the /jump parked state
|
||||
i.parkedTotal = 0
|
||||
i.helpBlock = nil // hide the help block once the user asks something
|
||||
i.mu.Unlock()
|
||||
i.invalidate()
|
||||
|
||||
|
|
|
|||
294
internal/agent/modes/jump_dialog.go
Normal file
294
internal/agent/modes/jump_dialog.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package modes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/patriceckhart/zot/internal/provider"
|
||||
"github.com/patriceckhart/zot/internal/tui"
|
||||
)
|
||||
|
||||
// jumpTarget describes one "turn" in the current session — a user
|
||||
// prompt plus the preview metadata the picker renders. MessageIdx
|
||||
// maps back into view.Messages so we can resolve the row offset via
|
||||
// view.BuildWithAnchors when the user picks a target.
|
||||
type jumpTarget struct {
|
||||
MessageIdx int // index into view.Messages
|
||||
TurnNo int // 1-based turn number in session order
|
||||
Preview string // first ~60 chars of the user prompt (one line)
|
||||
ToolCount int // tools invoked by the assistant in this turn
|
||||
}
|
||||
|
||||
// jumpDialog is the picker shown when the user runs /jump. Rows are
|
||||
// turns: one per user message. Filtering happens as the user types;
|
||||
// arrow keys move within the filtered set.
|
||||
type jumpDialog struct {
|
||||
active bool
|
||||
all []jumpTarget
|
||||
visible []jumpTarget // filtered subset
|
||||
cursor int
|
||||
filter string
|
||||
}
|
||||
|
||||
// jumpDialogAction is returned by HandleKey.
|
||||
type jumpDialogAction struct {
|
||||
Select bool
|
||||
MessageIdx int
|
||||
TurnNo int
|
||||
Close bool
|
||||
}
|
||||
|
||||
func newJumpDialog() *jumpDialog { return &jumpDialog{} }
|
||||
|
||||
// Open scans the current transcript for user-message anchor points,
|
||||
// applies the optional initial filter, and makes the dialog visible.
|
||||
// If the filter already narrows the list to exactly one match the
|
||||
// caller should check len(d.visible)==1 and treat that as an
|
||||
// immediate select rather than opening the full picker.
|
||||
func (d *jumpDialog) Open(msgs []provider.Message, initialFilter string) {
|
||||
d.all = buildJumpTargets(msgs)
|
||||
d.filter = initialFilter
|
||||
d.applyFilter()
|
||||
// Start on the last (most recent) target so enter-without-typing
|
||||
// goes to the newest turn, which is almost never what you want
|
||||
// for /jump — flip to the oldest filtered match instead so the
|
||||
// picker opens at the top of the list.
|
||||
d.cursor = 0
|
||||
d.active = true
|
||||
}
|
||||
|
||||
// Close hides the dialog.
|
||||
func (d *jumpDialog) Close() { d.active = false }
|
||||
|
||||
// Active reports whether the dialog is visible and consumes input.
|
||||
func (d *jumpDialog) Active() bool { return d != nil && d.active }
|
||||
|
||||
// Targets returns the current filtered target slice. Interactive uses
|
||||
// this right after Open() to detect the "one unique match" shortcut
|
||||
// and jump without showing the picker.
|
||||
func (d *jumpDialog) Targets() []jumpTarget {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.visible
|
||||
}
|
||||
|
||||
// applyFilter re-computes d.visible from d.all + d.filter. Matching
|
||||
// is case-insensitive substring on the preview. An empty filter
|
||||
// returns all targets.
|
||||
func (d *jumpDialog) applyFilter() {
|
||||
if d.filter == "" {
|
||||
d.visible = append(d.visible[:0], d.all...)
|
||||
} else {
|
||||
q := strings.ToLower(d.filter)
|
||||
d.visible = d.visible[:0]
|
||||
for _, t := range d.all {
|
||||
if strings.Contains(strings.ToLower(t.Preview), q) {
|
||||
d.visible = append(d.visible, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.cursor >= len(d.visible) {
|
||||
d.cursor = len(d.visible) - 1
|
||||
}
|
||||
if d.cursor < 0 {
|
||||
d.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Render draws the dialog.
|
||||
func (d *jumpDialog) Render(th tui.Theme, width int) []string {
|
||||
if !d.Active() {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
lines = append(lines, frameHeader(th, "jump to turn", width))
|
||||
if len(d.all) == 0 {
|
||||
lines = append(lines, th.FG256(th.Muted, "no turns in this session yet"))
|
||||
lines = append(lines, th.FG256(th.Muted, "press esc to close"))
|
||||
lines = append(lines, frameRule(th, width))
|
||||
return lines
|
||||
}
|
||||
|
||||
// Status line: shows the active filter, visible count, and hints.
|
||||
hint := "↑/↓ pick · enter jump · esc cancel · type to filter"
|
||||
if d.filter != "" {
|
||||
hint = fmt.Sprintf("filter: %q · %d match · ", d.filter, len(d.visible)) + hint
|
||||
}
|
||||
lines = append(lines, th.FG256(th.Muted, hint))
|
||||
|
||||
if len(d.visible) == 0 {
|
||||
lines = append(lines, th.FG256(th.Muted, " (nothing matches; backspace to widen)"))
|
||||
lines = append(lines, frameRule(th, width))
|
||||
return lines
|
||||
}
|
||||
|
||||
// Cap the visible window so a 200-turn session doesn't push the
|
||||
// editor off screen. Center around the cursor.
|
||||
const maxRows = 12
|
||||
start := 0
|
||||
end := len(d.visible)
|
||||
if end > maxRows {
|
||||
start = d.cursor - maxRows/2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end = start + maxRows
|
||||
if end > len(d.visible) {
|
||||
end = len(d.visible)
|
||||
start = end - maxRows
|
||||
}
|
||||
}
|
||||
if start > 0 {
|
||||
lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2191 %d more above", start)))
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
t := d.visible[i]
|
||||
plain := " " + formatJumpRowPlain(t, width-2)
|
||||
if i == d.cursor {
|
||||
lines = append(lines, th.PadHighlight(plain, width))
|
||||
} else {
|
||||
lines = append(lines, th.FG256(th.Muted, plain))
|
||||
}
|
||||
}
|
||||
if end < len(d.visible) {
|
||||
lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" \u2193 %d more below", len(d.visible)-end)))
|
||||
}
|
||||
|
||||
lines = append(lines, frameRule(th, width))
|
||||
return lines
|
||||
}
|
||||
|
||||
// formatJumpRowPlain renders one target as a single line with turn
|
||||
// number, tool count, and the prompt preview trimmed to fit.
|
||||
func formatJumpRowPlain(t jumpTarget, maxWidth int) string {
|
||||
left := fmt.Sprintf("#%-3d %s ", t.TurnNo, toolBadge(t.ToolCount))
|
||||
room := maxWidth - len(left)
|
||||
if room < 10 {
|
||||
room = 10
|
||||
}
|
||||
preview := t.Preview
|
||||
if len(preview) > room {
|
||||
preview = preview[:room-1] + "\u2026"
|
||||
}
|
||||
return left + preview
|
||||
}
|
||||
|
||||
func toolBadge(n int) string {
|
||||
if n <= 0 {
|
||||
return " "
|
||||
}
|
||||
if n > 99 {
|
||||
return " 99+t "
|
||||
}
|
||||
return fmt.Sprintf(" %2dt ", n)
|
||||
}
|
||||
|
||||
// HandleKey advances the dialog state and returns an action to apply.
|
||||
// Runes are added to the filter; backspace removes the last rune.
|
||||
func (d *jumpDialog) HandleKey(k tui.Key) jumpDialogAction {
|
||||
switch k.Kind {
|
||||
case tui.KeyUp:
|
||||
if d.cursor > 0 {
|
||||
d.cursor--
|
||||
}
|
||||
case tui.KeyDown:
|
||||
if d.cursor < len(d.visible)-1 {
|
||||
d.cursor++
|
||||
}
|
||||
case tui.KeyPageUp:
|
||||
d.cursor -= 5
|
||||
if d.cursor < 0 {
|
||||
d.cursor = 0
|
||||
}
|
||||
case tui.KeyPageDown:
|
||||
d.cursor += 5
|
||||
if d.cursor >= len(d.visible) {
|
||||
d.cursor = len(d.visible) - 1
|
||||
}
|
||||
case tui.KeyBackspace:
|
||||
if len(d.filter) > 0 {
|
||||
runes := []rune(d.filter)
|
||||
d.filter = string(runes[:len(runes)-1])
|
||||
d.applyFilter()
|
||||
}
|
||||
case tui.KeyRune:
|
||||
// Any printable rune extends the filter.
|
||||
d.filter += string(k.Rune)
|
||||
d.applyFilter()
|
||||
case tui.KeyEsc:
|
||||
d.Close()
|
||||
return jumpDialogAction{Close: true}
|
||||
case tui.KeyEnter:
|
||||
if len(d.visible) == 0 {
|
||||
return jumpDialogAction{}
|
||||
}
|
||||
t := d.visible[d.cursor]
|
||||
d.Close()
|
||||
return jumpDialogAction{Select: true, MessageIdx: t.MessageIdx, TurnNo: t.TurnNo}
|
||||
}
|
||||
return jumpDialogAction{}
|
||||
}
|
||||
|
||||
// buildJumpTargets walks the session transcript and produces one
|
||||
// jumpTarget per user message, enriched with the tool count from
|
||||
// the following assistant messages up to the next user boundary.
|
||||
func buildJumpTargets(msgs []provider.Message) []jumpTarget {
|
||||
var out []jumpTarget
|
||||
turn := 0
|
||||
for i, m := range msgs {
|
||||
if m.Role != provider.RoleUser {
|
||||
continue
|
||||
}
|
||||
turn++
|
||||
t := jumpTarget{
|
||||
MessageIdx: i,
|
||||
TurnNo: turn,
|
||||
Preview: firstLineOfUserMessage(m),
|
||||
ToolCount: countToolsUntilNextUser(msgs, i),
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstLineOfUserMessage returns the first non-empty text line of a
|
||||
// user message, trimmed to a single visible line, for use as a row
|
||||
// preview. Non-text blocks (images) are summarised.
|
||||
func firstLineOfUserMessage(m provider.Message) string {
|
||||
for _, c := range m.Content {
|
||||
switch b := c.(type) {
|
||||
case provider.TextBlock:
|
||||
for _, line := range strings.Split(b.Text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
case provider.ImageBlock:
|
||||
return fmt.Sprintf("[image · %s · %d bytes]", b.MimeType, len(b.Data))
|
||||
}
|
||||
}
|
||||
return "(empty)"
|
||||
}
|
||||
|
||||
// countToolsUntilNextUser totals the tool calls emitted by assistant
|
||||
// messages between msgs[i] (exclusive) and the next user message
|
||||
// (exclusive). Tool-result messages are ignored because they mirror
|
||||
// the call count 1:1.
|
||||
func countToolsUntilNextUser(msgs []provider.Message, i int) int {
|
||||
n := 0
|
||||
for j := i + 1; j < len(msgs); j++ {
|
||||
if msgs[j].Role == provider.RoleUser {
|
||||
break
|
||||
}
|
||||
if msgs[j].Role == provider.RoleAssistant {
|
||||
for _, c := range msgs[j].Content {
|
||||
if _, ok := c.(provider.ToolCallBlock); ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ var slashCatalog = []slashCommand{
|
|||
{"/logout", "clear a provider's credentials"},
|
||||
{"/model", "pick a model (or /model <id>)"},
|
||||
{"/sessions", "resume a previous session for this directory"},
|
||||
{"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
|
||||
{"/compact", "summarize and replace the transcript to free up context"},
|
||||
{"/lock", "confine tools to the current directory"},
|
||||
{"/unlock", "allow tools to touch paths outside this directory"},
|
||||
|
|
|
|||
|
|
@ -68,10 +68,18 @@ func containsImageEscape(s string) bool {
|
|||
// cells, preserving ANSI CSI escape sequences (which don't consume
|
||||
// cells). Lines carrying an inline-image escape are returned as-is
|
||||
// since we can't measure their painted size.
|
||||
//
|
||||
// Fast path: a byte-length <= cols is a conservative upper bound
|
||||
// guaranteeing the cell width is also <= cols, so we skip all the
|
||||
// rune-width math. That covers the vast majority of lines in a
|
||||
// transcript (narrow terminals wrap early; wide ones leave headroom).
|
||||
func truncateToWidth(s string, cols int) string {
|
||||
if cols <= 0 || containsImageEscape(s) {
|
||||
return s
|
||||
}
|
||||
if len(s) <= cols {
|
||||
return s
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(s))
|
||||
seen := 0
|
||||
|
|
|
|||
|
|
@ -55,6 +55,34 @@ type View struct {
|
|||
// ToolCollapseLines collapse to ToolCollapsePreview lines plus a
|
||||
// "... (N more lines, M total, ctrl+o to expand)" footer.
|
||||
ExpandAll bool
|
||||
|
||||
// renderCache holds the per-message rendered line slices so Build
|
||||
// doesn't re-markdown every message on every frame. Keyed by a
|
||||
// struct of (content hash, width, expandAll) — any of those
|
||||
// changing invalidates the entry. Messages are append-only after
|
||||
// they finalise so keeping the cache across turns is safe.
|
||||
//
|
||||
// Streaming/in-flight work (v.Streaming, v.ToolCalls) is never
|
||||
// cached because it changes every delta.
|
||||
renderCache map[msgCacheKey][]string
|
||||
}
|
||||
|
||||
// msgCacheKey identifies a cached message render. hash is a 64-bit
|
||||
// FNV-1a of the message's content, which is cheap to compute and
|
||||
// unambiguous enough for the cache (collisions produce a stale frame,
|
||||
// not wrong data, and we recompute on invalidation anyway).
|
||||
type msgCacheKey struct {
|
||||
hash uint64
|
||||
width int
|
||||
expandAll bool
|
||||
}
|
||||
|
||||
// InvalidateRenderCache drops all cached message renders. The tui
|
||||
// calls this when the transcript is replaced wholesale (/compact,
|
||||
// /clear, session swap) since messages can be replaced in place and
|
||||
// a content-hash miss alone doesn't reclaim the old entries.
|
||||
func (v *View) InvalidateRenderCache() {
|
||||
v.renderCache = nil
|
||||
}
|
||||
|
||||
// ToolCollapsePreview is the number of lines shown before a long tool
|
||||
|
|
@ -75,24 +103,48 @@ type ToolCallView struct {
|
|||
Done bool
|
||||
}
|
||||
|
||||
// MessageAnchor records where a rendered message starts in the chat
|
||||
// line slice. Used by /jump so the dialog can scroll the viewport to
|
||||
// the row where a turn's user prompt begins.
|
||||
type MessageAnchor struct {
|
||||
MessageIdx int // index into v.Messages
|
||||
Row int // first row of that message in the Build() output
|
||||
}
|
||||
|
||||
// Build returns the chat log lines for the given width.
|
||||
func (v *View) Build(width int) []string {
|
||||
// Map tool_use_id -> path argument, if any, so tool results can be
|
||||
// rendered with the file's language for syntax highlighting.
|
||||
v.toolPaths = map[string]string{}
|
||||
for _, m := range v.Messages {
|
||||
for _, c := range m.Content {
|
||||
if tc, ok := c.(provider.ToolCallBlock); ok {
|
||||
if p := pathFromToolArgs(tc.Arguments); p != "" {
|
||||
v.toolPaths[tc.ID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
lines, _ := v.BuildWithAnchors(width)
|
||||
return lines
|
||||
}
|
||||
|
||||
// BuildWithAnchors is like Build but additionally reports the first
|
||||
// row occupied by each message in v.Messages. Callers that need to
|
||||
// scroll to a specific turn (the /jump dialog) use the anchor slice
|
||||
// to map a message index back to a row offset.
|
||||
func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
|
||||
v.refreshToolPaths()
|
||||
if v.renderCache == nil {
|
||||
v.renderCache = make(map[msgCacheKey][]string)
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, m := range v.Messages {
|
||||
out = append(out, v.renderMessage(m, width)...)
|
||||
// Pre-render every message (hits the cache for unchanged ones) so
|
||||
// we can allocate `out` in a single shot with the exact capacity.
|
||||
// Growing via append on a long transcript copies the backing array
|
||||
// log2(N) times; for a 2000-line scrollback that's enough memcpy
|
||||
// to visibly stutter while typing.
|
||||
rendered := make([][]string, len(v.Messages))
|
||||
total := 0
|
||||
for idx, m := range v.Messages {
|
||||
lines := v.renderMessageCached(m, width)
|
||||
rendered[idx] = lines
|
||||
total += len(lines) + 1 // +1 for the blank separator row
|
||||
}
|
||||
|
||||
out := make([]string, 0, total+16)
|
||||
anchors := make([]MessageAnchor, 0, len(v.Messages))
|
||||
for idx := range v.Messages {
|
||||
anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
|
||||
out = append(out, rendered[idx]...)
|
||||
out = append(out, "")
|
||||
}
|
||||
if v.StreamingActive {
|
||||
|
|
@ -117,7 +169,131 @@ func (v *View) Build(width int) []string {
|
|||
out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
|
||||
out = append(out, "")
|
||||
}
|
||||
return out
|
||||
return out, anchors
|
||||
}
|
||||
|
||||
// refreshToolPaths rebuilds the tool_use_id -> path map from the
|
||||
// current transcript. Called once per Build() so tool result blocks
|
||||
// (which may be cached) can look up their syntax language when they
|
||||
// were originally rendered. Walking the transcript here is O(N) but
|
||||
// cheap compared to markdown/chroma work it enables.
|
||||
func (v *View) refreshToolPaths() {
|
||||
v.toolPaths = map[string]string{}
|
||||
for _, m := range v.Messages {
|
||||
for _, c := range m.Content {
|
||||
if tc, ok := c.(provider.ToolCallBlock); ok {
|
||||
if p := pathFromToolArgs(tc.Arguments); p != "" {
|
||||
v.toolPaths[tc.ID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderMessageCached returns the rendered line slice for m, using the
|
||||
// cache if the same (content hash, width, expandAll) combination has
|
||||
// been rendered before. The slice returned is shared — callers must
|
||||
// not mutate it; Build() only ever appends to its own `out` so the
|
||||
// shared slice is safe.
|
||||
func (v *View) renderMessageCached(m provider.Message, width int) []string {
|
||||
key := msgCacheKey{
|
||||
hash: hashMessage(m),
|
||||
width: width,
|
||||
expandAll: v.ExpandAll,
|
||||
}
|
||||
if v.renderCache != nil {
|
||||
if lines, ok := v.renderCache[key]; ok {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
lines := v.renderMessage(m, width)
|
||||
if v.renderCache != nil {
|
||||
// Bound the cache: 4x the current message count is enough to
|
||||
// survive /compact churn without leaking memory across a very
|
||||
// long session.
|
||||
max := len(v.Messages) * 4
|
||||
if max < 32 {
|
||||
max = 32
|
||||
}
|
||||
if len(v.renderCache) > max {
|
||||
// Drop half the entries. map iteration order gives us a
|
||||
// pseudo-LRU for free.
|
||||
dropped := 0
|
||||
target := len(v.renderCache) / 2
|
||||
for k := range v.renderCache {
|
||||
if dropped >= target {
|
||||
break
|
||||
}
|
||||
delete(v.renderCache, k)
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
v.renderCache[key] = lines
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// hashMessage returns a 64-bit FNV-1a over the role + content blocks
|
||||
// of m. Serialising each block to its salient bytes is enough: two
|
||||
// messages with the same role and same content render identically.
|
||||
func hashMessage(m provider.Message) uint64 {
|
||||
h := fnv64aInit
|
||||
h = fnv64aWrite(h, []byte(m.Role))
|
||||
h = fnv64aWriteByte(h, 0)
|
||||
for _, c := range m.Content {
|
||||
switch b := c.(type) {
|
||||
case provider.TextBlock:
|
||||
h = fnv64aWriteByte(h, 't')
|
||||
h = fnv64aWrite(h, []byte(b.Text))
|
||||
case provider.ImageBlock:
|
||||
h = fnv64aWriteByte(h, 'i')
|
||||
h = fnv64aWrite(h, []byte(b.MimeType))
|
||||
h = fnv64aWrite(h, b.Data)
|
||||
case provider.ToolCallBlock:
|
||||
h = fnv64aWriteByte(h, 'c')
|
||||
h = fnv64aWrite(h, []byte(b.ID))
|
||||
h = fnv64aWrite(h, []byte(b.Name))
|
||||
h = fnv64aWrite(h, []byte(b.Arguments))
|
||||
case provider.ToolResultBlock:
|
||||
h = fnv64aWriteByte(h, 'r')
|
||||
h = fnv64aWrite(h, []byte(b.CallID))
|
||||
if b.IsError {
|
||||
h = fnv64aWriteByte(h, 'E')
|
||||
}
|
||||
for _, inner := range b.Content {
|
||||
switch ib := inner.(type) {
|
||||
case provider.TextBlock:
|
||||
h = fnv64aWrite(h, []byte(ib.Text))
|
||||
case provider.ImageBlock:
|
||||
h = fnv64aWrite(h, []byte(ib.MimeType))
|
||||
h = fnv64aWrite(h, ib.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
h = fnv64aWriteByte(h, 0)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// FNV-1a implementation inlined so we don't pay the interface cost of
|
||||
// hash.Hash64 on every Build(). The whole point here is speed.
|
||||
const (
|
||||
fnv64aInit uint64 = 0xcbf29ce484222325
|
||||
fnv64aPrime uint64 = 0x100000001b3
|
||||
)
|
||||
|
||||
func fnv64aWriteByte(h uint64, b byte) uint64 {
|
||||
h ^= uint64(b)
|
||||
h *= fnv64aPrime
|
||||
return h
|
||||
}
|
||||
|
||||
func fnv64aWrite(h uint64, p []byte) uint64 {
|
||||
for _, b := range p {
|
||||
h ^= uint64(b)
|
||||
h *= fnv64aPrime
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (v *View) renderMessage(m provider.Message, width int) []string {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue