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. The
615 // spinner glyph and its funny-line message share the `zot`
616 // label colour (Theme.Assistant) so the whole "who's working"
617 // band reads at a glance. Elapsed time stays muted because it
618 // drifts every second and shouldn't grab focus.
619 var busyPrefix string
620 if i.busy {
621 busyPrefix = fmt.Sprintf("%s %s · %s",
622 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
623 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
624 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
625 )
626 }
627
628 ctxMax := 0
629 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
630 ctxMax = m.ContextWindow
631 }
632 statusLines := tui.StatusBar(tui.StatusBarParams{
633 Theme: i.cfg.Theme,
634 Provider: i.cfg.Provider,
635 Model: i.cfg.Model,
636 Busy: i.busy,
637 BusyPrefix: busyPrefix,
638 CWD: i.cfg.CWD,
639 Locked: i.cfg.Sandbox.Locked(),
640 Usage: i.cumUsage,
641 Subscription: i.cfg.AuthMethod == "oauth",
642 ContextUsed: i.lastCtxInput,
643 ContextMax: ctxMax,
644 AutoCompacting: i.autoCompacting,
645 Cols: cols,
646 })
647 edLines, curR, curC := i.ed.Render(cols)
648
649 // "Sliding in" chips for messages the user typed while a turn is
650 // in flight. Shown directly above the status bar so they're close
651 // to the editor but don't push the chat around.
652 var queue []string
653 if len(i.queued) > 0 {
654 for _, q := range i.queued {
655 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
656 text := truncateLine(q, cols-15)
657 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
658 }
659 }
660
661 // Bottom-sticky sections (always visible, never scroll).
662 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
663 bottom = append(bottom, dialog...)
664 bottom = append(bottom, suggest...)
665 bottom = append(bottom, queue...)
666 bottom = append(bottom, statusLines...)
667 bottom = append(bottom, edLines...)
668
669 _, rows := i.cfg.Terminal.Size()
670 chatRows := rows - len(bottom)
671 if chatRows < 1 {
672 chatRows = 1
673 }
674
675 // Apply scroll offset to the chat slice.
676 maxOffset := len(chat) - chatRows
677 if maxOffset < 0 {
678 maxOffset = 0
679 }
680 if i.scrollOffset > maxOffset {
681 i.scrollOffset = maxOffset
682 }
683 if i.scrollOffset < 0 {
684 i.scrollOffset = 0
685 }
686
687 var visibleChat []string
688 if len(chat) <= chatRows {
689 visibleChat = chat
690 } else {
691 end := len(chat) - i.scrollOffset
692 start := end - chatRows
693 if start < 0 {
694 start = 0
695 }
696 visibleChat = chat[start:end]
697 }
698
699 // A tiny "scrolled up" indicator in the top-right of the chat pane
700 // so you know you're not at the bottom. When the viewport was
701 // parked by /jump we include the turn number so the user remembers
702 // they're reading history rather than the live conversation.
703 if i.scrollOffset > 0 && len(visibleChat) > 0 {
704 var text string
705 if i.parkedTurn > 0 && i.parkedTotal > 0 {
706 text = fmt.Sprintf(" ↑ viewing turn %d of %d · %d lines more below (pgdn / end)",
707 i.parkedTurn, i.parkedTotal, i.scrollOffset)
708 } else {
709 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
710 }
711 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
712 visibleChat = append([]string{note}, visibleChat...)
713 if len(visibleChat) > chatRows {
714 visibleChat = visibleChat[:chatRows]
715 }
716 }
717
718 frame := make([]string, 0, len(visibleChat)+len(bottom))
719 frame = append(frame, visibleChat...)
720 frame = append(frame, bottom...)
721
722 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
723 cursorCol := curC
724 i.rend.Draw(frame, cursorRow, cursorCol)
725}
726
727// truncateLine shortens s so it fits within n display cells, with an
728// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
729// novel doesn't blow past the status line.
730func truncateLine(s string, n int) string {
731 if n <= 0 {
732 return ""
733 }
734 // Collapse newlines — chips are single line.
735 s = strings.ReplaceAll(s, "\n", " ↩ ")
736 runes := []rune(s)
737 if len(runes) <= n {
738 return s
739 }
740 if n <= 1 {
741 return "…"
742 }
743 return string(runes[:n-1]) + "…"
744}
745
746// ctrlCExitWindow is how long after a ctrl+c press a *second* press
747// will exit instead of just clearing input. Long enough to be
748// deliberate (rules out accidental key chord), short enough that the
749// hint stays meaningful.
750const ctrlCExitWindow = 2 * time.Second
751
752// armCtrlCExit records the timestamp of the current ctrl+c so the next
753// one within ctrlCExitWindow exits.
754func (i *Interactive) armCtrlCExit() {
755 i.mu.Lock()
756 i.lastCtrlC = time.Now()
757 i.mu.Unlock()
758}
759
760// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
761// that another press should now exit.
762func (i *Interactive) ctrlCExitArmed() bool {
763 i.mu.Lock()
764 t := i.lastCtrlC
765 i.mu.Unlock()
766 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
767}
768
769func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
770 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
771 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
772 // unexpectedly. The hint message also goes stale; clear it.
773 if k.Kind != tui.KeyCtrlC {
774 i.mu.Lock()
775 if !i.lastCtrlC.IsZero() {
776 i.lastCtrlC = time.Time{}
777 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
778 i.statusOK = ""
779 }
780 }
781 i.mu.Unlock()
782 }
783
784 // Dialogs consume keys while open (except ctrl+c, which always closes them).
785
786 // Confirm dialog has highest priority: the agent goroutine is
787 // blocked waiting for an answer, so we must not let keys leak
788 // anywhere else while it's up.
789 if i.confirmDialog.Active() {
790 i.confirmDialog.HandleKey(k)
791 i.invalidate()
792 return false
793 }
794 if i.dialog.Active() {
795 if k.Kind == tui.KeyCtrlC {
796 i.dialog.Close()
797 if i.cfg.AuthManager != nil {
798 i.cfg.AuthManager.CancelOAuth()
799 }
800 return false
801 }
802 act := i.dialog.HandleKey(k)
803 if act.StartAPIKey {
804 i.startAPIKeyFlow(act.Provider)
805 }
806 if act.StartOAuth {
807 i.startOAuthFlow(act.Provider)
808 }
809 return false
810 }
811 if i.modelDialog.Active() {
812 if k.Kind == tui.KeyCtrlC {
813 i.modelDialog.Close()
814 return false
815 }
816 act := i.modelDialog.HandleKey(k)
817 if act.Select {
818 i.applyModelSelection(act.Provider, act.Model)
819 }
820 return false
821 }
822 if i.sessionDialog.Active() {
823 if k.Kind == tui.KeyCtrlC {
824 i.sessionDialog.Close()
825 return false
826 }
827 act := i.sessionDialog.HandleKey(k)
828 if act.Select {
829 i.applySessionSelection(act.Path)
830 }
831 return false
832 }
833 if i.logoutDialog.Active() {
834 if k.Kind == tui.KeyCtrlC {
835 i.logoutDialog.Close()
836 i.invalidate()
837 return false
838 }
839 act := i.logoutDialog.HandleKey(k)
840 if act.Select {
841 i.doLogout(act.Target)
842 }
843 i.invalidate()
844 return false
845 }
846 if i.jumpDialog.Active() {
847 if k.Kind == tui.KeyCtrlC {
848 i.jumpDialog.Close()
849 return false
850 }
851 act := i.jumpDialog.HandleKey(k)
852 if act.Select {
853 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
854 }
855 return false
856 }
857 if i.btwDialog.Active() {
858 if k.Kind == tui.KeyCtrlC {
859 i.btwDialog.Close()
860 i.invalidate()
861 return false
862 }
863 i.btwDialog.HandleKey(k, i.invalidate)
864 return false
865 }
866 if i.skillsDialog.Active() {
867 if k.Kind == tui.KeyCtrlC {
868 i.skillsDialog.Close()
869 i.invalidate()
870 return false
871 }
872 i.skillsDialog.HandleKey(k)
873 i.invalidate()
874 return false
875 }
876 if i.changelogDialog.Active() {
877 if closed := i.changelogDialog.HandleKey(k); closed {
878 // User dismissed; let the parent persist the
879 // LastChangelogShown marker via the close callback.
880 if i.cfg.OnChangelogDismiss != nil {
881 i.cfg.OnChangelogDismiss()
882 }
883 }
884 i.invalidate()
885 return false
886 }
887
888 // Global keys.
889 switch k.Kind {
890 case tui.KeyCtrlC:
891 // While busy: cancel the active turn (same as esc). The exit
892 // hint stays armed so a quick second ctrl+c after the turn
893 // dies still exits, matching habits from other repls.
894 if i.busy && i.cancelTurn != nil {
895 i.cancelTurn()
896 i.armCtrlCExit()
897 return false
898 }
899 // Idle: first press clears the editor (and any queued
900 // follow-up messages); a second press within ctrlCExitWindow
901 // exits. With both an empty editor and no queue the first
902 // press still just arms — require a deliberate double-tap.
903 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0
904 if hadInput {
905 i.ed.Clear()
906 i.suggest.Reset()
907 i.mu.Lock()
908 i.queued = nil
909 i.statusOK = "input cleared"
910 i.statusErr = ""
911 i.mu.Unlock()
912 i.armCtrlCExit()
913 return false
914 }
915 if i.ctrlCExitArmed() {
916 return true
917 }
918 i.mu.Lock()
919 i.statusOK = "press ctrl+c again to exit"
920 i.statusErr = ""
921 i.mu.Unlock()
922 i.armCtrlCExit()
923 return false
924 case tui.KeyEsc:
925 // Esc interrupts a running turn. When idle, fall through so the
926 // editor can clear itself.
927 if i.busy && i.cancelTurn != nil {
928 i.cancelTurn()
929 // If a confirm dialog is pending, refuse it so the agent
930 // goroutine unblocks and the context cancellation can
931 // actually take effect.
932 i.confirmDialog.CancelAll("turn cancelled")
933 return false
934 }
935 case tui.KeyCtrlD:
936 if i.ed.IsEmpty() && !i.busy {
937 return true
938 }
939 case tui.KeyCtrlL:
940 i.rend.Clear()
941 i.invalidate()
942 return false
943 case tui.KeyCtrlO:
944 // Toggle expansion of collapsed tool results. Affects every tool
945 // call in the transcript — press again to re-collapse.
946 i.mu.Lock()
947 i.view.ExpandAll = !i.view.ExpandAll
948 i.mu.Unlock()
949 i.invalidate()
950 return false
951 case tui.KeyPageUp:
952 i.scrollBy(+i.chatPage())
953 return false
954 case tui.KeyPageDown:
955 i.scrollBy(-i.chatPage())
956 return false
957 case tui.KeyUp:
958 // Wheel-up in alt screen sends Up arrows on most terminals.
959 // When the editor is empty we use up/down for chat scroll —
960 // independently of whether the agent is busy, so users can
961 // scroll back through long streaming replies while they run.
962 if i.ed.IsEmpty() {
963 i.scrollBy(+3)
964 return false
965 }
966 case tui.KeyDown:
967 if i.ed.IsEmpty() {
968 if i.scrollOffset > 0 {
969 i.scrollBy(-3)
970 }
971 return false
972 }
973 }
974
975 // Note: we intentionally do NOT gate the editor on i.busy here.
976 // Typing while the agent is working is supported — submitted
977 // messages are queued and delivered as follow-up turns when the
978 // current turn ends. See the submit handler below.
979
980 if k.Kind == tui.KeyEnter && k.Alt {
981 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
982 return false
983 }
984
985 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
986 if i.suggest.Active(i.ed.Value()) {
987 switch k.Kind {
988 case tui.KeyUp:
989 i.suggest.Up()
990 return false
991 case tui.KeyDown:
992 i.suggest.Down()
993 return false
994 case tui.KeyTab:
995 if name := i.suggest.Selection(i.ed.Value()); name != "" {
996 i.ed.SetValue(name)
997 i.suggest.Reset()
998 }
999 return false
1000 case tui.KeyEnter:
1001 // Enter on an ambiguous or partial slash prefix: complete to the
1002 // currently highlighted command and run it. That way typing
1003 // "/lo" + enter picks whichever of /login or /logout is selected
1004 // in the popup instead of submitting "/lo" as unknown. Also
1005 // clear the editor so the command doesn't linger after the
1006 // dialog opens/closes.
1007 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1008 i.ed.PushHistory(name)
1009 i.ed.Clear()
1010 i.suggest.Reset()
1011 return i.runSlash(ctx, name)
1012 }
1013 case tui.KeyEsc:
1014 i.ed.Clear()
1015 i.suggest.Reset()
1016 return false
1017 }
1018 }
1019
1020 if submit := i.ed.HandleKey(k); submit {
1021 text := strings.TrimRight(i.ed.Value(), "\n")
1022 if text == "" {
1023 return false
1024 }
1025 i.ed.PushHistory(text)
1026 i.ed.Clear()
1027 i.suggest.Reset()
1028
1029 if looksLikeSlashCommand(text) {
1030 head := text
1031 rest := ""
1032 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
1033 head = text[:idx]
1034 rest = strings.TrimSpace(text[idx:])
1035 }
1036 if !isKnownSlashCommand(text) {
1037 // Try extensions before giving up. Extensions register
1038 // commands by bare name (no leading slash); strip it here.
1039 extName := strings.TrimPrefix(head, "/")
1040 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1041 go i.invokeExtensionCommand(ctx, extName, rest)
1042 return false
1043 }
1044 i.mu.Lock()
1045 i.statusErr = "unknown command " + head + " — type /help to see the list"
1046 i.statusOK = ""
1047 i.mu.Unlock()
1048 return false
1049 }
1050 // Slash commands run regardless of busy state. Commands that
1051 // would mutate the transcript or replace the agent (/clear,
1052 // /compact, /logout, /login, /model) cancel the active turn
1053 // first and wait for the goroutine to wind down so they don't
1054 // race with a streaming response. Safe commands (/help,
1055 // /jump, /sessions, /lock, /unlock, /exit) run immediately
1056 // without disturbing the active turn.
1057 if slashCancelsTurn(head) {
1058 i.cancelAndWaitForIdle()
1059 }
1060 return i.runSlash(ctx, text)
1061 }
1062
1063 if i.agent == nil {
1064 i.mu.Lock()
1065 i.statusErr = "not logged in. type /login first."
1066 i.mu.Unlock()
1067 return false
1068 }
1069 // If a turn is already in flight, queue this prompt instead of
1070 // starting a second one. The drain loop at the end of startTurn
1071 // will pick it up when the current turn finishes.
1072 i.mu.Lock()
1073 if i.busy {
1074 i.queued = append(i.queued, text)
1075 i.mu.Unlock()
1076 i.invalidate()
1077 return false
1078 }
1079 i.mu.Unlock()
1080 i.startTurn(ctx, text)
1081 }
1082 return false
1083}
1084
1085// invokeExtensionCommand fires an extension-registered slash command
1086// in a background goroutine, awaits the response, and applies the
1087// requested action (prompt / insert / display / noop). Errors and
1088// timeouts surface as a status_err line.
1089func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
1090 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
1091 if err != nil {
1092 i.mu.Lock()
1093 i.statusErr = "extension /" + name + ": " + err.Error()
1094 i.statusOK = ""
1095 i.mu.Unlock()
1096 i.invalidate()
1097 return
1098 }
1099 if resp.Error != "" {
1100 i.mu.Lock()
1101 i.statusErr = "extension /" + name + ": " + resp.Error
1102 i.statusOK = ""
1103 i.mu.Unlock()
1104 i.invalidate()
1105 return
1106 }
1107 switch resp.Action {
1108 case "prompt":
1109 if strings.TrimSpace(resp.Prompt) == "" {
1110 return
1111 }
1112 i.startTurn(i.runCtx, resp.Prompt)
1113 case "insert":
1114 i.ed.Insert(resp.Insert)
1115 i.invalidate()
1116 case "display":
1117 i.appendExtensionNote(name, resp.Display, "info")
1118 case "noop", "":
1119 // nothing
1120 default:
1121 i.mu.Lock()
1122 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
1123 i.mu.Unlock()
1124 i.invalidate()
1125 }
1126}
1127
1128// appendExtensionNote renders an extension-originated note in the
1129// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1130// "success" (tool/ok green).
1131func (i *Interactive) appendExtensionNote(extName, msg, level string) {
1132 if msg == "" {
1133 return
1134 }
1135 i.mu.Lock()
1136 defer i.mu.Unlock()
1137 color := i.cfg.Theme.Muted
1138 switch level {
1139 case "warn":
1140 color = i.cfg.Theme.Warning
1141 case "error":
1142 color = i.cfg.Theme.Error
1143 case "success":
1144 color = i.cfg.Theme.Tool
1145 }
1146 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
1147 for _, line := range strings.Split(msg, "\n") {
1148 i.statusOK = "" // clear any stale ok
1149 i.statusErr = ""
1150 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
1151 }
1152}
1153
1154// HostHooks implementation for the extension manager. The manager
1155// holds an interface, not a concrete *Interactive, so these methods
1156// are the only thing the manager sees.
1157
1158// Notify is the manager's NotifyFromExt entry point.
1159func (i *Interactive) Notify(extName, level, message string) {
1160 i.appendExtensionNote(extName, message, level)
1161 i.invalidate()
1162}
1163
1164// Submit feeds text through the agent loop as if the user had typed it.
1165func (i *Interactive) Submit(text string) {
1166 i.startTurn(i.runCtx, text)
1167}
1168
1169// Insert places text at the cursor in the editor.
1170func (i *Interactive) Insert(text string) {
1171 i.ed.Insert(text)
1172 i.invalidate()
1173}
1174
1175// Display appends a styled note from extName to the chat without a
1176// model call.
1177func (i *Interactive) Display(extName, text string) {
1178 i.appendExtensionNote(extName, text, "info")
1179 i.invalidate()
1180}
1181
1182func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1183 parts := strings.Fields(cmd)
1184 switch parts[0] {
1185 case "/exit":
1186 return true
1187 case "/clear":
1188 if i.agent != nil {
1189 i.agent.SetMessages(nil)
1190 }
1191 i.mu.Lock()
1192 i.toolCalls = map[string]*tui.ToolCallView{}
1193 i.toolOrder = nil
1194 i.statusErr = ""
1195 i.statusOK = ""
1196 i.helpBlock = nil
1197 i.parkedTurn = 0
1198 i.parkedTotal = 0
1199 i.scrollOffset = 0
1200 i.extNotes = nil
1201 i.view.InvalidateRenderCache()
1202 i.mu.Unlock()
1203 case "/help":
1204 i.mu.Lock()
1205 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
1206 i.statusErr = ""
1207 i.statusOK = ""
1208 // Pin the viewport to the newest content so the help block,
1209 // which we just appended to the end of the transcript, is
1210 // what the user actually sees.
1211 i.scrollOffset = 0
1212 i.mu.Unlock()
1213 case "/login":
1214 i.dialog.Open()
1215 case "/logout":
1216 if len(parts) >= 2 {
1217 // Explicit target: /logout anthropic | openai | all
1218 i.doLogout(parts[1])
1219 break
1220 }
1221 // No arg: open the picker over whichever providers are
1222 // currently logged in. If nothing's logged in, bail with a
1223 // status line.
1224 i.openLogoutDialog()
1225 case "/model":
1226 if len(parts) >= 2 {
1227 i.applyModelSelection("", parts[1])
1228 } else {
1229 i.modelDialog.Open(i.cfg.Model)
1230 }
1231 case "/sessions":
1232 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
1233 case "/jump":
1234 i.openJumpDialog(parts[1:])
1235 case "/btw":
1236 i.openBtwDialog(parts[1:])
1237 case "/skills":
1238 i.openSkillsDialog()
1239 case "/compact":
1240 i.runCompact(ctx, false)
1241 case "/lock":
1242 if i.cfg.Sandbox == nil {
1243 i.mu.Lock()
1244 i.statusErr = "sandbox not available in this build"
1245 i.mu.Unlock()
1246 break
1247 }
1248 i.cfg.Sandbox.Lock()
1249 i.mu.Lock()
1250 i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
1251 i.statusErr = ""
1252 i.mu.Unlock()
1253 case "/unlock":
1254 if i.cfg.Sandbox == nil {
1255 i.mu.Lock()
1256 i.statusErr = "sandbox not available in this build"
1257 i.mu.Unlock()
1258 break
1259 }
1260 i.cfg.Sandbox.Unlock()
1261 i.mu.Lock()
1262 i.statusOK = "unlocked"
1263 i.statusErr = ""
1264 i.mu.Unlock()
1265 case "/reload-ext":
1266 i.runReloadExt(ctx)
1267 case "/yolo":
1268 i.runYoloOn()
1269 default:
1270 // Last-resort fallback: try the extension manager. Built-in
1271 // cases above always win; this branch only fires for slash
1272 // commands the extension manager registered. Same routing as
1273 // the editor's submit-handler dispatch path so the autocomplete
1274 // "enter on highlighted suggestion" flow also works.
1275 extName := strings.TrimPrefix(parts[0], "/")
1276 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1277 rest := ""
1278 if len(parts) > 1 {
1279 rest = strings.Join(parts[1:], " ")
1280 }
1281 go i.invokeExtensionCommand(ctx, extName, rest)
1282 return false
1283 }
1284 i.mu.Lock()
1285 i.statusErr = "unknown command: " + parts[0]
1286 i.mu.Unlock()
1287 }
1288 return false
1289}
1290
1291// openLogoutDialog shows the provider picker for `/logout` with no
1292// argument. Only providers the user is currently logged into are
1293// listed, plus an "all" entry when more than one is present. If
1294// nothing's logged in, writes a status line instead of opening an
1295// empty dialog.
1296func (i *Interactive) openLogoutDialog() {
1297 if i.cfg.AuthManager == nil {
1298 i.mu.Lock()
1299 i.statusErr = "no auth manager configured"
1300 i.mu.Unlock()
1301 i.invalidate()
1302 return
1303 }
1304 store := i.cfg.AuthManager.Store()
1305 if store == nil {
1306 i.mu.Lock()
1307 i.statusErr = "auth store is not available"
1308 i.mu.Unlock()
1309 i.invalidate()
1310 return
1311 }
1312 creds, err := store.Load()
1313 if err != nil {
1314 i.mu.Lock()
1315 i.statusErr = "read auth store: " + err.Error()
1316 i.mu.Unlock()
1317 i.invalidate()
1318 return
1319 }
1320
1321 var items []logoutItem
1322 for _, p := range []string{"anthropic", "openai"} {
1323 if creds.Has(p) {
1324 items = append(items, logoutItem{
1325 label: p,
1326 target: p,
1327 method: creds.Method(p),
1328 })
1329 }
1330 }
1331 if len(items) == 0 {
1332 i.mu.Lock()
1333 i.statusOK = "no credentials stored; already logged out"
1334 i.statusErr = ""
1335 i.mu.Unlock()
1336 i.invalidate()
1337 return
1338 }
1339 if len(items) > 1 {
1340 items = append(items, logoutItem{label: "all", target: "all"})
1341 }
1342
1343 i.logoutDialog.Open(items)
1344 i.invalidate()
1345}
1346
1347// doLogout clears credentials for the given provider (or all providers)
1348// from auth.json. If the active agent was using those credentials, it
1349// is torn down so the user is forced through /login before their next
1350// prompt.
1351//
1352// target: "anthropic" | "openai" | "all"
1353func (i *Interactive) doLogout(target string) {
1354 if i.cfg.AuthManager == nil {
1355 i.mu.Lock()
1356 i.statusErr = "no auth manager configured"
1357 i.mu.Unlock()
1358 return
1359 }
1360 store := i.cfg.AuthManager.Store()
1361 if store == nil {
1362 i.mu.Lock()
1363 i.statusErr = "auth store is not available"
1364 i.mu.Unlock()
1365 return
1366 }
1367
1368 var providers []string
1369 switch target {
1370 case "", "all":
1371 providers = []string{"anthropic", "openai"}
1372 case "anthropic", "openai":
1373 providers = []string{target}
1374 default:
1375 i.mu.Lock()
1376 i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
1377 i.mu.Unlock()
1378 return
1379 }
1380
1381 var errs []string
1382 clearedCurrent := false
1383 for _, p := range providers {
1384 if err := store.Clear(p); err != nil {
1385 errs = append(errs, p+": "+err.Error())
1386 continue
1387 }
1388 if p == i.cfg.Provider {
1389 clearedCurrent = true
1390 }
1391 }
1392
1393 i.mu.Lock()
1394 defer i.mu.Unlock()
1395 if len(errs) > 0 {
1396 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
1397 return
1398 }
1399 i.statusErr = ""
1400 if clearedCurrent {
1401 // The running agent was using a credential we just wiped. Drop
1402 // it so prompts can't go out with the stale client, and hint at
1403 // /login.
1404 i.agent = nil
1405 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
1406 } else {
1407 i.statusOK = "logged out of " + strings.Join(providers, ", ")
1408 }
1409}
1410
1411func (i *Interactive) startAPIKeyFlow(provider string) {
1412 url, err := i.cfg.AuthManager.StartAPIKey(provider)
1413 if err != nil {
1414 i.dialog.ShowResult(false, err.Error())
1415 return
1416 }
1417 i.dialog.ShowWaiting(url)
1418}
1419
1420func (i *Interactive) startOAuthFlow(provider string) {
1421 url, err := i.cfg.AuthManager.StartOAuth(provider)
1422 if err != nil {
1423 i.dialog.ShowResult(false, err.Error())
1424 return
1425 }
1426 i.dialog.ShowWaiting(url)
1427}
1428
1429// applyModelSelection switches the active model (and provider, if the
1430// new model belongs to a different one). It rebuilds the underlying
1431// client when needed so the provider wire-protocol matches.
1432// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1433// briefly until the turn goroutine has updated i.busy = false. Used
1434// before destructive slash commands so transcript-mutating work
1435// (/clear, /compact, /logout, /login completion, cross-provider
1436// /model swap) doesn't race with the still-running stream.
1437//
1438// The wait is bounded; if the turn doesn't release within the timeout
1439// we proceed anyway. Worst case is a brief overlap that the agent's
1440// own mutex protects against.
1441func (i *Interactive) cancelAndWaitForIdle() {
1442 i.mu.Lock()
1443 busy := i.busy
1444 cancel := i.cancelTurn
1445 i.mu.Unlock()
1446 if !busy {
1447 return
1448 }
1449 if cancel != nil {
1450 cancel()
1451 }
1452 deadline := time.Now().Add(2 * time.Second)
1453 for time.Now().Before(deadline) {
1454 i.mu.Lock()
1455 done := !i.busy
1456 i.mu.Unlock()
1457 if done {
1458 return
1459 }
1460 time.Sleep(10 * time.Millisecond)
1461 }
1462}
1463
1464// openBtwDialog opens the side-chat overlay with a frozen snapshot
1465// of the current main session. The optional argument is auto-
1466// submitted as the first question, so '/btw does X work?' fires the
1467// model call immediately instead of just opening an empty dialog.
1468func (i *Interactive) openBtwDialog(args []string) {
1469 if i.agent == nil {
1470 i.mu.Lock()
1471 i.statusErr = "not logged in. type /login first."
1472 i.mu.Unlock()
1473 return
1474 }
1475 seed := strings.TrimSpace(strings.Join(args, " "))
1476 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1477 i.invalidate()
1478}
1479
1480// openSkillsDialog opens the skill inspector. The picker reflects
1481// whatever SkillSnapshot returns at call time, so edits to a
1482// SKILL.md made during a session show up on the next /skills.
1483func (i *Interactive) openSkillsDialog() {
1484 var list []*skills.Skill
1485 if i.cfg.SkillSnapshot != nil {
1486 list = i.cfg.SkillSnapshot()
1487 }
1488 i.skillsDialog.Open(list)
1489 i.invalidate()
1490}
1491
1492// openJumpDialog builds a /jump picker from the current transcript.
1493// If the user typed "/jump foo" with a filter and it matches exactly
1494// one turn, jump there directly without showing the dialog.
1495func (i *Interactive) openJumpDialog(args []string) {
1496 if i.view == nil || len(i.view.Messages) == 0 {
1497 i.mu.Lock()
1498 i.statusErr = "nothing to jump to \u2014 the session is empty"
1499 i.mu.Unlock()
1500 return
1501 }
1502 filter := strings.TrimSpace(strings.Join(args, " "))
1503 i.jumpDialog.Open(i.view.Messages, filter)
1504 // Shortcut: with a filter argument that matches exactly one turn,
1505 // jump immediately and skip the picker.
1506 if filter != "" {
1507 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
1508 t := tgts[0]
1509 i.jumpDialog.Close()
1510 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
1511 }
1512 }
1513}
1514
1515// applyJumpSelection scrolls the chat viewport so the user message at
1516// msgIdx is visible at (or near) the top of the chat area. Uses the
1517// anchor slice returned by view.BuildWithAnchors so the mapping from
1518// message index to row is exact, regardless of variable-height tool
1519// blocks above the target.
1520func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
1521 cols := i.lastCols()
1522 chat, anchors := i.view.BuildWithAnchors(cols)
1523 var row int
1524 found := false
1525 for _, a := range anchors {
1526 if a.MessageIdx == msgIdx {
1527 row = a.Row
1528 found = true
1529 break
1530 }
1531 }
1532 if !found {
1533 i.mu.Lock()
1534 i.statusErr = "could not resolve jump target"
1535 i.mu.Unlock()
1536 return
1537 }
1538
1539 chatLen := len(chat)
1540 page := i.chatPage()
1541 if page < 1 {
1542 page = 1
1543 }
1544 // scrollOffset is measured from the bottom of the chat slice, so
1545 // to place `row` at the top of the viewport we want:
1546 // chatLen - scrollOffset - page == row
1547 // Solve for scrollOffset and clamp to [0, chatLen-page].
1548 offset := chatLen - (row + page)
1549 if offset < 0 {
1550 offset = 0
1551 }
1552 maxOffset := chatLen - page
1553 if maxOffset < 0 {
1554 maxOffset = 0
1555 }
1556 if offset > maxOffset {
1557 offset = maxOffset
1558 }
1559
1560 i.mu.Lock()
1561 i.scrollOffset = offset
1562 i.parkedTurn = turnNo
1563 i.parkedTotal = totalTurnsLocked(i.view.Messages)
1564 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
1565 i.statusErr = ""
1566 i.mu.Unlock()
1567}
1568
1569// totalTurnsLocked counts user messages in the transcript. Caller is
1570// assumed to hold i.mu (the name is a mild reminder; this function
1571// itself doesn't touch shared state beyond the slice it's handed).
1572func totalTurnsLocked(msgs []provider.Message) int {
1573 n := 0
1574 for _, m := range msgs {
1575 if m.Role == provider.RoleUser {
1576 n++
1577 }
1578 }
1579 return n
1580}
1581
1582// applySessionSelection loads the given session via the cli-provided
1583// callback and parks the viewport on the last turn so the user lands
1584// looking at where the conversation left off (their last prompt at the
1585// top of the chat, the assistant's last reply right below). Older
1586// history is one scroll up; pgdn or end snaps to the current tail.
1587//
1588// Without this, scrollOffset stayed at 0 (pinned to the live tail),
1589// which on a long resumed session showed only the last few rows of
1590// the final assistant message — the user read that as "only one liner
1591// happened, the resume didn't work".
1592func (i *Interactive) applySessionSelection(path string) {
1593 if i.cfg.LoadSession == nil {
1594 i.mu.Lock()
1595 i.statusErr = "session loading is not wired in this build"
1596 i.mu.Unlock()
1597 return
1598 }
1599 if err := i.cfg.LoadSession(path); err != nil {
1600 i.mu.Lock()
1601 i.statusErr = err.Error()
1602 i.mu.Unlock()
1603 return
1604 }
1605
1606 i.mu.Lock()
1607 i.statusOK = "resumed session: " + path
1608 i.statusErr = ""
1609 i.parkedTurn = 0
1610 i.parkedTotal = 0
1611 i.view.InvalidateRenderCache()
1612 // Pull the freshly-loaded transcript into the view so the anchor
1613 // math below sees the post-resume messages, not the empty pre-load
1614 // state. redraw() does the same on its next pass; we just front-run
1615 // it here to compute the scroll target.
1616 if i.agent != nil {
1617 i.view.Messages = i.agent.Messages()
1618 }
1619 msgs := i.view.Messages
1620 i.mu.Unlock()
1621
1622 i.scrollToLastTurn(msgs)
1623}
1624
1625// scrollToLastTurn parks the viewport at the most recent user turn,
1626// or at the top if the transcript has no user messages. Used after
1627// resume so the user lands looking at where they left off.
1628func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
1629 if len(msgs) == 0 {
1630 i.mu.Lock()
1631 i.scrollOffset = 0
1632 i.mu.Unlock()
1633 return
1634 }
1635 // Find the last user message index.
1636 lastUser := -1
1637 turnNo, totalTurns := 0, 0
1638 for idx, m := range msgs {
1639 if m.Role == provider.RoleUser {
1640 totalTurns++
1641 lastUser = idx
1642 }
1643 }
1644 if lastUser < 0 {
1645 i.mu.Lock()
1646 i.scrollOffset = 0
1647 i.mu.Unlock()
1648 return
1649 }
1650 turnNo = totalTurns
1651
1652 cols := i.lastCols()
1653 chat, anchors := i.view.BuildWithAnchors(cols)
1654 var row int
1655 found := false
1656 for _, a := range anchors {
1657 if a.MessageIdx == lastUser {
1658 row = a.Row
1659 found = true
1660 break
1661 }
1662 }
1663 if !found {
1664 i.mu.Lock()
1665 i.scrollOffset = 0
1666 i.mu.Unlock()
1667 return
1668 }
1669
1670 chatLen := len(chat)
1671 page := i.chatPage()
1672 if page < 1 {
1673 page = 1
1674 }
1675 offset := chatLen - (row + page)
1676 if offset < 0 {
1677 offset = 0
1678 }
1679 maxOffset := chatLen - page
1680 if maxOffset < 0 {
1681 maxOffset = 0
1682 }
1683 if offset > maxOffset {
1684 offset = maxOffset
1685 }
1686
1687 i.mu.Lock()
1688 i.scrollOffset = offset
1689 // Mark the parked-turn footer so the user sees "viewing turn N of
1690 // M · pgdn to catch up" — same affordance as /jump. Tells them at
1691 // a glance that they're looking at history, not the live tail.
1692 if offset > 0 {
1693 i.parkedTurn = turnNo
1694 i.parkedTotal = totalTurns
1695 }
1696 i.mu.Unlock()
1697 i.invalidate()
1698}
1699
1700func (i *Interactive) applyModelSelection(prov, model string) {
1701 if model == "" {
1702 return
1703 }
1704 m, err := provider.FindModel(prov, model)
1705 if err != nil {
1706 i.mu.Lock()
1707 i.statusErr = err.Error()
1708 i.mu.Unlock()
1709 return
1710 }
1711 // Same provider? Just swap the model on the existing agent.
1712 if i.agent != nil && m.Provider == i.cfg.Provider {
1713 i.mu.Lock()
1714 i.cfg.Model = m.ID
1715 i.agent.Model = m.ID
1716 i.statusOK = "model: " + m.ID
1717 i.statusErr = ""
1718 i.mu.Unlock()
1719 if i.cfg.PersistModel != nil {
1720 i.cfg.PersistModel(i.cfg.Provider, m.ID)
1721 }
1722 return
1723 }
1724 // Different provider: rebuild agent (needs credentials for target provider).
1725 if i.cfg.BuildAgentFor == nil {
1726 i.mu.Lock()
1727 i.statusErr = "cannot switch provider: no builder configured"
1728 i.mu.Unlock()
1729 return
1730 }
1731 // Snapshot the current transcript and cumulative usage BEFORE we
1732 // build the replacement agent so we can hand them off. Without
1733 // this the user perceives the entire session as wiped on a
1734 // cross-provider /model swap.
1735 var carryMsgs []provider.Message
1736 var carryCost provider.Usage
1737 if i.agent != nil {
1738 carryMsgs = i.agent.Messages()
1739 carryCost = i.agent.Cost()
1740 }
1741
1742 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
1743 if err != nil {
1744 i.mu.Lock()
1745 i.statusErr = err.Error()
1746 i.mu.Unlock()
1747 return
1748 }
1749
1750 // Replay the transcript and seed the cost on the freshly-built
1751 // agent. Messages travel cleanly between providers because they
1752 // use the same provider.Message shape; tool-call ids are local
1753 // to a turn so cross-provider continuation never confuses the
1754 // new model (it just sees the assistant's reply, no orphan
1755 // tool_use blocks because /model swaps are gated to idle state).
1756 if len(carryMsgs) > 0 {
1757 ag.SetMessages(carryMsgs)
1758 }
1759 ag.SeedCost(carryCost)
1760
1761 i.mu.Lock()
1762 i.agent = ag
1763 i.cfg.Provider = p
1764 i.cfg.Model = md
1765 i.statusOK = "switched to " + p + " / " + md
1766 i.statusErr = ""
1767 // Render cache keys are width+content based, so the new agent's
1768 // identical messages will reuse the existing entries. Nothing
1769 // to invalidate.
1770 i.mu.Unlock()
1771 if i.cfg.PersistModel != nil {
1772 i.cfg.PersistModel(p, md)
1773 }
1774}
1775
1776func (i *Interactive) handleAuthEvent(ev auth.Event) {
1777 switch ev.Kind {
1778 case "started":
1779 i.dialog.ShowWaiting(ev.URL)
1780 case "browser_open":
1781 // no-op
1782 case "error":
1783 i.dialog.ShowResult(false, ev.Message)
1784 case "success":
1785 // Rebuild the agent with the fresh credential.
1786 ag, prov, model, err := i.cfg.BuildAgent()
1787 if err != nil {
1788 i.dialog.ShowResult(false, err.Error())
1789 return
1790 }
1791 i.mu.Lock()
1792 i.agent = ag
1793 i.cfg.Provider = prov
1794 i.cfg.Model = model
1795 i.statusErr = ""
1796 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1797 i.mu.Unlock()
1798 i.dialog.ShowResult(true, "")
1799 }
1800}
1801
1802// runCompact invokes core.Agent.Compact and reflects the progress in
1803// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1804// cancel via the same cancelTurn channel used for normal turns.
1805//
1806// When auto is true the spinner message is pinned to "condensing
1807// history" and the status bar surfaces "(auto)" next to the context
1808// percentage so it's obvious the system triggered this, not the user.
1809func (i *Interactive) runCompact(parent context.Context, auto bool) {
1810 if i.agent == nil {
1811 i.mu.Lock()
1812 i.statusErr = "not logged in. type /login first."
1813 i.mu.Unlock()
1814 return
1815 }
1816 ctx, cancel := context.WithCancel(parent)
1817 i.mu.Lock()
1818 i.busy = true
1819 if auto {
1820 i.spin.StartFixed("condensing history")
1821 i.autoCompacting = true
1822 i.statusOK = "condensing history… (esc to cancel)"
1823 } else {
1824 i.spin.Start()
1825 i.statusOK = "compacting..."
1826 }
1827 i.cancelTurn = cancel
1828 i.statusErr = ""
1829 i.streaming.Reset()
1830 i.streamOn = true
1831 i.scrollOffset = 0
1832 i.helpBlock = nil
1833 i.mu.Unlock()
1834 i.invalidate()
1835
1836 go func() {
1837 sink := func(delta string) {
1838 i.mu.Lock()
1839 i.streaming.WriteString(delta)
1840 i.mu.Unlock()
1841 i.invalidate()
1842 }
1843 summary, err := i.agent.Compact(ctx, 4, sink)
1844 i.mu.Lock()
1845 i.busy = false
1846 i.streamOn = false
1847 i.streaming.Reset()
1848 i.cancelTurn = nil
1849 i.autoCompacting = false
1850 switch {
1851 case err != nil && ctx.Err() != nil:
1852 i.statusErr = ""
1853 if auto {
1854 i.statusOK = "auto-condense cancelled"
1855 } else {
1856 i.statusOK = "compaction cancelled"
1857 }
1858 case err != nil:
1859 i.statusErr = "compaction failed: " + err.Error()
1860 i.statusOK = ""
1861 default:
1862 i.statusErr = ""
1863 i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary))
1864 i.lastCtxInput = 0 // reset; next turn will get a fresh measurement
1865 i.toolCalls = map[string]*tui.ToolCallView{}
1866 i.toolOrder = nil
1867 // Transcript was rewritten in place — purge the per-message
1868 // render cache so stale entries keyed on the old messages
1869 // don't linger.
1870 i.view.InvalidateRenderCache()
1871 }
1872 i.mu.Unlock()
1873 i.invalidate()
1874 }()
1875}
1876
1877func (i *Interactive) startTurn(parent context.Context, prompt string) {
1878 if i.agent == nil {
1879 return
1880 }
1881 ctx, cancel := context.WithCancel(parent)
1882 i.mu.Lock()
1883 i.busy = true
1884 i.spin.Start()
1885 i.cancelTurn = cancel
1886 i.statusErr = ""
1887 i.statusOK = ""
1888 i.streaming.Reset()
1889 i.streamOn = true
1890 i.toolCalls = map[string]*tui.ToolCallView{}
1891 i.toolOrder = nil
1892 i.scrollOffset = 0 // jump back to the bottom on new turn
1893 i.parkedTurn = 0 // starting a turn clears the /jump parked state
1894 i.parkedTotal = 0
1895 i.helpBlock = nil // hide the help block once the user asks something
1896 i.mu.Unlock()
1897 i.invalidate()
1898
1899 sink := func(ev core.AgentEvent) {
1900 i.handleEvent(ev)
1901 i.invalidate()
1902 }
1903
1904 go func() {
1905 err := i.agent.Prompt(ctx, prompt, nil, sink)
1906 i.mu.Lock()
1907 i.busy = false
1908 i.streamOn = false
1909 i.cancelTurn = nil
1910 if err != nil && ctx.Err() == nil {
1911 i.statusErr = err.Error()
1912 }
1913 // Pop the next queued message, if any, and relaunch.
1914 var next string
1915 var hasNext bool
1916 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
1917 next, i.queued = i.queued[0], i.queued[1:]
1918 hasNext = true
1919 }
1920 // If the turn was cancelled or errored, drop the queue so the
1921 // user isn't bombarded with stale messages after an interrupt.
1922 if ctx.Err() != nil || err != nil {
1923 i.queued = nil
1924 }
1925 // Decide whether the next thing to do is an auto-compaction.
1926 // Only fires when the turn completed cleanly AND the queue is
1927 // empty (otherwise a queued message would race the condense).
1928 shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
1929 i.mu.Unlock()
1930 i.invalidate()
1931 parent := i.runCtx
1932 if parent == nil {
1933 parent = context.Background()
1934 }
1935 switch {
1936 case hasNext:
1937 i.startTurn(parent, next)
1938 case shouldAutoCompact:
1939 i.runCompact(parent, true)
1940 }
1941 }()
1942}
1943
1944// autoCompactThreshold is the context-window fraction at which the
1945// agent will auto-compact after a turn ends. 0.85 leaves enough
1946// headroom for one more user prompt + response before we bump the
1947// hard limit.
1948const autoCompactThreshold = 0.85
1949
1950// shouldAutoCompactLocked reports whether the last turn pushed context
1951// usage past the auto-compact threshold. Must be called with i.mu
1952// held; it reads lastCtxInput and the current model's context window.
1953func (i *Interactive) shouldAutoCompactLocked() bool {
1954 if i.agent == nil {
1955 return false
1956 }
1957 if i.autoCompacting {
1958 return false
1959 }
1960 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
1961 if err != nil || m.ContextWindow <= 0 {
1962 return false
1963 }
1964 if i.lastCtxInput <= 0 {
1965 return false
1966 }
1967 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
1968}
1969
1970func (i *Interactive) handleEvent(ev core.AgentEvent) {
1971 i.mu.Lock()
1972 defer i.mu.Unlock()
1973 switch e := ev.(type) {
1974 case core.EvTextDelta:
1975 i.streaming.WriteString(e.Delta)
1976 case core.EvAssistantMessage:
1977 i.streaming.Reset()
1978 i.streamOn = false
1979 if i.cfg.OnAssistant != nil {
1980 i.cfg.OnAssistant(e.Message)
1981 }
1982 case core.EvToolUseStart:
1983 // Live streaming: pre-create the view so the user sees the
1984 // tool call being composed in real time. Any subsequent
1985 // EvToolCall for the same ID updates the same struct (the
1986 // final parsed args + name are already known here).
1987 if _, exists := i.toolCalls[e.ID]; !exists {
1988 i.toolCalls[e.ID] = &tui.ToolCallView{
1989 ID: e.ID,
1990 Name: e.Name,
1991 Streaming: true,
1992 }
1993 i.toolOrder = append(i.toolOrder, e.ID)
1994 }
1995 case core.EvToolUseArgs:
1996 if tc, ok := i.toolCalls[e.ID]; ok {
1997 tc.RawJSONBuf += e.Delta
1998 // Refresh the live path as soon as it parses; used in
1999 // the header (▸ write /Users/pat/Desktop/demo.ts)
2000 // while the content is still streaming.
2001 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2002 tc.LivePath = p
2003 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2004 tc.LivePath = p
2005 }
2006 }
2007 case core.EvToolUseEnd:
2008 if tc, ok := i.toolCalls[e.ID]; ok {
2009 tc.Streaming = false
2010 }
2011 case core.EvToolCall:
2012 // If we already pre-created the view during streaming, just
2013 // refresh the final Args summary. Otherwise create a new one
2014 // (non-streaming providers or legacy paths).
2015 if tc, ok := i.toolCalls[e.ID]; ok {
2016 tc.Args = shortArgs(e.Args)
2017 tc.Streaming = false
2018 } else {
2019 i.toolCalls[e.ID] = &tui.ToolCallView{
2020 ID: e.ID,
2021 Name: e.Name,
2022 Args: shortArgs(e.Args),
2023 }
2024 i.toolOrder = append(i.toolOrder, e.ID)
2025 }
2026 case core.EvToolResult:
2027 if tc, ok := i.toolCalls[e.ID]; ok {
2028 tc.Done = true
2029 tc.Error = e.Result.IsError
2030 var text strings.Builder
2031 for _, c := range e.Result.Content {
2032 if tb, ok := c.(provider.TextBlock); ok {
2033 if text.Len() > 0 {
2034 text.WriteString("\n")
2035 }
2036 text.WriteString(tb.Text)
2037 }
2038 }
2039 tc.Result = text.String()
2040 }
2041 if i.cfg.OnToolResult != nil {
2042 i.cfg.OnToolResult(e.ID, e.Result)
2043 }
2044 case core.EvUsage:
2045 i.cumUsage = e.Cumulative
2046 if e.Usage.InputTokens > 0 {
2047 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
2048 }
2049 case core.EvTurnEnd:
2050 if e.Stop == provider.StopAborted {
2051 // Aborted turn: discard the partial streaming text (it is not
2052 // persisted in the transcript) and clear any transient error.
2053 i.streaming.Reset()
2054 i.streamOn = false
2055 i.statusErr = ""
2056 i.statusOK = "cancelled"
2057 return
2058 }
2059 if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
2060 i.statusErr = e.Err.Error()
2061 }
2062 }
2063}
2064
2065// Agent returns the current agent, if any. Used by cli.go to flush the
2066// final transcript to the session file.
2067func (i *Interactive) Agent() *core.Agent {
2068 i.mu.Lock()
2069 defer i.mu.Unlock()
2070 return i.agent
2071}
2072
2073func shortArgs(raw json.RawMessage) string {
2074 var v any
2075 if err := json.Unmarshal(raw, &v); err != nil {
2076 return ""
2077 }
2078 if m, ok := v.(map[string]any); ok {
2079 for _, k := range []string{"path", "file_path", "command"} {
2080 if s, ok := m[k].(string); ok {
2081 if len(s) > 60 {
2082 s = s[:57] + "..."
2083 }
2084 return s
2085 }
2086 }
2087 }
2088 b, _ := json.Marshal(v)
2089 s := string(b)
2090 if len(s) > 60 {
2091 s = s[:57] + "..."
2092 }
2093 return s
2094}
2095
2096// silence unused import in some build configs
2097var _ = fmt.Sprintf
2098
2099// runReloadExt triggers a live reload of every extension (discovered
2100// + explicit). Runs on a goroutine so the TUI stays responsive; the
2101// Manager.Reload takes a couple of hundred ms to shut down subprocs
2102// and respawn them. Shows a status line throughout.
2103func (i *Interactive) runReloadExt(ctx context.Context) {
2104 if i.cfg.Extensions == nil {
2105 i.mu.Lock()
2106 i.statusErr = "no extension manager in this build"
2107 i.mu.Unlock()
2108 i.invalidate()
2109 return
2110 }
2111 i.mu.Lock()
2112 i.statusOK = "reloading extensions…"
2113 i.statusErr = ""
2114 i.mu.Unlock()
2115 i.invalidate()
2116
2117 go func() {
2118 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
2119 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
2120 if len(stats.Errors) > 0 {
2121 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
2122 }
2123 i.mu.Lock()
2124 i.statusOK = msg
2125 i.statusErr = ""
2126 i.mu.Unlock()
2127 i.invalidate()
2128 }()
2129}
2130
2131// Confirm implements core.Confirmer. The agent goroutine calls
2132// this synchronously before every tool invocation when --no-yolo is
2133// active. We push the request onto the confirmDialog queue, trigger
2134// a redraw, and block the caller until the user answers.
2135//
2136// If the session is cancelled or the TUI exits mid-prompt, any
2137// pending request is refused via CancelAll so the agent doesn't
2138// deadlock.
2139func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2140 resp := make(chan core.ConfirmDecision, 1)
2141 i.confirmDialog.Enqueue(&confirmRequest{
2142 toolName: toolName,
2143 preview: preview,
2144 resp: resp,
2145 })
2146 i.invalidate()
2147 return <-resp
2148}
2149
2150// runYoloOn disables --no-yolo for the rest of the session. Tool
2151// calls run without prompting after this; there's intentionally no
2152// way to re-enable gating mid-session, if the user wants that back
2153// they can exit and restart with --no-yolo.
2154func (i *Interactive) runYoloOn() {
2155 if i.cfg.ConfirmGate == nil {
2156 i.mu.Lock()
2157 i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2158 i.statusErr = ""
2159 i.mu.Unlock()
2160 i.invalidate()
2161 return
2162 }
2163 i.cfg.ConfirmGate.AllowAll()
2164 // Also auto-allow any currently pending confirmation so the
2165 // agent doesn't deadlock if /yolo is typed while a prompt is
2166 // open.
2167 i.confirmDialog.AllowAllPending()
2168 i.mu.Lock()
2169 i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2170 i.statusErr = ""
2171 i.mu.Unlock()
2172 i.invalidate()
2173}