package modes import ( "fmt" "strings" "github.com/patriceckhart/zot/packages/provider" "github.com/patriceckhart/zot/packages/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 { if room <= 3 { preview = "..."[:room] } else { preview = preview[:room-3] + "..." } } 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 }