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