1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/patriceckhart/zot/internal/agent/extensions"
12 "github.com/patriceckhart/zot/internal/agent/tools"
13 "github.com/patriceckhart/zot/internal/auth"
14 "github.com/patriceckhart/zot/internal/core"
15 "github.com/patriceckhart/zot/internal/provider"
16 "github.com/patriceckhart/zot/internal/skills"
17 "github.com/patriceckhart/zot/internal/tui"
18)
19
20// InteractiveConfig configures the interactive loop.
21type InteractiveConfig struct {
22 Terminal tui.Terminal
23 Theme tui.Theme
24 Model string
25 Provider string
26 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
27 BaseURL string
28 Reasoning string
29 SystemPrompt string
30 Tools core.Registry
31 MaxSteps int
32 CWD string
33
34 // Agent is optional. If nil, zot opens without credentials; the
35 // user must /login before they can prompt.
36 Agent *core.Agent
37
38 InitialInput string
39
40 // Auth is required. When the user runs /login, Interactive talks to
41 // AuthManager to open a browser and wait for the callback.
42 AuthManager *auth.Manager
43 // BuildAgent is called after a successful login to (re)construct the
44 // agent with the fresh credential. It returns the new agent and
45 // the concrete provider/model in use.
46 BuildAgent func() (*core.Agent, string, string, error)
47
48 // BuildAgentFor rebuilds the agent with an explicit provider/model
49 // override (used by the /model picker when switching providers).
50 // If providerOverride is empty, the current provider is kept.
51 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
52
53 // ZotHome is the root directory for sessions/, used by /sessions
54 // and the update-check cache.
55 ZotHome string
56
57 // Version is the binary's current version (from main.version).
58 // Used only for display; the update check itself is done outside
59 // this package to avoid an import cycle.
60 Version string
61
62 // UpdateInfoChan is an optional channel that delivers the result
63 // of the github-release update check. Interactive reads at most
64 // one value, drops it if the check reported nothing, and otherwise
65 // surfaces a yellow "update available" banner at the top of the
66 // chat. Nil channel = no banner, no startup cost.
67 UpdateInfoChan <-chan UpdateInfo
68
69 // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock.
70 Sandbox *tools.Sandbox
71
72 // LoadSession swaps the current session for the one at path. The
73 // callback returns the new agent message slice so the TUI can invalidate.
74 LoadSession func(path string) error
75
76 // PersistModel is called whenever the user switches model or provider.
77 // It should update config.json and (if there's an active session)
78 // write a new meta row so resume picks up the same model.
79 PersistModel func(providerName, model string)
80
81 OnAssistant func(m provider.Message)
82 OnToolResult func(id string, r core.ToolResult)
83
84 // Extensions, if non-nil, lets users invoke extension-registered
85 // slash commands. Commands declared by extensions are looked up
86 // AFTER the built-in catalog so a built-in name always wins.
87 Extensions *extensions.Manager
88
89 // SkillSnapshot, if non-nil, returns the current list of
90 // discovered SKILL.md files. Re-invoked each time /skills opens
91 // so the picker reflects edits made during the session.
92 SkillSnapshot func() []*skills.Skill
93}
94
95// Interactive is the TUI chat loop.
96type Interactive struct {
97 cfg InteractiveConfig
98 view *tui.View
99 ed *tui.Editor
100 rend *tui.Renderer
101
102 mu sync.Mutex
103 agent *core.Agent
104 streaming strings.Builder
105 streamOn bool
106 toolCalls map[string]*tui.ToolCallView
107 toolOrder []string
108 statusErr string
109 statusOK string
110 helpBlock []string // rendered above the chat when /help was typed
111 cumUsage provider.Usage
112 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
113 busy bool
114 dirty chan struct{}
115 cancelTurn context.CancelFunc
116 scrollOffset int // rows from the bottom; 0 = pinned to latest
117
118 // Messages typed while a turn is in flight. Each is delivered as
119 // its own follow-up turn once the current one finishes. Rendered
120 // above the status bar as "sliding in: ..." chips.
121 queued []string
122
123 // runCtx is the top-level context passed to Run(). Follow-up turns
124 // drained from `queued` are started against this context so they
125 // survive past the ctx of the key event that enqueued them.
126 runCtx context.Context
127
128 // autoCompacting is true while a model-triggered compaction is in
129 // flight. Surfaced in the status bar so the user can tell a
130 // condense pass from a regular assistant turn.
131 autoCompacting bool
132
133 // updateInfo is the result of the async update check. Zero value
134 // while the check hasn't completed or nothing is available.
135 updateInfo UpdateInfo
136
137 dialog *loginDialog
138 modelDialog *modelDialog
139 sessionDialog *sessionDialog
140 jumpDialog *jumpDialog
141 btwDialog *btwDialog
142 skillsDialog *skillsDialog
143 suggest *slashSuggester
144 spin *spinner
145
146 // parkedTurn is the 1-based turn number the viewport is currently
147 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
148 // Rendered as a muted footer at the bottom of the chat so users
149 // don't forget they're looking at history.
150 parkedTurn int
151 parkedTotal int
152
153 // lastCtrlC is when the user last pressed ctrl+c. The first press
154 // clears the editor / cancels a turn / shows a hint; a second press
155 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
156 lastCtrlC time.Time
157
158 // welcomeStart is when the interactive run began. The welcome
159 // banner shows the binary version for welcomeVersionDuration
160 // after this point and reverts to plain text after.
161 welcomeStart time.Time
162
163 // extNotes are one-shot styled lines pushed by extensions via
164 // Notify / Display. They live above the editor (just below the
165 // transcript) until cleared by /clear or another reset.
166 extNotes []string
167}
168
169// welcomeVersionDuration is how long the welcome banner shows the
170// version suffix before reverting to the plain headline. 1.5s is
171// enough to read at a glance and keeps the splash short.
172const welcomeVersionDuration = 1500 * time.Millisecond
173
174// NewInteractive constructs an Interactive from cfg.
175func NewInteractive(cfg InteractiveConfig) *Interactive {
176 i := &Interactive{
177 cfg: cfg,
178 view: &tui.View{
179 Theme: cfg.Theme,
180 ImageProto: tui.DetectImageProtocol(),
181 },
182 ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
183 rend: tui.NewRenderer(cfg.Terminal),
184 toolCalls: map[string]*tui.ToolCallView{},
185 dirty: make(chan struct{}, 8),
186 dialog: newLoginDialog(),
187 modelDialog: newModelDialog(),
188 sessionDialog: newSessionDialog(),
189 jumpDialog: newJumpDialog(),
190 btwDialog: newBtwDialog(),
191 skillsDialog: newSkillsDialog(),
192 suggest: newSlashSuggester(),
193 spin: newSpinner(),
194 }
195 if cfg.Agent != nil {
196 i.agent = cfg.Agent
197 i.view.Messages = cfg.Agent.Messages()
198 }
199 return i
200}
201
202// Run blocks until the user quits.
203func (i *Interactive) Run(ctx context.Context) error {
204 i.runCtx = ctx
205 term := i.cfg.Terminal
206 restore, err := term.EnterRaw()
207 if err != nil {
208 return err
209 }
210 defer restore()
211
212 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
213 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
214 defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
215
216 cols, rows := term.Size()
217 i.rend.Resize(cols, rows)
218 term.OnResize(func() {
219 c, r := term.Size()
220 i.rend.Resize(c, r)
221 // Force an immediate redraw on resize. The throttled invalidate
222 // path is fine for animation, but a window resize is a discrete
223 // user action where any visible delay (or stale frame) reads as
224 // brokenness. redraw() is mutex-safe; the worst that happens is
225 // a duplicate paint if the throttler is mid-flight, which is
226 // invisible.
227 i.redraw()
228 })
229
230 if i.cfg.InitialInput != "" {
231 i.ed.SetValue(i.cfg.InitialInput)
232 }
233
234 // Stamp the welcome time and schedule a one-shot redraw at the
235 // expiry so the version suffix disappears on its own even if the
236 // user hasn't typed anything yet.
237 i.welcomeStart = time.Now()
238 time.AfterFunc(welcomeVersionDuration, i.invalidate)
239
240 // If the agent was constructed with a pre-loaded transcript
241 // (--continue, --resume, --session) park the viewport on the
242 // most recent turn so the user lands looking at where the
243 // previous session left off rather than at the bottom of an
244 // already-rendered final reply.
245 if i.agent != nil {
246 if msgs := i.agent.Messages(); len(msgs) > 0 {
247 i.scrollToLastTurn(msgs)
248 }
249 }
250
251 // No credential at startup? Auto-open the login dialog, and mark
252 // the status line. The user can Esc out of the dialog if they
253 // want to dismiss it (e.g. to check /help or /exit first).
254 if i.agent == nil {
255 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
256 i.dialog.Open()
257 }
258
259 // Input goroutine.
260 keys := make(chan tui.Key, 8)
261 go func() {
262 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
263 for {
264 k, err := reader.Read()
265 if err != nil {
266 return
267 }
268 keys <- k
269 }
270 }()
271
272 // Subscribe to auth events.
273 var authEvents <-chan auth.Event
274 if i.cfg.AuthManager != nil {
275 authEvents = i.cfg.AuthManager.Events()
276 }
277
278 // Animation ticker: drives spinner and dialog-related redraws when
279 // nothing else changed. 120ms is slow enough that highlighting a huge
280 // transcript doesn't spin the cpu.
281 tick := time.NewTicker(120 * time.Millisecond)
282 defer tick.Stop()
283
284 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
285 // at most once every redrawMinInterval. Huge tool-result dumps can
286 // fire hundreds of invalidations while the user is typing; without
287 // this, the input goroutine never gets CPU and keystrokes lag.
288 const redrawMinInterval = 16 * time.Millisecond
289 var lastRedraw time.Time
290 var pendingRedraw bool
291 var pendingTimer *time.Timer
292
293 drainPending := func() {
294 if pendingTimer != nil {
295 pendingTimer.Stop()
296 pendingTimer = nil
297 }
298 if pendingRedraw {
299 pendingRedraw = false
300 lastRedraw = time.Now()
301 i.redraw()
302 }
303 }
304
305 requestRedraw := func() {
306 since := time.Since(lastRedraw)
307 if since >= redrawMinInterval {
308 // Redrawing right now subsumes any pending redraw, so clear
309 // the throttle state. Without this, a pending flag stays
310 // stuck at true and subsequent invalidate() calls within
311 // redrawMinInterval get dropped — which is exactly how the
312 // final "turn finished" frame went missing until the user
313 // nudged the ui by typing or scrolling.
314 if pendingTimer != nil {
315 pendingTimer.Stop()
316 }
317 pendingRedraw = false
318 lastRedraw = time.Now()
319 i.redraw()
320 return
321 }
322 if pendingRedraw {
323 return // already scheduled
324 }
325 pendingRedraw = true
326 wait := redrawMinInterval - since
327 if pendingTimer == nil {
328 pendingTimer = time.AfterFunc(wait, func() {
329 // Poke the dirty channel so the main loop wakes and
330 // drains the pending redraw on its own goroutine. We
331 // can't call drainPending here directly — it touches
332 // closure state shared with the main loop.
333 i.invalidate()
334 })
335 } else {
336 pendingTimer.Reset(wait)
337 }
338 }
339
340 i.invalidate()
341
342 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
343
344 for {
345 select {
346 case <-ctx.Done():
347 return ctx.Err()
348 case k := <-keys:
349 if done := i.handleKey(ctx, k); done {
350 return nil
351 }
352 i.invalidate()
353 case ev := <-authEvents:
354 i.handleAuthEvent(ev)
355 i.invalidate()
356 case info, ok := <-updates:
357 if ok && info.Available {
358 i.mu.Lock()
359 i.updateInfo = info
360 i.mu.Unlock()
361 i.invalidate()
362 }
363 updates = nil // single-shot; subsequent iterations skip this case
364 case <-i.dirty:
365 requestRedraw()
366 case <-tick.C:
367 // Always drain a pending redraw on the tick. This is the
368 // safety net that catches the case where the dirty channel
369 // was saturated when the final "turn finished" invalidate
370 // fired, or where the throttle scheduled a deferred redraw
371 // and the AfterFunc-driven invalidate got dropped on a
372 // full channel.
373 drainPending()
374 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() {
375 requestRedraw() // keep the spinner / dialog animation moving
376 }
377 }
378 }
379}
380
381func (i *Interactive) invalidate() {
382 select {
383 case i.dirty <- struct{}{}:
384 default:
385 }
386}
387
388// lastCols returns the current terminal width in columns.
389func (i *Interactive) lastCols() int {
390 cols, _ := i.cfg.Terminal.Size()
391 return cols
392}
393
394// chatPage returns the number of chat rows currently visible, used
395// as the page size for PageUp/PageDown.
396func (i *Interactive) chatPage() int {
397 _, rows := i.cfg.Terminal.Size()
398 p := rows - 6 // rough reservation for status + editor + a dialog line
399 if p < 4 {
400 p = 4
401 }
402 return p
403}
404
405// scrollBy adjusts the scroll offset. Positive = up (into history).
406// Clearing the parked-turn label when we're back at the bottom means
407// the "viewing turn N" footer goes away automatically as soon as you
408// scroll back to the live tail.
409func (i *Interactive) scrollBy(delta int) {
410 i.mu.Lock()
411 i.scrollOffset += delta
412 if i.scrollOffset < 0 {
413 i.scrollOffset = 0
414 }
415 if i.scrollOffset == 0 {
416 i.parkedTurn = 0
417 i.parkedTotal = 0
418 }
419 i.mu.Unlock()
420 i.invalidate()
421}
422
423// scrollToBottom pins the view to the latest content.
424func (i *Interactive) scrollToBottom() {
425 i.mu.Lock()
426 i.scrollOffset = 0
427 i.parkedTurn = 0
428 i.parkedTotal = 0
429 i.mu.Unlock()
430 i.invalidate()
431}
432
433func (i *Interactive) redraw() {
434 i.mu.Lock()
435 defer i.mu.Unlock()
436
437 cols, _ := i.cfg.Terminal.Size()
438 if i.agent != nil {
439 i.view.Messages = i.agent.Messages()
440 } else {
441 i.view.Messages = nil
442 }
443 i.view.Streaming = i.streaming.String()
444 i.view.StreamingActive = i.streamOn
445 // Belt and suspenders: if the transcript's last message is already an
446 // assistant message, the streaming view is stale (EvAssistantMessage may
447 // not have fired yet) — suppress it to avoid double-rendering the reply.
448 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
449 i.view.StreamingActive = false
450 }
451 // Live tool-call view: only shown while a turn is in flight. Once
452 // the agent is idle, every tool call has already been folded into
453 // the transcript (as assistant.ToolCallBlock + a tool-role message),
454 // so showing v.ToolCalls a second time would duplicate them below
455 // the final assistant text — which looks like the summary came
456 // "before" the tools.
457 i.view.ToolCalls = i.view.ToolCalls[:0]
458 if i.busy {
459 for _, id := range i.toolOrder {
460 if tc, ok := i.toolCalls[id]; ok {
461 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
462 }
463 }
464 }
465 i.view.Err = i.statusErr
466
467 chat := i.view.Build(cols)
468
469 // Welcome banner: shown at the top of the chat area when there is
470 // no transcript yet. Disappears after the first message is sent.
471 // The version suffix is shown for welcomeVersionDuration after
472 // startup, then drops off automatically.
473 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
474 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
475 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
476 }
477
478 // Update-available banner: prepended above everything else so it's
479 // the first thing the user sees when opening a new zot session.
480 // Once rendered, it stays until the user updates to a newer
481 // version — we don't persist a "dismissed" flag because this is
482 // cheap and re-showing it is how most users remember to update.
483 if i.updateInfo.Available {
484 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
485 chat = append(banner, chat...)
486 }
487
488 // /help block: appended to the transcript so it appears at the
489 // bottom of the chat area (right above the status bar / editor).
490 // Prepending it would push long conversations off the top of the
491 // viewport, which users would miss entirely.
492 if len(i.helpBlock) > 0 {
493 chat = append(chat, i.helpBlock...)
494 }
495
496 if i.statusOK != "" {
497 // Hard-truncate the OK line to the visible width so a long
498 // session path ("resumed session: /Users/.../sessions/...")
499 // doesn't overflow past the right edge and look broken on a
500 // narrow terminal.
501 line := "✓ " + i.statusOK
502 if cols > 4 && len(line) > cols {
503 line = line[:cols-1] + "…"
504 }
505 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
506 }
507
508 // Extension notes (notify / display) live just under the
509 // transcript, above the dialog/editor band. Cleared by /clear.
510 if len(i.extNotes) > 0 {
511 chat = append(chat, i.extNotes...)
512 chat = append(chat, "")
513 }
514
515 // Dialogs (login or model picker) render between chat and the editor.
516 var dialog []string
517 switch {
518 case i.dialog.Active():
519 dialog = i.dialog.Render(i.cfg.Theme, cols)
520 case i.modelDialog.Active():
521 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
522 case i.sessionDialog.Active():
523 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
524 case i.jumpDialog.Active():
525 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
526 case i.btwDialog.Active():
527 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
528 case i.skillsDialog.Active():
529 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
530 }
531
532 // Slash-command autocomplete: popup above the status line, only
533 // when the editor starts with "/" and no dialog is already open.
534 // Feed extension-registered commands into the suggester first so
535 // they show up in tab-complete + the popup alongside the built-ins.
536 if i.cfg.Extensions != nil {
537 catalog := i.cfg.Extensions.Commands()
538 extra := make([]slashCommand, 0, len(catalog))
539 for _, c := range catalog {
540 // The popup renders extension commands under a dedicated
541 // "── extensions ───" divider, so the description doesn't
542 // need to repeat the source. If the description is empty,
543 // fall back to the extension name so the row isn't blank.
544 desc := c.Description
545 if strings.TrimSpace(desc) == "" {
546 desc = "(" + c.Extension + ")"
547 }
548 extra = append(extra, slashCommand{
549 Name: "/" + c.Name,
550 Desc: desc,
551 })
552 }
553 i.suggest.SetExtra(extra)
554 }
555 var suggest []string
556 currentInput := i.ed.Value()
557 if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy {
558 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
559 }
560
561 // Busy prefix shown at the far left of the status bar.
562 var busyPrefix string
563 if i.busy {
564 busyPrefix = fmt.Sprintf("%s %s · %s",
565 i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()),
566 i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()),
567 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
568 )
569 }
570
571 ctxMax := 0
572 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
573 ctxMax = m.ContextWindow
574 }
575 statusLines := tui.StatusBar(tui.StatusBarParams{
576 Theme: i.cfg.Theme,
577 Provider: i.cfg.Provider,
578 Model: i.cfg.Model,
579 Busy: i.busy,
580 BusyPrefix: busyPrefix,
581 CWD: i.cfg.CWD,
582 Locked: i.cfg.Sandbox.Locked(),
583 Usage: i.cumUsage,
584 Subscription: i.cfg.AuthMethod == "oauth",
585 ContextUsed: i.lastCtxInput,
586 ContextMax: ctxMax,
587 AutoCompacting: i.autoCompacting,
588 Cols: cols,
589 })
590 edLines, curR, curC := i.ed.Render(cols)
591
592 // "Sliding in" chips for messages the user typed while a turn is
593 // in flight. Shown directly above the status bar so they're close
594 // to the editor but don't push the chat around.
595 var queue []string
596 if len(i.queued) > 0 {
597 for _, q := range i.queued {
598 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
599 text := truncateLine(q, cols-15)
600 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
601 }
602 }
603
604 // Bottom-sticky sections (always visible, never scroll).
605 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
606 bottom = append(bottom, dialog...)
607 bottom = append(bottom, suggest...)
608 bottom = append(bottom, queue...)
609 bottom = append(bottom, statusLines...)
610 bottom = append(bottom, edLines...)
611
612 _, rows := i.cfg.Terminal.Size()
613 chatRows := rows - len(bottom)
614 if chatRows < 1 {
615 chatRows = 1
616 }
617
618 // Apply scroll offset to the chat slice.
619 maxOffset := len(chat) - chatRows
620 if maxOffset < 0 {
621 maxOffset = 0
622 }
623 if i.scrollOffset > maxOffset {
624 i.scrollOffset = maxOffset
625 }
626 if i.scrollOffset < 0 {
627 i.scrollOffset = 0
628 }
629
630 var visibleChat []string
631 if len(chat) <= chatRows {
632 visibleChat = chat
633 } else {
634 end := len(chat) - i.scrollOffset
635 start := end - chatRows
636 if start < 0 {
637 start = 0
638 }
639 visibleChat = chat[start:end]
640 }
641
642 // A tiny "scrolled up" indicator in the top-right of the chat pane
643 // so you know you're not at the bottom. When the viewport was
644 // parked by /jump we include the turn number so the user remembers
645 // they're reading history rather than the live conversation.
646 if i.scrollOffset > 0 && len(visibleChat) > 0 {
647 var text string
648 if i.parkedTurn > 0 && i.parkedTotal > 0 {
649 text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
650 i.parkedTurn, i.parkedTotal, i.scrollOffset)
651 } else {
652 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
653 }
654 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
655 visibleChat = append([]string{note}, visibleChat...)
656 if len(visibleChat) > chatRows {
657 visibleChat = visibleChat[:chatRows]
658 }
659 }
660
661 frame := make([]string, 0, len(visibleChat)+len(bottom))
662 frame = append(frame, visibleChat...)
663 frame = append(frame, bottom...)
664
665 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
666 cursorCol := curC
667 i.rend.Draw(frame, cursorRow, cursorCol)
668}
669
670// truncateLine shortens s so it fits within n display cells, with an
671// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
672// novel doesn't blow past the status line.
673func truncateLine(s string, n int) string {
674 if n <= 0 {
675 return ""
676 }
677 // Collapse newlines — chips are single line.
678 s = strings.ReplaceAll(s, "\n", " ↩ ")
679 runes := []rune(s)
680 if len(runes) <= n {
681 return s
682 }
683 if n <= 1 {
684 return "…"
685 }
686 return string(runes[:n-1]) + "…"
687}
688
689// ctrlCExitWindow is how long after a ctrl+c press a *second* press
690// will exit instead of just clearing input. Long enough to be
691// deliberate (rules out accidental key chord), short enough that the
692// hint stays meaningful.
693const ctrlCExitWindow = 2 * time.Second
694
695// armCtrlCExit records the timestamp of the current ctrl+c so the next
696// one within ctrlCExitWindow exits.
697func (i *Interactive) armCtrlCExit() {
698 i.mu.Lock()
699 i.lastCtrlC = time.Now()
700 i.mu.Unlock()
701}
702
703// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
704// that another press should now exit.
705func (i *Interactive) ctrlCExitArmed() bool {
706 i.mu.Lock()
707 t := i.lastCtrlC
708 i.mu.Unlock()
709 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
710}
711
712func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
713 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
714 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
715 // unexpectedly. The hint message also goes stale; clear it.
716 if k.Kind != tui.KeyCtrlC {
717 i.mu.Lock()
718 if !i.lastCtrlC.IsZero() {
719 i.lastCtrlC = time.Time{}
720 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
721 i.statusOK = ""
722 }
723 }
724 i.mu.Unlock()
725 }
726
727 // Dialogs consume keys while open (except ctrl+c, which always closes them).
728 if i.dialog.Active() {
729 if k.Kind == tui.KeyCtrlC {
730 i.dialog.Close()
731 if i.cfg.AuthManager != nil {
732 i.cfg.AuthManager.CancelOAuth()
733 }
734 return false
735 }
736 act := i.dialog.HandleKey(k)
737 if act.StartAPIKey {
738 i.startAPIKeyFlow(act.Provider)
739 }
740 if act.StartOAuth {
741 i.startOAuthFlow(act.Provider)
742 }
743 return false
744 }
745 if i.modelDialog.Active() {
746 if k.Kind == tui.KeyCtrlC {
747 i.modelDialog.Close()
748 return false
749 }
750 act := i.modelDialog.HandleKey(k)
751 if act.Select {
752 i.applyModelSelection(act.Provider, act.Model)
753 }
754 return false
755 }
756 if i.sessionDialog.Active() {
757 if k.Kind == tui.KeyCtrlC {
758 i.sessionDialog.Close()
759 return false
760 }
761 act := i.sessionDialog.HandleKey(k)
762 if act.Select {
763 i.applySessionSelection(act.Path)
764 }
765 return false
766 }
767 if i.jumpDialog.Active() {
768 if k.Kind == tui.KeyCtrlC {
769 i.jumpDialog.Close()
770 return false
771 }
772 act := i.jumpDialog.HandleKey(k)
773 if act.Select {
774 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
775 }
776 return false
777 }
778 if i.btwDialog.Active() {
779 if k.Kind == tui.KeyCtrlC {
780 i.btwDialog.Close()
781 i.invalidate()
782 return false
783 }
784 i.btwDialog.HandleKey(k, i.invalidate)
785 return false
786 }
787 if i.skillsDialog.Active() {
788 if k.Kind == tui.KeyCtrlC {
789 i.skillsDialog.Close()
790 i.invalidate()
791 return false
792 }
793 i.skillsDialog.HandleKey(k)
794 i.invalidate()
795 return false
796 }
797
798 // Global keys.
799 switch k.Kind {
800 case tui.KeyCtrlC:
801 // While busy: cancel the active turn (same as esc). The exit
802 // hint stays armed so a quick second ctrl+c after the turn
803 // dies still exits, matching habits from other repls.
804 if i.busy && i.cancelTurn != nil {
805 i.cancelTurn()
806 i.armCtrlCExit()
807 return false
808 }
809 // Idle: first press clears the editor (and any queued
810 // follow-up messages); a second press within ctrlCExitWindow
811 // exits. With both an empty editor and no queue the first
812 // press still just arms — require a deliberate double-tap.
813 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
814 if hadInput {
815 i.ed.Clear()
816 i.suggest.Reset()
817 i.mu.Lock()
818 i.queued = nil
819 i.statusOK = "input cleared"
820 i.statusErr = ""
821 i.mu.Unlock()
822 i.armCtrlCExit()
823 return false
824 }
825 if i.ctrlCExitArmed() {
826 return true
827 }
828 i.mu.Lock()
829 i.statusOK = "press ctrl+c again to exit"
830 i.statusErr = ""
831 i.mu.Unlock()
832 i.armCtrlCExit()
833 return false
834 case tui.KeyEsc:
835 // Esc interrupts a running turn. When idle, fall through so the
836 // editor can clear itself.
837 if i.busy && i.cancelTurn != nil {
838 i.cancelTurn()
839 return false
840 }
841 case tui.KeyCtrlD:
842 if i.ed.IsEmpty() && !i.busy {
843 return true
844 }
845 case tui.KeyCtrlL:
846 i.rend.Clear()
847 i.invalidate()
848 return false
849 case tui.KeyCtrlO:
850 // Toggle expansion of collapsed tool results. Affects every tool
851 // call in the transcript — press again to re-collapse.
852 i.mu.Lock()
853 i.view.ExpandAll = !i.view.ExpandAll
854 i.mu.Unlock()
855 i.invalidate()
856 return false
857 case tui.KeyPageUp:
858 i.scrollBy(+i.chatPage())
859 return false
860 case tui.KeyPageDown:
861 i.scrollBy(-i.chatPage())
862 return false
863 case tui.KeyUp:
864 // Wheel-up in alt screen sends Up arrows on most terminals.
865 // When the editor is empty we use up/down for chat scroll —
866 // independently of whether the agent is busy, so users can
867 // scroll back through long streaming replies while they run.
868 if i.ed.IsEmpty() {
869 i.scrollBy(+3)
870 return false
871 }
872 case tui.KeyDown:
873 if i.ed.IsEmpty() {
874 if i.scrollOffset > 0 {
875 i.scrollBy(-3)
876 }
877 return false
878 }
879 }
880
881 // Note: we intentionally do NOT gate the editor on i.busy here.
882 // Typing while the agent is working is supported — submitted
883 // messages are queued and delivered as follow-up turns when the
884 // current turn ends. See the submit handler below.
885
886 if k.Kind == tui.KeyEnter && k.Alt {
887 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
888 return false
889 }
890
891 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
892 if i.suggest.Active(i.ed.Value()) {
893 switch k.Kind {
894 case tui.KeyUp:
895 i.suggest.Up()
896 return false
897 case tui.KeyDown:
898 i.suggest.Down()
899 return false
900 case tui.KeyTab:
901 if name := i.suggest.Selection(i.ed.Value()); name != "" {
902 i.ed.SetValue(name)
903 i.suggest.Reset()
904 }
905 return false
906 case tui.KeyEnter:
907 // Enter on an ambiguous or partial slash prefix: complete to the
908 // currently highlighted command and run it. That way typing
909 // "/lo" + enter picks whichever of /login or /logout is selected
910 // in the popup instead of submitting "/lo" as unknown. Also
911 // clear the editor so the command doesn't linger after the
912 // dialog opens/closes.
913 if name := i.suggest.Selection(i.ed.Value()); name != "" {
914 i.ed.PushHistory(name)
915 i.ed.Clear()
916 i.suggest.Reset()
917 return i.runSlash(ctx, name)
918 }
919 case tui.KeyEsc:
920 i.ed.Clear()
921 i.suggest.Reset()
922 return false
923 }
924 }
925
926 if submit := i.ed.HandleKey(k); submit {
927 text := strings.TrimRight(i.ed.Value(), "\n")
928 if text == "" {
929 return false
930 }
931 i.ed.PushHistory(text)
932 i.ed.Clear()
933 i.suggest.Reset()
934
935 if looksLikeSlashCommand(text) {
936 head := text
937 rest := ""
938 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
939 head = text[:idx]
940 rest = strings.TrimSpace(text[idx:])
941 }
942 if !isKnownSlashCommand(text) {
943 // Try extensions before giving up. Extensions register
944 // commands by bare name (no leading slash); strip it here.
945 extName := strings.TrimPrefix(head, "/")
946 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
947 go i.invokeExtensionCommand(ctx, extName, rest)
948 return false
949 }
950 i.mu.Lock()
951 i.statusErr = "unknown command " + head + " — type /help to see the list"
952 i.statusOK = ""
953 i.mu.Unlock()
954 return false
955 }
956 // Slash commands run regardless of busy state. Commands that
957 // would mutate the transcript or replace the agent (/clear,
958 // /compact, /logout, /login, /model) cancel the active turn
959 // first and wait for the goroutine to wind down so they don't
960 // race with a streaming response. Safe commands (/help,
961 // /jump, /sessions, /lock, /unlock, /exit) run immediately
962 // without disturbing the active turn.
963 if slashCancelsTurn(head) {
964 i.cancelAndWaitForIdle()
965 }
966 return i.runSlash(ctx, text)
967 }
968
969 if i.agent == nil {
970 i.mu.Lock()
971 i.statusErr = "not logged in. type /login first."
972 i.mu.Unlock()
973 return false
974 }
975 // If a turn is already in flight, queue this prompt instead of
976 // starting a second one. The drain loop at the end of startTurn
977 // will pick it up when the current turn finishes.
978 i.mu.Lock()
979 if i.busy {
980 i.queued = append(i.queued, text)
981 i.mu.Unlock()
982 i.invalidate()
983 return false
984 }
985 i.mu.Unlock()
986 i.startTurn(ctx, text)
987 }
988 return false
989}
990
991// invokeExtensionCommand fires an extension-registered slash command
992// in a background goroutine, awaits the response, and applies the
993// requested action (prompt / insert / display / noop). Errors and
994// timeouts surface as a status_err line.
995func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
996 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
997 if err != nil {
998 i.mu.Lock()
999 i.statusErr = "extension /" + name + ": " + err.Error()
1000 i.statusOK = ""
1001 i.mu.Unlock()
1002 i.invalidate()
1003 return
1004 }
1005 if resp.Error != "" {
1006 i.mu.Lock()
1007 i.statusErr = "extension /" + name + ": " + resp.Error
1008 i.statusOK = ""
1009 i.mu.Unlock()
1010 i.invalidate()
1011 return
1012 }
1013 switch resp.Action {
1014 case "prompt":
1015 if strings.TrimSpace(resp.Prompt) == "" {
1016 return
1017 }
1018 i.startTurn(i.runCtx, resp.Prompt)
1019 case "insert":
1020 i.ed.Insert(resp.Insert)
1021 i.invalidate()
1022 case "display":
1023 i.appendExtensionNote(name, resp.Display, "info")
1024 case "noop", "":
1025 // nothing
1026 default:
1027 i.mu.Lock()
1028 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1029 i.mu.Unlock()
1030 i.invalidate()
1031 }
1032}
1033
1034// appendExtensionNote renders an extension-originated note in the
1035// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1036// "success" (tool/ok green).
1037func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1038 if msg == "" {
1039 return
1040 }
1041 i.mu.Lock()
1042 defer i.mu.Unlock()
1043 color := i.cfg.Theme.Muted
1044 switch level {
1045 case "warn":
1046 color = i.cfg.Theme.Warning
1047 case "error":
1048 color = i.cfg.Theme.Error
1049 case "success":
1050 color = i.cfg.Theme.Tool
1051 }
1052 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1053 for _, line := range strings.Split(msg, "\n") {
1054 i.statusOK = "" // clear any stale ok
1055 i.statusErr = ""
1056 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1057 }
1058}
1059
1060// HostHooks implementation for the extension manager. The manager
1061// holds an interface, not a concrete *Interactive, so these methods
1062// are the only thing the manager sees.
1063
1064// Notify is the manager's NotifyFromExt entry point.
1065func (i *Interactive) Notify(extName, level, message string) {
1066 i.appendExtensionNote(extName, message, level)
1067 i.invalidate()
1068}
1069
1070// Submit feeds text through the agent loop as if the user had typed it.
1071func (i *Interactive) Submit(text string) {
1072 i.startTurn(i.runCtx, text)
1073}
1074
1075// Insert places text at the cursor in the editor.
1076func (i *Interactive) Insert(text string) {
1077 i.ed.Insert(text)
1078 i.invalidate()
1079}
1080
1081// Display appends a styled note from extName to the chat without a
1082// model call.
1083func (i *Interactive) Display(extName, text string) {
1084 i.appendExtensionNote(extName, text, "info")
1085 i.invalidate()
1086}
1087
1088func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1089 parts := strings.Fields(cmd)
1090 switch parts[0] {
1091 case "/exit":
1092 return true
1093 case "/clear":
1094 if i.agent != nil {
1095 i.agent.SetMessages(nil)
1096 }
1097 i.mu.Lock()
1098 i.toolCalls = map[string]*tui.ToolCallView{}
1099 i.toolOrder = nil
1100 i.statusErr = ""
1101 i.statusOK = ""
1102 i.helpBlock = nil
1103 i.parkedTurn = 0
1104 i.parkedTotal = 0
1105 i.scrollOffset = 0
1106 i.extNotes = nil
1107 i.view.InvalidateRenderCache()
1108 i.mu.Unlock()
1109 case "/help":
1110 i.mu.Lock()
1111 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1112 i.statusErr = ""
1113 i.statusOK = ""
1114 // Pin the viewport to the newest content so the help block,
1115 // which we just appended to the end of the transcript, is
1116 // what the user actually sees.
1117 i.scrollOffset = 0
1118 i.mu.Unlock()
1119 case "/login":
1120 i.dialog.Open()
1121 case "/logout":
1122 target := "all"
1123 if len(parts) >= 2 {
1124 target = parts[1]
1125 }
1126 i.doLogout(target)
1127 case "/model":
1128 if len(parts) >= 2 {
1129 i.applyModelSelection("", parts[1])
1130 } else {
1131 i.modelDialog.Open(i.cfg.Model)
1132 }
1133 case "/sessions":
1134 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1135 case "/jump":
1136 i.openJumpDialog(parts[1:])
1137 case "/btw":
1138 i.openBtwDialog(parts[1:])
1139 case "/skills":
1140 i.openSkillsDialog()
1141 case "/compact":
1142 i.runCompact(ctx, false)
1143 case "/lock":
1144 if i.cfg.Sandbox == nil {
1145 i.mu.Lock()
1146 i.statusErr = "sandbox not available in this build"
1147 i.mu.Unlock()
1148 break
1149 }
1150 i.cfg.Sandbox.Lock()
1151 i.mu.Lock()
1152 i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1153 i.statusErr = ""
1154 i.mu.Unlock()
1155 case "/unlock":
1156 if i.cfg.Sandbox == nil {
1157 i.mu.Lock()
1158 i.statusErr = "sandbox not available in this build"
1159 i.mu.Unlock()
1160 break
1161 }
1162 i.cfg.Sandbox.Unlock()
1163 i.mu.Lock()
1164 i.statusOK = "unlocked"
1165 i.statusErr = ""
1166 i.mu.Unlock()
1167 default:
1168 // Last-resort fallback: try the extension manager. Built-in
1169 // cases above always win; this branch only fires for slash
1170 // commands the extension manager registered. Same routing as
1171 // the editor's submit-handler dispatch path so the autocomplete
1172 // "enter on highlighted suggestion" flow also works.
1173 extName := strings.TrimPrefix(parts[0], "/")
1174 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1175 rest := ""
1176 if len(parts) > 1 {
1177 rest = strings.Join(parts[1:], " ")
1178 }
1179 go i.invokeExtensionCommand(ctx, extName, rest)
1180 return false
1181 }
1182 i.mu.Lock()
1183 i.statusErr = "unknown command: " + parts[0]
1184 i.mu.Unlock()
1185 }
1186 return false
1187}
1188
1189// doLogout clears credentials for the given provider (or all providers)
1190// from auth.json. If the active agent was using those credentials, it
1191// is torn down so the user is forced through /login before their next
1192// prompt.
1193//
1194// target: "anthropic" | "openai" | "all"
1195func (i *Interactive) doLogout(target string) {
1196 if i.cfg.AuthManager == nil {
1197 i.mu.Lock()
1198 i.statusErr = "no auth manager configured"
1199 i.mu.Unlock()
1200 return
1201 }
1202 store := i.cfg.AuthManager.Store()
1203 if store == nil {
1204 i.mu.Lock()
1205 i.statusErr = "auth store is not available"
1206 i.mu.Unlock()
1207 return
1208 }
1209
1210 var providers []string
1211 switch target {
1212 case "", "all":
1213 providers = []string{"anthropic", "openai"}
1214 case "anthropic", "openai":
1215 providers = []string{target}
1216 default:
1217 i.mu.Lock()
1218 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1219 i.mu.Unlock()
1220 return
1221 }
1222
1223 var errs []string
1224 clearedCurrent := false
1225 for _, p := range providers {
1226 if err := store.Clear(p); err != nil {
1227 errs = append(errs, p+": "+err.Error())
1228 continue
1229 }
1230 if p == i.cfg.Provider {
1231 clearedCurrent = true
1232 }
1233 }
1234
1235 i.mu.Lock()
1236 defer i.mu.Unlock()
1237 if len(errs) > 0 {
1238 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1239 return
1240 }
1241 i.statusErr = ""
1242 if clearedCurrent {
1243 // The running agent was using a credential we just wiped. Drop
1244 // it so prompts can't go out with the stale client, and hint at
1245 // /login.
1246 i.agent = nil
1247 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1248 } else {
1249 i.statusOK = "logged out of " + strings.Join(providers, ", ")
1250 }
1251}
1252
1253func (i *Interactive) startAPIKeyFlow(provider string) {
1254 url, err := i.cfg.AuthManager.StartAPIKey(provider)
1255 if err != nil {
1256 i.dialog.ShowResult(false, err.Error())
1257 return
1258 }
1259 i.dialog.ShowWaiting(url)
1260}
1261
1262func (i *Interactive) startOAuthFlow(provider string) {
1263 url, err := i.cfg.AuthManager.StartOAuth(provider)
1264 if err != nil {
1265 i.dialog.ShowResult(false, err.Error())
1266 return
1267 }
1268 i.dialog.ShowWaiting(url)
1269}
1270
1271// applyModelSelection switches the active model (and provider, if the
1272// new model belongs to a different one). It rebuilds the underlying
1273// client when needed so the provider wire-protocol matches.
1274// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1275// briefly until the turn goroutine has updated i.busy = false. Used
1276// before destructive slash commands so transcript-mutating work
1277// (/clear, /compact, /logout, /login completion, cross-provider
1278// /model swap) doesn't race with the still-running stream.
1279//
1280// The wait is bounded; if the turn doesn't release within the timeout
1281// we proceed anyway. Worst case is a brief overlap that the agent's
1282// own mutex protects against.
1283func (i *Interactive) cancelAndWaitForIdle() {
1284 i.mu.Lock()
1285 busy := i.busy
1286 cancel := i.cancelTurn
1287 i.mu.Unlock()
1288 if !busy {
1289 return
1290 }
1291 if cancel != nil {
1292 cancel()
1293 }
1294 deadline := time.Now().Add(2 * time.Second)
1295 for time.Now().Before(deadline) {
1296 i.mu.Lock()
1297 done := !i.busy
1298 i.mu.Unlock()
1299 if done {
1300 return
1301 }
1302 time.Sleep(10 * time.Millisecond)
1303 }
1304}
1305
1306// openBtwDialog opens the side-chat overlay with a frozen snapshot
1307// of the current main session. The optional argument is auto-
1308// submitted as the first question, so '/btw does X work?' fires the
1309// model call immediately instead of just opening an empty dialog.
1310func (i *Interactive) openBtwDialog(args []string) {
1311 if i.agent == nil {
1312 i.mu.Lock()
1313 i.statusErr = "not logged in. type /login first."
1314 i.mu.Unlock()
1315 return
1316 }
1317 seed := strings.TrimSpace(strings.Join(args, " "))
1318 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1319 i.invalidate()
1320}
1321
1322// openSkillsDialog opens the skill inspector. The picker reflects
1323// whatever SkillSnapshot returns at call time, so edits to a
1324// SKILL.md made during a session show up on the next /skills.
1325func (i *Interactive) openSkillsDialog() {
1326 var list []*skills.Skill
1327 if i.cfg.SkillSnapshot != nil {
1328 list = i.cfg.SkillSnapshot()
1329 }
1330 i.skillsDialog.Open(list)
1331 i.invalidate()
1332}
1333
1334// openJumpDialog builds a /jump picker from the current transcript.
1335// If the user typed "/jump foo" with a filter and it matches exactly
1336// one turn, jump there directly without showing the dialog.
1337func (i *Interactive) openJumpDialog(args []string) {
1338 if i.view == nil || len(i.view.Messages) == 0 {
1339 i.mu.Lock()
1340 i.statusErr = "nothing to jump to \u2014 the session is empty"
1341 i.mu.Unlock()
1342 return
1343 }
1344 filter := strings.TrimSpace(strings.Join(args, " "))
1345 i.jumpDialog.Open(i.view.Messages, filter)
1346 // Shortcut: with a filter argument that matches exactly one turn,
1347 // jump immediately and skip the picker.
1348 if filter != "" {
1349 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1350 t := tgts[0]
1351 i.jumpDialog.Close()
1352 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1353 }
1354 }
1355}
1356
1357// applyJumpSelection scrolls the chat viewport so the user message at
1358// msgIdx is visible at (or near) the top of the chat area. Uses the
1359// anchor slice returned by view.BuildWithAnchors so the mapping from
1360// message index to row is exact, regardless of variable-height tool
1361// blocks above the target.
1362func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1363 cols := i.lastCols()
1364 chat, anchors := i.view.BuildWithAnchors(cols)
1365 var row int
1366 found := false
1367 for _, a := range anchors {
1368 if a.MessageIdx == msgIdx {
1369 row = a.Row
1370 found = true
1371 break
1372 }
1373 }
1374 if !found {
1375 i.mu.Lock()
1376 i.statusErr = "could not resolve jump target"
1377 i.mu.Unlock()
1378 return
1379 }
1380
1381 chatLen := len(chat)
1382 page := i.chatPage()
1383 if page < 1 {
1384 page = 1
1385 }
1386 // scrollOffset is measured from the bottom of the chat slice, so
1387 // to place `row` at the top of the viewport we want:
1388 // chatLen - scrollOffset - page == row
1389 // Solve for scrollOffset and clamp to [0, chatLen-page].
1390 offset := chatLen - (row + page)
1391 if offset < 0 {
1392 offset = 0
1393 }
1394 maxOffset := chatLen - page
1395 if maxOffset < 0 {
1396 maxOffset = 0
1397 }
1398 if offset > maxOffset {
1399 offset = maxOffset
1400 }
1401
1402 i.mu.Lock()
1403 i.scrollOffset = offset
1404 i.parkedTurn = turnNo
1405 i.parkedTotal = totalTurnsLocked(i.view.Messages)
1406 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1407 i.statusErr = ""
1408 i.mu.Unlock()
1409}
1410
1411// totalTurnsLocked counts user messages in the transcript. Caller is
1412// assumed to hold i.mu (the name is a mild reminder; this function
1413// itself doesn't touch shared state beyond the slice it's handed).
1414func totalTurnsLocked(msgs []provider.Message) int {
1415 n := 0
1416 for _, m := range msgs {
1417 if m.Role == provider.RoleUser {
1418 n++
1419 }
1420 }
1421 return n
1422}
1423
1424// applySessionSelection loads the given session via the cli-provided
1425// callback and parks the viewport on the last turn so the user lands
1426// looking at where the conversation left off (their last prompt at the
1427// top of the chat, the assistant's last reply right below). Older
1428// history is one scroll up; pgdn or end snaps to the current tail.
1429//
1430// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1431// which on a long resumed session showed only the last few rows of
1432// the final assistant message — the user read that as "only one liner
1433// happened, the resume didn't work".
1434func (i *Interactive) applySessionSelection(path string) {
1435 if i.cfg.LoadSession == nil {
1436 i.mu.Lock()
1437 i.statusErr = "session loading is not wired in this build"
1438 i.mu.Unlock()
1439 return
1440 }
1441 if err := i.cfg.LoadSession(path); err != nil {
1442 i.mu.Lock()
1443 i.statusErr = err.Error()
1444 i.mu.Unlock()
1445 return
1446 }
1447
1448 i.mu.Lock()
1449 i.statusOK = "resumed session: " + path
1450 i.statusErr = ""
1451 i.parkedTurn = 0
1452 i.parkedTotal = 0
1453 i.view.InvalidateRenderCache()
1454 // Pull the freshly-loaded transcript into the view so the anchor
1455 // math below sees the post-resume messages, not the empty pre-load
1456 // state. redraw() does the same on its next pass; we just front-run
1457 // it here to compute the scroll target.
1458 if i.agent != nil {
1459 i.view.Messages = i.agent.Messages()
1460 }
1461 msgs := i.view.Messages
1462 i.mu.Unlock()
1463
1464 i.scrollToLastTurn(msgs)
1465}
1466
1467// scrollToLastTurn parks the viewport at the most recent user turn,
1468// or at the top if the transcript has no user messages. Used after
1469// resume so the user lands looking at where they left off.
1470func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1471 if len(msgs) == 0 {
1472 i.mu.Lock()
1473 i.scrollOffset = 0
1474 i.mu.Unlock()
1475 return
1476 }
1477 // Find the last user message index.
1478 lastUser := -1
1479 turnNo, totalTurns := 0, 0
1480 for idx, m := range msgs {
1481 if m.Role == provider.RoleUser {
1482 totalTurns++
1483 lastUser = idx
1484 }
1485 }
1486 if lastUser < 0 {
1487 i.mu.Lock()
1488 i.scrollOffset = 0
1489 i.mu.Unlock()
1490 return
1491 }
1492 turnNo = totalTurns
1493
1494 cols := i.lastCols()
1495 chat, anchors := i.view.BuildWithAnchors(cols)
1496 var row int
1497 found := false
1498 for _, a := range anchors {
1499 if a.MessageIdx == lastUser {
1500 row = a.Row
1501 found = true
1502 break
1503 }
1504 }
1505 if !found {
1506 i.mu.Lock()
1507 i.scrollOffset = 0
1508 i.mu.Unlock()
1509 return
1510 }
1511
1512 chatLen := len(chat)
1513 page := i.chatPage()
1514 if page < 1 {
1515 page = 1
1516 }
1517 offset := chatLen - (row + page)
1518 if offset < 0 {
1519 offset = 0
1520 }
1521 maxOffset := chatLen - page
1522 if maxOffset < 0 {
1523 maxOffset = 0
1524 }
1525 if offset > maxOffset {
1526 offset = maxOffset
1527 }
1528
1529 i.mu.Lock()
1530 i.scrollOffset = offset
1531 // Mark the parked-turn footer so the user sees "viewing turn N of
1532 // M · pgdn to catch up" — same affordance as /jump. Tells them at
1533 // a glance that they're looking at history, not the live tail.
1534 if offset > 0 {
1535 i.parkedTurn = turnNo
1536 i.parkedTotal = totalTurns
1537 }
1538 i.mu.Unlock()
1539 i.invalidate()
1540}
1541
1542func (i *Interactive) applyModelSelection(prov, model string) {
1543 if model == "" {
1544 return
1545 }
1546 m, err := provider.FindModel(prov, model)
1547 if err != nil {
1548 i.mu.Lock()
1549 i.statusErr = err.Error()
1550 i.mu.Unlock()
1551 return
1552 }
1553 // Same provider? Just swap the model on the existing agent.
1554 if i.agent != nil && m.Provider == i.cfg.Provider {
1555 i.mu.Lock()
1556 i.cfg.Model = m.ID
1557 i.agent.Model = m.ID
1558 i.statusOK = "model: " + m.ID
1559 i.statusErr = ""
1560 i.mu.Unlock()
1561 if i.cfg.PersistModel != nil {
1562 i.cfg.PersistModel(i.cfg.Provider, m.ID)
1563 }
1564 return
1565 }
1566 // Different provider: rebuild agent (needs credentials for target provider).
1567 if i.cfg.BuildAgentFor == nil {
1568 i.mu.Lock()
1569 i.statusErr = "cannot switch provider: no builder configured"
1570 i.mu.Unlock()
1571 return
1572 }
1573 // Snapshot the current transcript and cumulative usage BEFORE we
1574 // build the replacement agent so we can hand them off. Without
1575 // this the user perceives the entire session as wiped on a
1576 // cross-provider /model swap.
1577 var carryMsgs []provider.Message
1578 var carryCost provider.Usage
1579 if i.agent != nil {
1580 carryMsgs = i.agent.Messages()
1581 carryCost = i.agent.Cost()
1582 }
1583
1584 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1585 if err != nil {
1586 i.mu.Lock()
1587 i.statusErr = err.Error()
1588 i.mu.Unlock()
1589 return
1590 }
1591
1592 // Replay the transcript and seed the cost on the freshly-built
1593 // agent. Messages travel cleanly between providers because they
1594 // use the same provider.Message shape; tool-call ids are local
1595 // to a turn so cross-provider continuation never confuses the
1596 // new model (it just sees the assistant's reply, no orphan
1597 // tool_use blocks because /model swaps are gated to idle state).
1598 if len(carryMsgs) > 0 {
1599 ag.SetMessages(carryMsgs)
1600 }
1601 ag.SeedCost(carryCost)
1602
1603 i.mu.Lock()
1604 i.agent = ag
1605 i.cfg.Provider = p
1606 i.cfg.Model = md
1607 i.statusOK = "switched to " + p + " / " + md
1608 i.statusErr = ""
1609 // Render cache keys are width+content based, so the new agent's
1610 // identical messages will reuse the existing entries. Nothing
1611 // to invalidate.
1612 i.mu.Unlock()
1613 if i.cfg.PersistModel != nil {
1614 i.cfg.PersistModel(p, md)
1615 }
1616}
1617
1618func (i *Interactive) handleAuthEvent(ev auth.Event) {
1619 switch ev.Kind {
1620 case "started":
1621 i.dialog.ShowWaiting(ev.URL)
1622 case "browser_open":
1623 // no-op
1624 case "error":
1625 i.dialog.ShowResult(false, ev.Message)
1626 case "success":
1627 // Rebuild the agent with the fresh credential.
1628 ag, prov, model, err := i.cfg.BuildAgent()
1629 if err != nil {
1630 i.dialog.ShowResult(false, err.Error())
1631 return
1632 }
1633 i.mu.Lock()
1634 i.agent = ag
1635 i.cfg.Provider = prov
1636 i.cfg.Model = model
1637 i.statusErr = ""
1638 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1639 i.mu.Unlock()
1640 i.dialog.ShowResult(true, "")
1641 }
1642}
1643
1644// runCompact invokes core.Agent.Compact and reflects the progress in
1645// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1646// cancel via the same cancelTurn channel used for normal turns.
1647//
1648// When auto is true the spinner message is pinned to "condensing
1649// history" and the status bar surfaces "(auto)" next to the context
1650// percentage so it's obvious the system triggered this, not the user.
1651func (i *Interactive) runCompact(parent context.Context, auto bool) {
1652 if i.agent == nil {
1653 i.mu.Lock()
1654 i.statusErr = "not logged in. type /login first."
1655 i.mu.Unlock()
1656 return
1657 }
1658 ctx, cancel := context.WithCancel(parent)
1659 i.mu.Lock()
1660 i.busy = true
1661 if auto {
1662 i.spin.StartFixed("condensing history")
1663 i.autoCompacting = true
1664 i.statusOK = "condensing history… (esc to cancel)"
1665 } else {
1666 i.spin.Start()
1667 i.statusOK = "compacting..."
1668 }
1669 i.cancelTurn = cancel
1670 i.statusErr = ""
1671 i.streaming.Reset()
1672 i.streamOn = true
1673 i.scrollOffset = 0
1674 i.helpBlock = nil
1675 i.mu.Unlock()
1676 i.invalidate()
1677
1678 go func() {
1679 sink := func(delta string) {
1680 i.mu.Lock()
1681 i.streaming.WriteString(delta)
1682 i.mu.Unlock()
1683 i.invalidate()
1684 }
1685 summary, err := i.agent.Compact(ctx, 4, sink)
1686 i.mu.Lock()
1687 i.busy = false
1688 i.streamOn = false
1689 i.streaming.Reset()
1690 i.cancelTurn = nil
1691 i.autoCompacting = false
1692 switch {
1693 case err != nil && ctx.Err() != nil:
1694 i.statusErr = ""
1695 if auto {
1696 i.statusOK = "auto-condense cancelled"
1697 } else {
1698 i.statusOK = "compaction cancelled"
1699 }
1700 case err != nil:
1701 i.statusErr = "compaction failed: " + err.Error()
1702 i.statusOK = ""
1703 default:
1704 i.statusErr = ""
1705 i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1706 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1707 i.toolCalls = map[string]*tui.ToolCallView{}
1708 i.toolOrder = nil
1709 // Transcript was rewritten in place — purge the per-message
1710 // render cache so stale entries keyed on the old messages
1711 // don't linger.
1712 i.view.InvalidateRenderCache()
1713 }
1714 i.mu.Unlock()
1715 i.invalidate()
1716 }()
1717}
1718
1719func (i *Interactive) startTurn(parent context.Context, prompt string) {
1720 if i.agent == nil {
1721 return
1722 }
1723 ctx, cancel := context.WithCancel(parent)
1724 i.mu.Lock()
1725 i.busy = true
1726 i.spin.Start()
1727 i.cancelTurn = cancel
1728 i.statusErr = ""
1729 i.statusOK = ""
1730 i.streaming.Reset()
1731 i.streamOn = true
1732 i.toolCalls = map[string]*tui.ToolCallView{}
1733 i.toolOrder = nil
1734 i.scrollOffset = 0 // jump back to the bottom on new turn
1735 i.parkedTurn = 0 // starting a turn clears the /jump parked state
1736 i.parkedTotal = 0
1737 i.helpBlock = nil // hide the help block once the user asks something
1738 i.mu.Unlock()
1739 i.invalidate()
1740
1741 sink := func(ev core.AgentEvent) {
1742 i.handleEvent(ev)
1743 i.invalidate()
1744 }
1745
1746 go func() {
1747 err := i.agent.Prompt(ctx, prompt, nil, sink)
1748 i.mu.Lock()
1749 i.busy = false
1750 i.streamOn = false
1751 i.cancelTurn = nil
1752 if err != nil && ctx.Err() == nil {
1753 i.statusErr = err.Error()
1754 }
1755 // Pop the next queued message, if any, and relaunch.
1756 var next string
1757 var hasNext bool
1758 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1759 next, i.queued = i.queued[0], i.queued[1:]
1760 hasNext = true
1761 }
1762 // If the turn was cancelled or errored, drop the queue so the
1763 // user isn't bombarded with stale messages after an interrupt.
1764 if ctx.Err() != nil || err != nil {
1765 i.queued = nil
1766 }
1767 // Decide whether the next thing to do is an auto-compaction.
1768 // Only fires when the turn completed cleanly AND the queue is
1769 // empty (otherwise a queued message would race the condense).
1770 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1771 i.mu.Unlock()
1772 i.invalidate()
1773 parent := i.runCtx
1774 if parent == nil {
1775 parent = context.Background()
1776 }
1777 switch {
1778 case hasNext:
1779 i.startTurn(parent, next)
1780 case shouldAutoCompact:
1781 i.runCompact(parent, true)
1782 }
1783 }()
1784}
1785
1786// autoCompactThreshold is the context-window fraction at which the
1787// agent will auto-compact after a turn ends. 0.85 leaves enough
1788// headroom for one more user prompt + response before we bump the
1789// hard limit.
1790const autoCompactThreshold = 0.85
1791
1792// shouldAutoCompactLocked reports whether the last turn pushed context
1793// usage past the auto-compact threshold. Must be called with i.mu
1794// held; it reads lastCtxInput and the current model's context window.
1795func (i *Interactive) shouldAutoCompactLocked() bool {
1796 if i.agent == nil {
1797 return false
1798 }
1799 if i.autoCompacting {
1800 return false
1801 }
1802 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1803 if err != nil || m.ContextWindow <= 0 {
1804 return false
1805 }
1806 if i.lastCtxInput <= 0 {
1807 return false
1808 }
1809 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1810}
1811
1812func (i *Interactive) handleEvent(ev core.AgentEvent) {
1813 i.mu.Lock()
1814 defer i.mu.Unlock()
1815 switch e := ev.(type) {
1816 case core.EvTextDelta:
1817 i.streaming.WriteString(e.Delta)
1818 case core.EvAssistantMessage:
1819 i.streaming.Reset()
1820 i.streamOn = false
1821 if i.cfg.OnAssistant != nil {
1822 i.cfg.OnAssistant(e.Message)
1823 }
1824 case core.EvToolCall:
1825 tcv := &tui.ToolCallView{
1826 ID: e.ID,
1827 Name: e.Name,
1828 Args: shortArgs(e.Args),
1829 }
1830 i.toolCalls[e.ID] = tcv
1831 i.toolOrder = append(i.toolOrder, e.ID)
1832 case core.EvToolResult:
1833 if tc, ok := i.toolCalls[e.ID]; ok {
1834 tc.Done = true
1835 tc.Error = e.Result.IsError
1836 var text strings.Builder
1837 for _, c := range e.Result.Content {
1838 if tb, ok := c.(provider.TextBlock); ok {
1839 if text.Len() > 0 {
1840 text.WriteString("\n")
1841 }
1842 text.WriteString(tb.Text)
1843 }
1844 }
1845 tc.Result = text.String()
1846 }
1847 if i.cfg.OnToolResult != nil {
1848 i.cfg.OnToolResult(e.ID, e.Result)
1849 }
1850 case core.EvUsage:
1851 i.cumUsage = e.Cumulative
1852 if e.Usage.InputTokens > 0 {
1853 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
1854 }
1855 case core.EvTurnEnd:
1856 if e.Stop == provider.StopAborted {
1857 // Aborted turn: discard the partial streaming text (it is not
1858 // persisted in the transcript) and clear any transient error.
1859 i.streaming.Reset()
1860 i.streamOn = false
1861 i.statusErr = ""
1862 i.statusOK = "cancelled"
1863 return
1864 }
1865 if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
1866 i.statusErr = e.Err.Error()
1867 }
1868 }
1869}
1870
1871// Agent returns the current agent, if any. Used by cli.go to flush the
1872// final transcript to the session file.
1873func (i *Interactive) Agent() *core.Agent {
1874 i.mu.Lock()
1875 defer i.mu.Unlock()
1876 return i.agent
1877}
1878
1879func shortArgs(raw json.RawMessage) string {
1880 var v any
1881 if err := json.Unmarshal(raw, &v); err != nil {
1882 return ""
1883 }
1884 if m, ok := v.(map[string]any); ok {
1885 for _, k := range []string{"path", "file_path", "command"} {
1886 if s, ok := m[k].(string); ok {
1887 if len(s) > 60 {
1888 s = s[:57] + "..."
1889 }
1890 return s
1891 }
1892 }
1893 }
1894 b, _ := json.Marshal(v)
1895 s := string(b)
1896 if len(s) > 60 {
1897 s = s[:57] + "..."
1898 }
1899 return s
1900}
1901
1902// silence unused import in some build configs
1903var _ = fmt.Sprintf