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