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