1package modes
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/patriceckhart/zot/internal/agent/extensions"
13 "github.com/patriceckhart/zot/internal/agent/modes/telegram"
14 "github.com/patriceckhart/zot/internal/agent/tools"
15 "github.com/patriceckhart/zot/internal/auth"
16 "github.com/patriceckhart/zot/internal/core"
17 "github.com/patriceckhart/zot/internal/extproto"
18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"
20 "github.com/patriceckhart/zot/internal/swarm"
21 "github.com/patriceckhart/zot/internal/tui"
22)
23
24// InteractiveConfig configures the interactive loop.
25type InteractiveConfig struct {
26 Terminal tui.Terminal
27 Theme tui.Theme
28 Model string
29 Provider string
30 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
31 BaseURL string
32 Reasoning string
33 SystemPrompt string
34 Tools core.Registry
35 MaxSteps int
36 CWD string
37
38 // InlineImagesEnabled overrides terminal image rendering. nil means
39 // auto-detect and render when supported; false disables; true uses
40 // the detected protocol when available.
41 InlineImagesEnabled *bool
42
43 // AutoSwarmEnabled mirrors the persisted config flag at startup so
44 // the /settings dialog can render the current state without
45 // re-reading config.json on every open.
46 AutoSwarmEnabled *bool
47
48 // AutoSwarmSystemAddendum is the system-prompt block that gets
49 // appended/stripped when the user toggles auto-swarm at runtime.
50 // Plumbed in from the cli so this package doesn't have to import
51 // agent (cycle).
52 AutoSwarmSystemAddendum string
53 SettingsStore SettingsStore
54
55 // Agent is optional. If nil, zot opens without credentials; the
56 // user must /login before they can prompt.
57 Agent *core.Agent
58
59 InitialInput string
60
61 // Auth is required. When the user runs /login, Interactive talks to
62 // AuthManager to open a browser and wait for the callback.
63 AuthManager *auth.Manager
64 // BuildAgent is called after a successful login to (re)construct the
65 // agent with the fresh credential. It returns the new agent and
66 // the concrete provider/model in use.
67 BuildAgent func() (*core.Agent, string, string, error)
68
69 // SetKimiCLIFallbackDisabled controls whether zot may fall back to
70 // the official Kimi Code CLI token when zot has no stored Kimi token.
71 SetKimiCLIFallbackDisabled func(disabled bool) error
72
73 // BuildAgentFor rebuilds the agent with an explicit provider/model
74 // override (used by the /model picker when switching providers).
75 // If providerOverride is empty, the current provider is kept.
76 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
77
78 // BuildAgentForRescue rebuilds the agent for the rescue picker that
79 // opens after a recoverable provider failure. Unlike BuildAgentFor,
80 // this builder drops launch-time --api-key and --base-url overrides
81 // because those are usually the reason rescue triggered. Re-resolves
82 // credentials from env vars / auth.json / provider defaults so the
83 // retry has a real chance of succeeding. Falls back to BuildAgentFor
84 // when nil so embedders that don't wire it keep working.
85 BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
86
87 // LoggedInProviders returns the list of provider names that
88 // currently have credentials. Used by /model to filter the
89 // picker to only show reachable models.
90 LoggedInProviders func() []string
91
92 // ZotHome is the root directory for sessions/, used by /sessions
93 // and the update-check cache.
94 ZotHome string
95
96 // Version is the binary's current version (from main.version).
97 // Used only for display; the update check itself is done outside
98 // this package to avoid an import cycle.
99 Version string
100
101 // UpdateInfoChan is an optional channel that delivers the result
102 // of the github-release update check. Interactive reads at most
103 // one value, drops it if the check reported nothing, and otherwise
104 // surfaces a yellow "update available" banner at the top of the
105 // chat. Nil channel = no banner, no startup cost.
106 UpdateInfoChan <-chan UpdateInfo
107
108 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
109 Sandbox *tools.Sandbox
110
111 // LoadSession swaps the current session for the one at path. The
112 // callback returns the new agent message slice so the TUI can invalidate.
113 LoadSession func(path string) error
114
115 // ChangeCWD switches the running zot session's working directory
116 // to path. The host closes the current session, rebuilds the
117 // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
118 // opens a fresh session there. Returns an error if path doesn't
119 // exist, isn't a directory, or the host can't rebuild the agent.
120 //
121 // Optional: not wired by every embedder. When nil the hidden /cd
122 // command surfaces a clear error rather than no-oping.
123 ChangeCWD func(path string) error
124
125 // CurrentSessionPath returns the path of the live session file
126 // on disk (the one every AppendMessage writes to). Used by
127 // /session export so the exporter ships the exact bytes on
128 // disk. Returns an empty string when --no-session is set or
129 // no session is open.
130 CurrentSessionPath func() string
131
132 // FlushSession writes any in-memory agent messages to the
133 // session file that haven't been persisted yet. Called by
134 // /session export right before reading the file so the
135 // exported bytes reflect the full current conversation, not
136 // just the rows the agent happened to write synchronously.
137 // The default WriteNewTranscript-at-exit strategy means most
138 // of a running session lives only in memory until the tui
139 // closes; without a flush hook, /session export writes a
140 // file that only has the meta row.
141 FlushSession func()
142
143 // PersistModel is called whenever the user switches model or provider.
144 // It should update config.json and (if there's an active session)
145 // write a new meta row so resume picks up the same model.
146 PersistModel func(providerName, model string)
147
148 OnAssistant func(m provider.Message)
149 OnToolResult func(id string, r core.ToolResult)
150
151 // Extensions, if non-nil, lets users invoke extension-registered
152 // slash commands. Commands declared by extensions are looked up
153 // AFTER the built-in catalog so a built-in name always wins.
154 Extensions *extensions.Manager
155
156 // Swarm, if non-nil, enables the /swarm slash command and the
157 // dashboard dialog. The cli constructs the Swarm once per
158 // interactive run and tears it down on exit. nil disables the
159 // feature entirely (used by embedders / tests that don't want
160 // subprocesses).
161 Swarm *swarm.Swarm
162
163 // SkillSnapshot, if non-nil, returns the current list of
164 // discovered SKILL.md files. Re-invoked each time /skills opens
165 // so the picker reflects edits made during the session.
166 SkillSnapshot func() []*skills.Skill
167
168 // ChangelogChan, if non-nil, delivers release-notes for the
169 // current binary version once at startup. Interactive opens a
170 // dismissible overlay when the channel produces a non-empty
171 // body. Receiver fires at most once.
172 ChangelogChan <-chan ChangelogPayload
173
174 // OnChangelogDismiss, if non-nil, is called once the user
175 // closes the changelog overlay. The cli wires this to a
176 // MarkChangelogShown call so the same version doesn't show
177 // again on the next launch.
178 OnChangelogDismiss func()
179
180 // NoYolo is true when --no-yolo was passed. Interactive opens
181 // a confirmation dialog before every tool call and blocks the
182 // tool until the user picks yes / always-this-tool /
183 // always-all / no. When false (default), tools run freely.
184 NoYolo bool
185
186 // ConfirmGate is the session-scoped gate wrapping this
187 // interactive's Confirmer. When non-nil, /yolo can call
188 // AllowAll() on it to disable confirmation for the rest of the
189 // session. When nil (yolo mode), /yolo reports that there's
190 // nothing to disable.
191 ConfirmGate *core.ConfirmGate
192}
193
194// ChangelogPayload mirrors agent.ChangelogInfo without the import
195// cycle. The cli builds one from the http response, the tui opens
196// the overlay when one arrives.
197type ChangelogPayload struct {
198 Version string
199 Body string
200 URL string
201}
202
203// Interactive is the TUI chat loop.
204type chatCacheKey struct {
205 cols int
206 agentRev uint64
207 statusOK string
208 statusErr string
209 help string
210 extNotes string
211 updateAvailable bool
212 updateCurrent string
213 updateLatest string
214 updateURL string
215 welcomeShowVer bool
216 expandAll bool
217 tailLimit int
218}
219
220// SettingsStore persists user-toggleable settings surfaced by /settings.
221type SettingsStore interface {
222 SetInlineImages(enabled bool) error
223 SetAutoSwarm(enabled bool) error
224 SetReasoning(level string) error
225}
226
227type Interactive struct {
228 cfg InteractiveConfig
229 view *tui.View
230 ed *tui.Editor
231 rend *tui.Renderer
232
233 mu sync.Mutex
234 agent *core.Agent
235 streaming strings.Builder // what's currently painted on screen
236 streamOn bool
237
238 // streamPending is the runes buffered after each EvTextDelta that
239 // haven't yet been promoted into `streaming` for rendering. It
240 // exists because some provider paths (notably Anthropic via the
241 // oauth/subscription channel) coalesce the model's output into a
242 // few fat chunks instead of drip-streaming. Painting those fat
243 // chunks verbatim looks like the summary "just appears". The
244 // paintPace goroutine drains a handful of runes per tick from
245 // this buffer into `streaming`, giving every path the same
246 // typewriter feel regardless of upstream chunk size.
247 streamPending []rune
248 // streamFlushPending is set when EvAssistantMessage fires while
249 // streamPending still has unrendered runes. The ticker flushes
250 // them, then closes out the stream (clearing flags, resetting
251 // buffers) so the final paint matches the on-disk message.
252 streamFlushPending bool
253 toolCalls map[string]*tui.ToolCallView
254 toolOrder []string
255 statusErr string
256 statusOK string
257 liveBlock []string // live streaming/tool progress rendered outside scrollback
258 helpBlock []string // rendered above the chat when /help was typed
259 cumUsage provider.Usage
260 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
261 busy bool
262 dirty chan struct{}
263 cancelTurn context.CancelFunc
264 scrollOffset int // rows from the bottom; 0 = pinned to latest
265 prevScrollOffset int // last value redraw snapped against; tracks intent
266
267 // prevChatLen and prevChatCols track the chat buffer's size at the
268 // last redraw so that when content grows below the user's viewport
269 // while they're scrolled up reading history, we can bump
270 // scrollOffset by exactly the growth and keep the visible content
271 // pinned. Without this, every streamed line shifts the visible
272 // window down through the buffer (because scrollOffset is measured
273 // from the bottom) and the user's reading position drifts upward
274 // and off the top.
275 prevChatLen int
276 prevChatCols int
277 prevOverlayOpen bool
278
279 // chatCache stores the built transcript/status-note rows for idle
280 // frames. Editor typing changes only the bottom input region, so
281 // reusing this cache avoids copying/walking/reassembling a long
282 // session on every keypress.
283 chatCache []string
284 chatCacheKey chatCacheKey
285 chatCacheValid bool
286
287 // Messages typed while a turn is in flight. Each is delivered as
288 // its own follow-up turn once the current one finishes. Rendered
289 // above the status bar as "sliding in: ..." chips.
290 queued []string
291
292 // runCtx is the top-level context passed to Run(). Follow-up turns
293 // drained from `queued` are started against this context so they
294 // survive past the ctx of the key event that enqueued them.
295 runCtx context.Context
296
297 // pendingPostCompactNote is a status_ok message to surface after
298 // a successful auto-compact pass triggered by a 413 or by the
299 // pre-turn fraction guard. Cleared by runCompact once shown.
300 pendingPostCompactNote string
301
302 // autoCompacting is true while a model-triggered compaction is in
303 // flight. Surfaced in the status bar so the user can tell a
304 // condense pass from a regular assistant turn.
305 autoCompacting bool
306
307 // updateInfo is the result of the async update check. Zero value
308 // while the check hasn't completed or nothing is available.
309 updateInfo UpdateInfo
310
311 dialog *loginDialog
312 modelDialog *modelDialog
313 rescueDialog *rescueDialog
314 sessionDialog *sessionDialog
315 swarmDialog *swarmDialog
316 jumpDialog *jumpDialog
317 btwDialog *btwDialog
318 skillsDialog *skillsDialog
319 changelogDialog *changelogDialog
320 confirmDialog *confirmDialog
321 logoutDialog *logoutDialog
322 telegramDialog *telegramDialog
323 settingsDialog *settingsDialog
324 telegramBridge *telegram.Bridge
325 sessionOpsDialog *sessionOpsDialog
326 sessionTreeDialog *sessionTreeDialog
327 extPanel *extPanelDialog
328
329 // swarmWatch tracks auto-swarm sub-agents the main agent spawned
330 // via swarm_spawn. Each entry holds the agent + the task text;
331 // a per-entry goroutine waits on the agent's terminal state. When
332 // every tracked entry has finished, the watcher flushes a single
333 // summary turn into the main chat (queued if the main agent is
334 // busy, run immediately if idle).
335 swarmWatchMu sync.Mutex
336 swarmWatch []*swarmWatchEntry
337
338 // pendingFork is true when the user ran /session fork: the next
339 // jump-picker selection should branch off that message instead
340 // of scrolling. Flag resets after the action fires or the dialog
341 // is dismissed, so repeated /jump calls don't turn into forks.
342 pendingFork bool
343 suggest *slashSuggester
344 fileSuggest *fileSuggester
345 spin *spinner
346
347 // parkedTurn is the 1-based turn number the viewport is currently
348 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
349 // Rendered as a muted footer at the bottom of the chat so users
350 // don't forget they're looking at history.
351 parkedTurn int
352 parkedTotal int
353
354 // inputHistoryIndex is -1 when not browsing history. When the
355 // editor is empty, Left/Right can walk previous user prompts for
356 // quick manual testing without stealing normal cursor movement in
357 // non-empty input.
358 inputHistoryIndex int
359
360 // lastCtrlC is when the user last pressed ctrl+c. The first press
361 // clears the editor / cancels a turn / shows a hint; a second press
362 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
363 lastCtrlC time.Time
364
365 // welcomeStart is when the interactive run began. The welcome
366 // banner shows the binary version for welcomeVersionDuration
367 // after this point and reverts to plain text after.
368 welcomeStart time.Time
369
370 // extNotes are one-shot styled lines pushed by extensions via
371 // Notify / Display. They live above the editor (just below the
372 // transcript) until cleared by /clear or another reset.
373 extNotes []string
374
375 // sessionLoading is true while a /sessions selection is being read
376 // on a background goroutine. Keeping this off the input goroutine
377 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
378 sessionLoading bool
379
380 // pendingRescuePrompt / pendingRescueImages stash the prompt and
381 // images that should be re-run after the user picks a model in
382 // the rescue dialog. Cleared once applyRescueSelection consumes
383 // them (or when the dialog is dismissed via esc).
384 pendingRescuePrompt string
385 pendingRescueImages []provider.ImageBlock
386}
387
388// welcomeVersionDuration is how long the welcome banner shows the
389// version suffix before reverting to the plain headline. 1.5s is
390// enough to read at a glance and keeps the splash short.
391const welcomeVersionDuration = 1500 * time.Millisecond
392
393// initialResumeTailLimit caps how many messages from a freshly-resumed
394// transcript we render on the first paint. The full transcript is
395// still in memory; older messages are rendered (and their cached
396// lines kept for the lifetime of the View) as soon as the user
397// scrolls past the rendered tail. Picked to comfortably fill the
398// largest realistic terminal viewport while keeping first paint
399// snappy on multi-thousand-message sessions where markdown / syntax
400// highlighting dominates the redraw cost.
401const initialResumeTailLimit = 80
402
403// resumeTailExpandStep is how many additional messages the tail
404// limit grows by each time the user scrolls past the currently
405// rendered top. Pre-rendering this many messages on a single tick
406// keeps scroll-up smooth without falling back to a one-by-one
407// reveal that would feel jerky.
408const resumeTailExpandStep = 80
409
410// NewInteractive constructs an Interactive from cfg.
411func NewInteractive(cfg InteractiveConfig) *Interactive {
412 i := &Interactive{
413 cfg: cfg,
414 view: &tui.View{
415 Theme: cfg.Theme,
416 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
417 },
418 // Prompt is the standard half-block accent bar used by chat
419 // speaker labels too, so the input gutter matches the rest
420 // of the UI.
421 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
422 rend: tui.NewRenderer(cfg.Terminal),
423 toolCalls: map[string]*tui.ToolCallView{},
424 dirty: make(chan struct{}, 8),
425 dialog: newLoginDialog(),
426 modelDialog: newModelDialog(),
427 rescueDialog: newRescueDialog(),
428 sessionDialog: newSessionDialog(),
429 swarmDialog: newSwarmDialog(),
430 jumpDialog: newJumpDialog(),
431 btwDialog: newBtwDialog(),
432 skillsDialog: newSkillsDialog(),
433 changelogDialog: newChangelogDialog(),
434 confirmDialog: newConfirmDialog(),
435 logoutDialog: newLogoutDialog(),
436 telegramDialog: newTelegramDialog(),
437 settingsDialog: newSettingsDialog(),
438 sessionOpsDialog: newSessionOpsDialog(),
439 sessionTreeDialog: newSessionTreeDialog(),
440 extPanel: newExtPanelDialog(),
441 suggest: newSlashSuggester(),
442 fileSuggest: newFileSuggester(),
443 spin: newSpinner(),
444 inputHistoryIndex: -1,
445 }
446 if cfg.Agent != nil {
447 i.agent = cfg.Agent
448 i.view.Messages = cfg.Agent.Messages()
449 i.cumUsage = cfg.Agent.Cost()
450 // Rehydrate the "context used" gauge from the last persisted
451 // turn. Without this the status bar reads 0.0% after a resume
452 // until the next turn lands a usage event.
453 if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
454 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
455 }
456 // Cap the first paint at the tail of the transcript so
457 // resuming a multi-thousand-message session doesn't block
458 // on rendering every prior turn before showing anything.
459 if len(i.view.Messages) > initialResumeTailLimit {
460 i.view.TailLimit = initialResumeTailLimit
461 }
462 }
463 return i
464}
465
466// Run blocks until the user quits.
467func (i *Interactive) Run(ctx context.Context) error {
468 i.runCtx = ctx
469 term := i.cfg.Terminal
470 restore, err := term.EnterRaw()
471 if err != nil {
472 return err
473 }
474 defer restore()
475 defer func() {
476 if i.telegramBridge != nil {
477 i.telegramBridge.Stop()
478 }
479 }()
480
481 // Enabling mouse reporting steals click-drag selection from the
482 // host terminal (VS Code, Ghostty, iTerm). The user prefers native
483 // selection over the wheel-speed boost, so we no longer turn it
484 // on automatically. Wheel events fall through to the terminal's
485 // own scrollback handler.
486 // Keep zot on the terminal's main screen. We intentionally do not
487 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
488 // chat as normal terminal flow/scrollback and redraws only the live
489 // input/status block on normal typing.
490 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
491 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
492
493 // Streaming pacer: drains buffered text deltas at a steady rate
494 // so typewriter feel is identical across providers regardless of
495 // upstream chunk size. Starts here so it lives for the whole
496 // session and exits with ctx.
497 go i.runStreamPacer(ctx)
498
499 cols, rows := term.Size()
500 i.rend.Resize(cols, rows)
501 term.OnResize(func() {
502 c, r := term.Size()
503 i.rend.Resize(c, r)
504 // Force an immediate redraw on resize. The throttled invalidate
505 // path is fine for animation, but a window resize is a discrete
506 // user action where any visible delay (or stale frame) reads as
507 // brokenness. redraw() is mutex-safe; the worst that happens is
508 // a duplicate paint if the throttler is mid-flight, which is
509 // invisible.
510 i.redraw()
511 })
512
513 if i.cfg.InitialInput != "" {
514 i.ed.SetValue(i.cfg.InitialInput)
515 }
516
517 // Stamp the welcome time and schedule a one-shot redraw at the
518 // expiry so the version suffix disappears on its own even if the
519 // user hasn't typed anything yet.
520 i.welcomeStart = time.Now()
521 time.AfterFunc(welcomeVersionDuration, i.invalidate)
522
523 // If the agent was constructed with a pre-loaded transcript
524 // (--continue, --resume, --session) pin the viewport at the
525 // bottom so the most recent reply (and any prompt the user just
526 // typed) is fully visible. Earlier behaviour parked the view at
527 // the last user turn, which could leave the latest message clipped
528 // off the bottom of the page on long sessions.
529 if i.agent != nil {
530 if msgs := i.agent.Messages(); len(msgs) > 0 {
531 i.scrollToBottom()
532 }
533 }
534
535 // No credential at startup? Auto-open the login dialog, and mark
536 // the status line. The user can Esc out of the dialog if they
537 // want to dismiss it (e.g. to check /help or /exit first).
538 if i.agent == nil {
539 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
540 i.dialog.Open(i.cfg.ZotHome)
541 }
542
543 // Input goroutine. Buffered generously so a drag-drop that the
544 // terminal delivers as a burst of single-character key events
545 // (no bracketed paste) can be drained in one main-loop pass
546 // instead of triggering a redraw per character.
547 keys := make(chan tui.Key, 256)
548 go func() {
549 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
550 for {
551 k, err := reader.Read()
552 if err != nil {
553 return
554 }
555 keys <- k
556 }
557 }()
558
559 // Subscribe to auth events.
560 var authEvents <-chan auth.Event
561 if i.cfg.AuthManager != nil {
562 authEvents = i.cfg.AuthManager.Events()
563 }
564
565 // Animation ticker: drives spinner and dialog-related redraws when
566 // nothing else changed. 120ms is slow enough that highlighting a huge
567 // transcript doesn't spin the cpu.
568 tick := time.NewTicker(120 * time.Millisecond)
569 defer tick.Stop()
570
571 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
572 // at most once every redrawMinInterval. Huge tool-result dumps can
573 // fire hundreds of invalidations while the user is typing; without
574 // this, the input goroutine never gets CPU and keystrokes lag.
575 const redrawMinInterval = 16 * time.Millisecond
576 var lastRedraw time.Time
577 var pendingRedraw bool
578 var pendingTimer *time.Timer
579
580 drainPending := func() {
581 if pendingTimer != nil {
582 pendingTimer.Stop()
583 pendingTimer = nil
584 }
585 if pendingRedraw {
586 pendingRedraw = false
587 lastRedraw = time.Now()
588 i.redraw()
589 }
590 }
591
592 requestRedraw := func() {
593 since := time.Since(lastRedraw)
594 if since >= redrawMinInterval {
595 // Redrawing right now subsumes any pending redraw, so clear
596 // the throttle state. Without this, a pending flag stays
597 // stuck at true and subsequent invalidate() calls within
598 // redrawMinInterval get dropped — which is exactly how the
599 // final "turn finished" frame went missing until the user
600 // nudged the ui by typing or scrolling.
601 if pendingTimer != nil {
602 pendingTimer.Stop()
603 }
604 pendingRedraw = false
605 lastRedraw = time.Now()
606 i.redraw()
607 return
608 }
609 if pendingRedraw {
610 return // already scheduled
611 }
612 pendingRedraw = true
613 wait := redrawMinInterval - since
614 if pendingTimer == nil {
615 pendingTimer = time.AfterFunc(wait, func() {
616 // Poke the dirty channel so the main loop wakes and
617 // drains the pending redraw on its own goroutine. We
618 // can't call drainPending here directly — it touches
619 // closure state shared with the main loop.
620 i.invalidate()
621 })
622 } else {
623 pendingTimer.Reset(wait)
624 }
625 }
626
627 i.invalidate()
628
629 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
630 changelog := i.cfg.ChangelogChan // single-shot, see case below
631
632 for {
633 select {
634 case <-ctx.Done():
635 return ctx.Err()
636 case k := <-keys:
637 if done := i.handleKey(ctx, k); done {
638 return nil
639 }
640 // Drain any keystrokes that arrived during this iteration.
641 // VS Code (and other terminals that don't bracket drops as
642 // paste) deliver a path one rune at a time — without this
643 // loop the editor would render between every rune and a
644 // long path on a heavy transcript would visibly type in.
645 drain:
646 for {
647 select {
648 case k2 := <-keys:
649 if done := i.handleKey(ctx, k2); done {
650 return nil
651 }
652 default:
653 break drain
654 }
655 }
656 i.invalidate()
657 case ev := <-authEvents:
658 i.handleAuthEvent(ev)
659 i.invalidate()
660 case info, ok := <-updates:
661 if ok && info.Available {
662 i.mu.Lock()
663 i.updateInfo = info
664 i.mu.Unlock()
665 i.invalidate()
666 }
667 updates = nil // single-shot; subsequent iterations skip this case
668 case cl, ok := <-changelog:
669 if ok && cl.Body != "" {
670 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
671 i.invalidate()
672 }
673 changelog = nil // single-shot
674 case <-i.dirty:
675 requestRedraw()
676 case <-tick.C:
677 // Always drain a pending redraw on the tick. This is the
678 // safety net that catches the case where the dirty channel
679 // was saturated when the final "turn finished" invalidate
680 // fired, or where the throttle scheduled a deferred redraw
681 // and the AfterFunc-driven invalidate got dropped on a
682 // full channel.
683 drainPending()
684 // Only force a periodic redraw when something is actually
685 // animating (the main spinner during a busy turn, or the
686 // btw side-chat spinner while it's awaiting a response).
687 // Static pickers (model, session, jump, etc.) don't need
688 // the tick and firing it cancels the terminal's cursor
689 // blink inside dialogs that host their own editor (btw),
690 // because each frame re-emits hide-cursor + show-cursor.
691 //
692 // The swarm dashboard is also animated: its rows reflect
693 // background subprocesses whose activity / age change
694 // without any user input. Without the tick redraw the
695 // dashboard freezes on the snapshot taken when the user
696 // last pressed a key. We exclude the dashboard when one
697 // of its inline editors (spawn task or prompt composer)
698 // is active so the cursor blink in those editors works
699 // the same way it does inside btw.
700 if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
701 requestRedraw()
702 }
703 }
704 }
705}
706
707func (i *Interactive) invalidate() {
708 select {
709 case i.dirty <- struct{}{}:
710 default:
711 }
712}
713
714func (i *Interactive) cachedChatLocked(cols int) []string {
715 key, cacheable := i.chatCacheKeyLocked(cols)
716 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
717 return append([]string(nil), i.chatCache...)
718 }
719 chat := i.buildChatLocked(cols)
720 if cacheable {
721 i.chatCache = append(i.chatCache[:0], chat...)
722 i.chatCacheKey = key
723 i.chatCacheValid = true
724 } else {
725 i.chatCacheValid = false
726 }
727 return chat
728}
729
730func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
731 // Live turns mutate streaming/tool-call state at high frequency;
732 // keep those on the old rebuild path. The cache targets the common
733 // idle case where only the editor contents changed between redraws.
734 if i.busy || i.streamOn || i.streamFlushPending {
735 return chatCacheKey{}, false
736 }
737 var rev uint64
738 if i.agent != nil {
739 rev = i.agent.Revision()
740 }
741 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
742 return chatCacheKey{
743 cols: cols,
744 agentRev: rev,
745 statusOK: i.statusOK,
746 statusErr: i.statusErr,
747 help: strings.Join(i.helpBlock, "\n"),
748 extNotes: strings.Join(i.extNotes, "\n"),
749 updateAvailable: i.updateInfo.Available,
750 updateCurrent: i.updateInfo.Current,
751 updateLatest: i.updateInfo.Latest,
752 updateURL: i.updateInfo.URL,
753 welcomeShowVer: showVer,
754 expandAll: i.view.ExpandAll,
755 tailLimit: i.view.TailLimit,
756 }, true
757}
758
759func (i *Interactive) buildChatLocked(cols int) []string {
760 if i.agent != nil {
761 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
762 } else {
763 i.view.Messages = nil
764 }
765 // Pacer flush: while the streaming pacer is still draining the
766 // buffer (i.e. EvAssistantMessage already fired but more runes
767 // are queued), the final assistant message is already in
768 // i.agent.Messages() in full. Painting it in the transcript
769 // AND the streaming block at the same time shows the user the
770 // complete text immediately — which defeats the whole pacer.
771 // Hide the last message until the pacer catches up; once the
772 // flush-pending latch clears, the message is revealed (the
773 // streaming block disappears the same frame because streamOn
774 // flips off, so the transition is seamless).
775 if i.streamFlushPending && len(i.view.Messages) > 0 {
776 i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
777 }
778 i.view.Streaming = i.streaming.String()
779 i.view.StreamingActive = i.streamOn
780 // Guard against the narrow race where EvAssistantMessage has
781 // just promoted a streaming reply into the transcript but a
782 // render tick hasn't flipped streamOn off yet. Without the
783 // guard, the same text would appear twice (once as the
784 // in-flight streaming block, once as the last transcript
785 // message). We detect the duplicate strictly: the last
786 // assistant message's visible text must equal the streaming
787 // buffer. Just matching on role is too broad — it also hides
788 // the next round's typewriter streaming after a tool turn,
789 // because the last transcript message is always assistant
790 // (the tool-use block) until the follow-up summary lands.
791 if i.streamOn && i.streaming.Len() > 0 {
792 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
793 if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
794 i.view.StreamingActive = false
795 }
796 }
797 }
798 // Live tool-call view: only shown while a turn is in flight. Once
799 // the agent is idle, every tool call has already been folded into
800 // the transcript (as assistant.ToolCallBlock + a tool-role message),
801 // so showing v.ToolCalls a second time would duplicate them below
802 // the final assistant text — which looks like the summary came
803 // "before" the tools.
804 i.view.ToolCalls = i.view.ToolCalls[:0]
805 if i.busy {
806 for _, id := range i.toolOrder {
807 if tc, ok := i.toolCalls[id]; ok {
808 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
809 }
810 }
811 }
812 i.view.Err = i.statusErr
813 // Live streaming/tool rows are appended to the chat buffer (not
814 // hoisted into a separate live block above the editor). That keeps
815 // the renderer's diff view append-only: when a tool finishes the
816 // rows update in place at the end of the buffer, instead of the
817 // whole bottom band shrinking and shifting chat lines around.
818 i.liveBlock = nil
819 chat := i.view.Build(cols)
820
821 // Welcome banner: shown at the top of the chat area when there is
822 // no transcript yet. Disappears after the first message is sent.
823 // The version suffix is shown for welcomeVersionDuration after
824 // startup, then drops off automatically.
825 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
826 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
827 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
828 }
829
830 // Update-available banner: prepended above everything else so it's
831 // the first thing the user sees when opening a new zot session.
832 // Once rendered, it stays until the user updates to a newer
833 // version — we don't persist a "dismissed" flag because this is
834 // cheap and re-showing it is how most users remember to update.
835 if i.updateInfo.Available {
836 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
837 chat = append(banner, chat...)
838 }
839
840 // /help block: appended to the transcript so it appears at the
841 // bottom of the chat area (right above the status bar / editor).
842 // Prepending it would push long conversations off the top of the
843 // viewport, which users would miss entirely.
844 if len(i.helpBlock) > 0 {
845 chat = append(chat, i.helpBlock...)
846 }
847
848 if i.statusOK != "" {
849 // Hard-truncate the OK line to the visible width so a long
850 // session path ("resumed session: /Users/.../sessions/...")
851 // doesn't overflow past the right edge and look broken on a
852 // narrow terminal.
853 line := "✓ " + i.statusOK
854 if cols > 4 && len(line) > cols {
855 line = line[:cols-3] + "..."
856 }
857 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
858 }
859
860 // Extension notes (notify / display) live just under the
861 // transcript, above the dialog/editor band. Cleared by /clear.
862 if len(i.extNotes) > 0 {
863 chat = append(chat, i.extNotes...)
864 chat = append(chat, "")
865 }
866
867 // Strip trailing blank rows so the chat content sits flush
868 // against the new "blank above status bar" row added by the
869 // bottom-region assembly. Build() ends every message with a
870 // blank separator; without this trim, the final message in
871 // the transcript would have its own trailing blank plus the
872 // status block's leading blank, doubling the gap.
873 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
874 chat = chat[:len(chat)-1]
875 }
876 return chat
877}
878
879// lastCols returns the current terminal width in columns.
880func (i *Interactive) lastCols() int {
881 cols, _ := i.cfg.Terminal.Size()
882 return cols
883}
884
885// chatPage returns the number of chat rows currently visible, used
886// as the page size for PageUp/PageDown.
887func (i *Interactive) chatPage() int {
888 _, rows := i.cfg.Terminal.Size()
889 p := rows - 6 // rough reservation for status + editor + a dialog line
890 if p < 4 {
891 p = 4
892 }
893 return p
894}
895
896// scrollBy adjusts the scroll offset. Positive = up (into history).
897// Clearing the parked-turn label when we're back at the bottom means
898// the "viewing turn N" footer goes away automatically as soon as you
899// scroll back to the live tail.
900func (i *Interactive) scrollBy(delta int) {
901 i.mu.Lock()
902 i.scrollOffset += delta
903 if i.scrollOffset < 0 {
904 i.scrollOffset = 0
905 }
906 if i.scrollOffset == 0 {
907 i.parkedTurn = 0
908 i.parkedTotal = 0
909 }
910 if i.rend != nil {
911 // VS Code's terminal is especially prone to leaving stray
912 // wrapped-character fragments behind during scroll-driven
913 // viewport changes. Force a full repaint on scroll, but
914 // avoid a whole-screen clear because that visibly flickers.
915 i.rend.Invalidate()
916 }
917 i.mu.Unlock()
918 i.invalidate()
919}
920
921// scrollToBottom pins the view to the latest content.
922func (i *Interactive) scrollToBottom() {
923 i.mu.Lock()
924 i.scrollOffset = 0
925 i.parkedTurn = 0
926 i.parkedTotal = 0
927 if i.rend != nil {
928 i.rend.Invalidate()
929 }
930 i.mu.Unlock()
931 i.invalidate()
932}
933
934func (i *Interactive) redraw() {
935 i.mu.Lock()
936 defer i.mu.Unlock()
937
938 cols, _ := i.cfg.Terminal.Size()
939 chat := i.cachedChatLocked(cols)
940
941 // Dialogs (login or model picker) render between chat and the editor.
942 var dialog []string
943 switch {
944 case i.dialog.Active():
945 dialog = i.dialog.Render(i.cfg.Theme, cols)
946 case i.modelDialog.Active():
947 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
948 case i.rescueDialog.Active():
949 dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
950 case i.sessionDialog.Active():
951 // Reserve rows for the editor (~3), status line (1-2),
952 // dialog chrome (header + hint + rule + indicators, ~5),
953 // and leave the remainder for session rows. Minimum of 3
954 // rows so even a very small terminal shows something.
955 _, rows := i.cfg.Terminal.Size()
956 avail := rows - 12
957 if avail < 3 {
958 avail = 3
959 }
960 i.sessionDialog.MaxRows = avail
961 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
962 case i.swarmDialog.Active():
963 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
964 case i.jumpDialog.Active():
965 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
966 case i.btwDialog.Active():
967 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
968 case i.skillsDialog.Active():
969 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
970 case i.changelogDialog.Active():
971 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
972 case i.confirmDialog.Active():
973 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
974 case i.logoutDialog.Active():
975 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
976 case i.telegramDialog.Active():
977 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
978 case i.settingsDialog.Active():
979 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
980 case i.sessionOpsDialog.Active():
981 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
982 case i.sessionTreeDialog.Active():
983 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
984 case i.extPanel.Active():
985 dialog = i.extPanel.Render(i.cfg.Theme, cols)
986 }
987 if len(dialog) > 0 {
988 dialog = padDialogFrame(dialog)
989 }
990
991 // Slash-command autocomplete: popup above the status line, only
992 // when the editor starts with "/" and no dialog is already open.
993 // Feed extension-registered commands into the suggester first so
994 // they show up in tab-complete + the popup alongside the built-ins.
995 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
996 if i.cfg.Extensions != nil {
997 catalog := i.cfg.Extensions.Commands()
998 extra := make([]slashCommand, 0, len(catalog))
999 for _, c := range catalog {
1000 // The popup renders extension commands under a dedicated
1001 // "── extensions ───" divider, so the description doesn't
1002 // need to repeat the source. If the description is empty,
1003 // fall back to the extension name so the row isn't blank.
1004 desc := c.Description
1005 if strings.TrimSpace(desc) == "" {
1006 desc = "(" + c.Extension + ")"
1007 }
1008 extra = append(extra, slashCommand{
1009 Name: "/" + c.Name,
1010 Desc: desc,
1011 })
1012 }
1013 i.suggest.SetExtra(extra)
1014 }
1015 var suggest []string
1016 currentInput := i.ed.Value()
1017 // Slash popup renders even while the agent is busy so the user
1018 // can queue a destructive command (/clear, /compact, /logout,
1019 // /model) or a read-only one (/help, /jump, /sessions, etc.)
1020 // without waiting for the current turn to finish. The dispatcher
1021 // in runSlash already handles the busy case per-command: safe
1022 // ones run immediately, destructive ones cancel the turn first.
1023 i.fileSuggest.SetCWD(i.cfg.CWD)
1024 if len(dialog) == 0 && i.suggest.Active(currentInput) {
1025 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1026 } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) {
1027 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1028 }
1029
1030 // Detect overlay close (any dialog or slash/file suggestion popup
1031 // just transitioned from open to closed). Force a full redraw so
1032 // the rows the overlay occupied are guaranteed to be repainted
1033 // from the chat below, instead of the diff path leaving stale
1034 // dialog content behind. Equivalent to the user pressing ctrl+l.
1035 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1036 if i.prevOverlayOpen && !overlayOpen && i.rend != nil {
1037 i.rend.Clear()
1038 }
1039 i.prevOverlayOpen = overlayOpen
1040 if len(suggest) > 0 {
1041 // One blank row above the popup so it doesn't sit flush
1042 // against the chat / welcome content above.
1043 suggest = append([]string{""}, suggest...)
1044 }
1045
1046 // Busy prefix shown at the far left of the status bar. The
1047 // spinner glyph and its funny-line message share the `zot`
1048 // label colour (Theme.Assistant) so the whole "who's working"
1049 // band reads at a glance. Elapsed time stays muted because it
1050 // drifts every second and shouldn't grab focus.
1051 var busyPrefix string
1052 if i.busy {
1053 busyPrefix = fmt.Sprintf("%s %s %s %s",
1054 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
1055 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
1056 i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
1057 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1058 )
1059 }
1060
1061 ctxMax := 0
1062 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1063 ctxMax = m.ContextWindow
1064 }
1065 statusLines := tui.StatusBar(tui.StatusBarParams{
1066 Theme: i.cfg.Theme,
1067 Provider: i.cfg.Provider,
1068 Model: i.cfg.Model,
1069 Reasoning: i.cfg.Reasoning,
1070 Busy: i.busy,
1071 BusyPrefix: busyPrefix,
1072 CWD: i.cfg.CWD,
1073 Locked: i.cfg.Sandbox.Locked(),
1074 NoYolo: i.cfg.NoYolo,
1075 Usage: i.cumUsage,
1076 Subscription: i.cfg.AuthMethod == "oauth",
1077 ContextUsed: i.lastCtxInput,
1078 ContextMax: ctxMax,
1079 AutoCompacting: i.autoCompacting,
1080 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1081 Cols: cols,
1082 })
1083 edLines, curR, curC := i.ed.Render(cols)
1084
1085 // "Sliding in" chips for messages the user typed while a turn is
1086 // in flight. Shown directly above the status bar so they're close
1087 // to the editor but don't push the chat around.
1088 var queue []string
1089 queued := append([]string(nil), i.queued...)
1090 if i.agent != nil {
1091 queued = append(queued, i.agent.PendingQueuedMessages()...)
1092 }
1093 if len(queued) > 0 {
1094 queue = append(queue, "")
1095 for _, q := range queued {
1096 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1097 text := truncateLine(q, cols-17)
1098 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1099 }
1100 // Hint row, rendered in the same muted tone as the model
1101 // info on the status bar so it reads as ambient metadata
1102 // rather than a chip. Tells the user how to recover the
1103 // most recent queued message back into the editor.
1104 hint := " Press " + slideBackChordHint() + " to slide back into input"
1105 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1106 }
1107
1108 // Bottom-sticky sections (always visible, never scroll). Each
1109 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1110 // is preceded by one blank row so it has air above the chat
1111 // content. The status block and editor get their own dedicated
1112 // blanks so spacing stays consistent whether or not a dialog or
1113 // popup is showing.
1114 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
1115 if len(dialog) > 0 {
1116 bottom = append(bottom, "")
1117 }
1118 bottom = append(bottom, dialog...)
1119 // The swarm dashboard owns the bottom of the screen while it's
1120 // active: it has its own inline editors for spawn (`n`) and
1121 // prompt (`p`), so the main input would be a confusing second
1122 // caret. The suggest popup, sliding-in queue, status block, and
1123 // main editor are all hidden underneath it. Keystrokes still
1124 // reach handleKey — it routes them to swarmDialog.HandleKey
1125 // before the editor ever sees them — so the only effect of this
1126 // branch is visual.
1127 if !i.swarmDialog.Active() {
1128 bottom = append(bottom, suggest...)
1129 bottom = append(bottom, queue...)
1130 bottom = append(bottom, "")
1131 bottom = append(bottom, statusLines...)
1132 bottom = append(bottom, "")
1133 bottom = append(bottom, edLines...)
1134 }
1135
1136 _, rows := i.cfg.Terminal.Size()
1137 chatRows := rows - len(bottom)
1138 if chatRows < 1 {
1139 chatRows = 1
1140 }
1141
1142 // Auto-follow guard: when the user has scrolled up (scrollOffset
1143 // > 0) and the agent appends new content below the viewport while
1144 // they're reading, compensate so the visible content stays
1145 // anchored. scrollOffset is measured from the bottom of `chat`,
1146 // so without compensation a growing buffer pushes the window
1147 // downward through the content and the lines the user was
1148 // reading scroll off the top.
1149 //
1150 // Skip compensation when the terminal width changed (a resize
1151 // reflows the whole buffer and the line-count delta no longer
1152 // corresponds to appended content) and when scrollOffset is 0
1153 // (the user is following the tail and wants new content to push
1154 // the view down as usual).
1155 if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1156 if delta := len(chat) - i.prevChatLen; delta != 0 {
1157 i.scrollOffset += delta
1158 if i.scrollOffset < 0 {
1159 i.scrollOffset = 0
1160 }
1161 }
1162 }
1163 i.prevChatLen = len(chat)
1164 i.prevChatCols = cols
1165
1166 // Apply scroll offset to the chat slice.
1167 maxOffset := len(chat) - chatRows
1168 if maxOffset < 0 {
1169 maxOffset = 0
1170 }
1171 // Tail-render expansion: if the user has scrolled to (or above)
1172 // the top of the currently rendered tail and there are still
1173 // truncated messages above, widen view.TailLimit and rebuild.
1174 // The chat cache is keyed on tailLimit so the next cachedChatLocked
1175 // will re-issue Build instead of returning the stale slice. We
1176 // rebuild immediately so the same redraw shows the freshly-revealed
1177 // rows; otherwise the user would have to scroll again to see them.
1178 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1179 i.view.TailLimit += resumeTailExpandStep
1180 if i.view.TailLimit >= len(i.view.Messages) {
1181 i.view.TailLimit = 0 // unbounded
1182 }
1183 i.chatCacheValid = false
1184 chat = i.cachedChatLocked(cols)
1185 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1186 chat = chat[:len(chat)-1]
1187 }
1188 maxOffset = len(chat) - chatRows
1189 if maxOffset < 0 {
1190 maxOffset = 0
1191 }
1192 }
1193 if i.scrollOffset > maxOffset {
1194 i.scrollOffset = maxOffset
1195 }
1196 if i.scrollOffset < 0 {
1197 i.scrollOffset = 0
1198 }
1199
1200 var visibleChat []string
1201 if len(chat) <= chatRows {
1202 visibleChat = chat
1203 } else {
1204 end := len(chat) - i.scrollOffset
1205 rawStart := end - chatRows
1206 if rawStart < 0 {
1207 rawStart = 0
1208 }
1209 start := snapViewportStartToImageBlock(chat, rawStart)
1210 // If the snap pulled start upward (an image-block was atomic) while
1211 // the user is scrolling downward, the viewport would sit on the same
1212 // image until the user mashes down past every reserved row. Bump
1213 // scrollOffset past the image so one keypress always clears it.
1214 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
1215 jump := rawStart - start
1216 i.scrollOffset -= jump
1217 if i.scrollOffset < 0 {
1218 i.scrollOffset = 0
1219 }
1220 end = len(chat) - i.scrollOffset
1221 rawStart = end - chatRows
1222 if rawStart < 0 {
1223 rawStart = 0
1224 }
1225 start = snapViewportStartToImageBlock(chat, rawStart)
1226 }
1227 end = start + chatRows
1228 if end > len(chat) {
1229 end = len(chat)
1230 start = end - chatRows
1231 if start < 0 {
1232 start = 0
1233 }
1234 }
1235 visibleChat = chat[start:end]
1236 }
1237 i.prevScrollOffset = i.scrollOffset
1238 visibleChat = clipBottomClippedImages(visibleChat)
1239
1240 // A tiny "scrolled up" indicator in the top-right of the chat pane
1241 // so you know you're not at the bottom. When the viewport was
1242 // parked by /jump we include the turn number so the user remembers
1243 // they're reading history rather than the live conversation.
1244 if i.scrollOffset > 0 && len(visibleChat) > 0 {
1245 var text string
1246 if i.parkedTurn > 0 && i.parkedTotal > 0 {
1247 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
1248 i.parkedTurn, i.parkedTotal, i.scrollOffset)
1249 } else {
1250 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
1251 }
1252 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
1253 visibleChat = append([]string{note}, visibleChat...)
1254 if len(visibleChat) > chatRows {
1255 visibleChat = visibleChat[:chatRows]
1256 }
1257 }
1258
1259 // Default: the real terminal cursor sits on the main editor's
1260 // input position. In main-screen log mode cursor rows are relative
1261 // to the fixed bottom band, not the chat transcript.
1262 // dialogLead is 1 when the bottom region prepends a blank above
1263 // the dialog block (whenever a dialog is showing) so popup-side
1264 // cursor positions still land in the right cell.
1265 dialogLead := 0
1266 if len(dialog) > 0 {
1267 dialogLead = 1
1268 }
1269 // +2 accounts for the blank row above statusLines (so the
1270 // status block has air above it) and the blank row between
1271 // statusLines and edLines (input breathing room). Without
1272 // these the rendered cursor would land on a blank instead of
1273 // inside the editor row.
1274 cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1275 cursorCol := curC
1276 if i.btwDialog.Active() {
1277 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1278 cursorRow = dialogLead + r
1279 cursorCol = c
1280 }
1281 }
1282 if i.dialog.Active() {
1283 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1284 cursorRow = dialogLead + r
1285 cursorCol = c
1286 }
1287 }
1288 if i.sessionDialog.Active() {
1289 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1290 cursorRow = dialogLead + r
1291 cursorCol = c
1292 }
1293 }
1294 if i.swarmDialog.Active() {
1295 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1296 cursorRow = dialogLead + r
1297 cursorCol = c
1298 } else {
1299 // Dashboard list / transcript view has no caret. Without
1300 // this branch the default cursorRow points at the
1301 // (hidden) main editor row, so the terminal would draw
1302 // a stray block somewhere in the chat region.
1303 cursorRow = -1
1304 cursorCol = 0
1305 }
1306 }
1307 if i.extPanel.Active() {
1308 cursorRow = -1
1309 cursorCol = 0
1310 }
1311 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1312 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1313}
1314
1315func hasImageEscape(line string) bool {
1316 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1317}
1318
1319// snapViewportStartToImageBlock treats inline images as atomic blocks for
1320// scrolling. Terminal image protocols draw from a single escape row into a
1321// separate graphics layer; the following blank rows are only zot's reserved
1322// footprint. If the viewport starts on one of those blank rows, there is no
1323// correct partial-image state to render. Snap back to the escape row instead
1324// so the image is either shown from its beginning or skipped entirely.
1325func snapViewportStartToImageBlock(chat []string, start int) int {
1326 if start <= 0 || start >= len(chat) {
1327 return start
1328 }
1329 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1330 return start
1331 }
1332 for k := start - 1; k >= 0; k-- {
1333 line := chat[k]
1334 if hasImageEscape(line) {
1335 return k
1336 }
1337 if !isBoxBlankLine(line) {
1338 break
1339 }
1340 }
1341 return start
1342}
1343
1344const hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1345
1346func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1347 if len(msgs) == 0 {
1348 return nil
1349 }
1350 out := make([]provider.Message, 0, len(msgs))
1351 for _, m := range msgs {
1352 if isHiddenTranscriptMessage(m) {
1353 continue
1354 }
1355 out = append(out, m)
1356 }
1357 return out
1358}
1359
1360func isHiddenTranscriptMessage(m provider.Message) bool {
1361 if m.Role != provider.RoleUser || len(m.Content) == 0 {
1362 return false
1363 }
1364 tb, ok := m.Content[0].(provider.TextBlock)
1365 if !ok {
1366 return false
1367 }
1368 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1369}
1370
1371func clipBottomClippedImages(lines []string) []string {
1372 if len(lines) == 0 {
1373 return lines
1374 }
1375 out := append([]string(nil), lines...)
1376 for i, line := range out {
1377 if !hasImageEscape(line) {
1378 continue
1379 }
1380 // Image blocks render as: image escape, zero or more blank
1381 // reservation rows, then the muted "image - ..." info line,
1382 // then one trailing blank. If the info line isn't visible in
1383 // the current chat slice, the image would paint down into the
1384 // fixed status bar area. Suppress that image for this frame.
1385 //
1386 // When the image lives inside a tool box, the reservation rows
1387 // are wrapped in vertical box edges ("│ ... │"); those rows
1388 // look non-blank under a naive whitespace check but are still
1389 // reservation rows for this scan, so treat them as blank.
1390 foundInfo := false
1391 for j := i + 1; j < len(out); j++ {
1392 if strings.Contains(out[j], "image - ") {
1393 foundInfo = true
1394 break
1395 }
1396 if !isBoxBlankLine(out[j]) {
1397 break
1398 }
1399 }
1400 if !foundInfo {
1401 out[i] = ""
1402 }
1403 }
1404 return out
1405}
1406
1407// isBoxBlankLine reports whether line is visually empty after
1408// stripping ANSI escape sequences, surrounding whitespace, and the
1409// vertical box edges drawn by the tool-box renderer. Used by
1410// clipBottomClippedImages so an image's reservation rows still count
1411// as blank when those rows are wrapped in "│ ... │" inside a tool box.
1412func isBoxBlankLine(line string) bool {
1413 stripped := stripANSIBytes(line)
1414 stripped = strings.TrimSpace(stripped)
1415 stripped = strings.Trim(stripped, "│")
1416 stripped = strings.TrimSpace(stripped)
1417 return stripped == ""
1418}
1419
1420// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1421// byte) from s without pulling in the regexp package. Mirrors the
1422// internal helper in package tui; the duplicated copy avoids exporting
1423// it just for one caller.
1424func stripANSIBytes(s string) string {
1425 if !strings.Contains(s, "\x1b") {
1426 return s
1427 }
1428 var b strings.Builder
1429 b.Grow(len(s))
1430 i := 0
1431 for i < len(s) {
1432 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1433 end := i + 2
1434 for end < len(s) {
1435 c := s[end]
1436 end++
1437 if c >= 0x40 && c <= 0x7e {
1438 break
1439 }
1440 }
1441 i = end
1442 continue
1443 }
1444 b.WriteByte(s[i])
1445 i++
1446 }
1447 return b.String()
1448}
1449
1450// truncateLine shortens s so it fits within n display cells, with an
1451// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1452// novel doesn't blow past the status line.
1453func panelKeyName(k tui.Key) string {
1454 switch k.Kind {
1455 case tui.KeyUp:
1456 return "up"
1457 case tui.KeyDown:
1458 return "down"
1459 case tui.KeyLeft:
1460 return "left"
1461 case tui.KeyRight:
1462 return "right"
1463 case tui.KeyEnter:
1464 return "enter"
1465 case tui.KeyEsc:
1466 return "esc"
1467 case tui.KeyTab:
1468 return "tab"
1469 case tui.KeyBackspace:
1470 return "backspace"
1471 case tui.KeyDelete:
1472 return "delete"
1473 case tui.KeyHome:
1474 return "home"
1475 case tui.KeyEnd:
1476 return "end"
1477 case tui.KeyPageUp:
1478 return "pageup"
1479 case tui.KeyPageDown:
1480 return "pagedown"
1481 case tui.KeyRune:
1482 return "rune"
1483 default:
1484 return "unknown"
1485 }
1486}
1487
1488func panelKeyText(k tui.Key) string {
1489 if k.Kind == tui.KeyRune {
1490 return string(k.Rune)
1491 }
1492 return ""
1493}
1494
1495func truncateLine(s string, n int) string {
1496 if n <= 0 {
1497 return ""
1498 }
1499 // Collapse newlines — chips are single line.
1500 s = strings.ReplaceAll(s, "\n", " ↩ ")
1501 runes := []rune(s)
1502 if len(runes) <= n {
1503 return s
1504 }
1505 if n <= 3 {
1506 return strings.Repeat(".", n)
1507 }
1508 return string(runes[:n-3]) + "..."
1509}
1510
1511// ctrlCExitWindow is how long after a ctrl+c press a *second* press
1512// will exit instead of just clearing input. Long enough to be
1513// deliberate (rules out accidental key chord), short enough that the
1514// hint stays meaningful.
1515const ctrlCExitWindow = 2 * time.Second
1516
1517// armCtrlCExit records the timestamp of the current ctrl+c so the next
1518// one within ctrlCExitWindow exits.
1519func (i *Interactive) armCtrlCExit() {
1520 i.mu.Lock()
1521 i.lastCtrlC = time.Now()
1522 i.mu.Unlock()
1523}
1524
1525// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
1526// that another press should now exit.
1527func (i *Interactive) ctrlCExitArmed() bool {
1528 i.mu.Lock()
1529 t := i.lastCtrlC
1530 i.mu.Unlock()
1531 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
1532}
1533
1534func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1535 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1536 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
1537 // unexpectedly. The hint message also goes stale; clear it.
1538 if k.Kind != tui.KeyCtrlC {
1539 i.mu.Lock()
1540 if !i.lastCtrlC.IsZero() {
1541 i.lastCtrlC = time.Time{}
1542 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
1543 i.statusOK = ""
1544 }
1545 }
1546 i.mu.Unlock()
1547 }
1548
1549 // Dialogs consume keys while open (except ctrl+c, which always closes them).
1550
1551 // Confirm dialog has highest priority: the agent goroutine is
1552 // blocked waiting for an answer, so we must not let keys leak
1553 // anywhere else while it's up.
1554 if i.confirmDialog.Active() {
1555 i.confirmDialog.HandleKey(k)
1556 i.invalidate()
1557 return false
1558 }
1559 if i.dialog.Active() {
1560 if k.Kind == tui.KeyCtrlC {
1561 i.dialog.Close()
1562 if i.cfg.AuthManager != nil {
1563 i.cfg.AuthManager.CancelOAuth()
1564 }
1565 return false
1566 }
1567 act := i.dialog.HandleKey(k)
1568 if act.StartAPIKey {
1569 i.startAPIKeyFlow(act.Provider)
1570 }
1571 if act.StartOAuth {
1572 i.startOAuthFlow(act.Provider)
1573 }
1574 if act.StartManual {
1575 i.startManualOAuthFlow(act.Provider)
1576 }
1577 if act.SubmitCode != "" {
1578 i.submitManualOAuthCode(act.SubmitCode)
1579 }
1580 return false
1581 }
1582 if i.modelDialog.Active() {
1583 if k.Kind == tui.KeyCtrlC {
1584 i.modelDialog.Close()
1585 return false
1586 }
1587 act := i.modelDialog.HandleKey(k)
1588 if act.Select {
1589 i.applyModelSelection(act.Provider, act.Model)
1590 }
1591 return false
1592 }
1593 if i.rescueDialog.Active() {
1594 if k.Kind == tui.KeyCtrlC {
1595 i.rescueDialog.Close()
1596 i.invalidate()
1597 return false
1598 }
1599 act := i.rescueDialog.HandleKey(k)
1600 if act.Select {
1601 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
1602 }
1603 i.invalidate()
1604 return false
1605 }
1606 if i.sessionDialog.Active() {
1607 if k.Kind == tui.KeyCtrlC {
1608 i.sessionDialog.Close()
1609 return false
1610 }
1611 act := i.sessionDialog.HandleKey(k)
1612 if act.Select {
1613 i.applySessionSelection(act.Path)
1614 }
1615 return false
1616 }
1617 if i.swarmDialog.Active() {
1618 if k.Kind == tui.KeyCtrlC {
1619 i.swarmDialog.Close()
1620 i.invalidate()
1621 return false
1622 }
1623 _, msg, errMsg := i.swarmDialog.HandleKey(k)
1624 if msg != "" || errMsg != "" {
1625 i.mu.Lock()
1626 i.statusOK = msg
1627 i.statusErr = errMsg
1628 i.mu.Unlock()
1629 }
1630 i.invalidate()
1631 return false
1632 }
1633 if i.logoutDialog.Active() {
1634 if k.Kind == tui.KeyCtrlC {
1635 i.logoutDialog.Close()
1636 i.invalidate()
1637 return false
1638 }
1639 act := i.logoutDialog.HandleKey(k)
1640 if act.Select {
1641 i.doLogout(act.Target)
1642 }
1643 i.invalidate()
1644 return false
1645 }
1646 if i.telegramDialog.Active() {
1647 if k.Kind == tui.KeyCtrlC {
1648 i.telegramDialog.Close()
1649 i.invalidate()
1650 return false
1651 }
1652 act := i.telegramDialog.HandleKey(k)
1653 if act.Select {
1654 i.doTelegram(act.Action)
1655 }
1656 i.invalidate()
1657 return false
1658 }
1659 if i.settingsDialog.Active() {
1660 if k.Kind == tui.KeyCtrlC {
1661 i.settingsDialog.Close()
1662 i.invalidate()
1663 return false
1664 }
1665 act := i.settingsDialog.HandleKey(k)
1666 if act.Toggle {
1667 i.applySettingChange(act)
1668 }
1669 i.invalidate()
1670 return false
1671 }
1672 if i.sessionOpsDialog.Active() {
1673 if k.Kind == tui.KeyCtrlC {
1674 i.sessionOpsDialog.Close()
1675 i.invalidate()
1676 return false
1677 }
1678 act := i.sessionOpsDialog.HandleKey(k)
1679 if act.Select {
1680 i.doSessionOp(act.Action, "")
1681 }
1682 i.invalidate()
1683 return false
1684 }
1685 if i.sessionTreeDialog.Active() {
1686 if k.Kind == tui.KeyCtrlC {
1687 i.sessionTreeDialog.Close()
1688 i.invalidate()
1689 return false
1690 }
1691 act := i.sessionTreeDialog.HandleKey(k)
1692 if act.Select {
1693 i.applySessionTreeSelection(act.Path)
1694 }
1695 i.invalidate()
1696 return false
1697 }
1698 if i.extPanel.Active() {
1699 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
1700 if i.cfg.Extensions != nil {
1701 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
1702 }
1703 i.extPanel.Close()
1704 i.invalidate()
1705 return false
1706 }
1707 if i.cfg.Extensions != nil {
1708 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
1709 }
1710 return false
1711 }
1712 if i.jumpDialog.Active() {
1713 if k.Kind == tui.KeyCtrlC {
1714 i.jumpDialog.Close()
1715 i.pendingFork = false
1716 return false
1717 }
1718 act := i.jumpDialog.HandleKey(k)
1719 if act.Select {
1720 if i.pendingFork {
1721 i.applyForkSelection(act.MessageIdx)
1722 } else {
1723 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
1724 }
1725 }
1726 // If the user dismissed the dialog without selecting, also
1727 // clear the pending-fork flag so a later plain /jump isn't
1728 // hijacked.
1729 if act.Close {
1730 i.pendingFork = false
1731 }
1732 return false
1733 }
1734 if i.btwDialog.Active() {
1735 if k.Kind == tui.KeyCtrlC {
1736 i.btwDialog.Close()
1737 i.invalidate()
1738 return false
1739 }
1740 i.btwDialog.HandleKey(k, i.invalidate)
1741 return false
1742 }
1743 if i.skillsDialog.Active() {
1744 if k.Kind == tui.KeyCtrlC {
1745 i.skillsDialog.Close()
1746 i.invalidate()
1747 return false
1748 }
1749 i.skillsDialog.HandleKey(k)
1750 i.invalidate()
1751 return false
1752 }
1753 if i.changelogDialog.Active() {
1754 if closed := i.changelogDialog.HandleKey(k); closed {
1755 // User dismissed; let the parent persist the
1756 // LastChangelogShown marker via the close callback.
1757 if i.cfg.OnChangelogDismiss != nil {
1758 i.cfg.OnChangelogDismiss()
1759 }
1760 }
1761 i.invalidate()
1762 return false
1763 }
1764
1765 // Global keys.
1766 switch k.Kind {
1767 case tui.KeyCtrlC:
1768 i.mu.Lock()
1769 loadingSession := i.sessionLoading
1770 i.mu.Unlock()
1771 if loadingSession {
1772 return true
1773 }
1774 // While busy: do NOT cancel the turn. ctrl+c during a
1775 // running turn is almost always reflex muscle memory
1776 // ("be quiet" in a shell) rather than a deliberate
1777 // decision to kill a multi-minute model call that's
1778 // already cost tokens. Use esc to interrupt a turn; use
1779 // a deliberate double-ctrl+c to exit zot entirely. First
1780 // press arms the exit hint, second press within
1781 // ctrlCExitWindow quits.
1782 if i.busy {
1783 if i.ctrlCExitArmed() {
1784 return true
1785 }
1786 i.mu.Lock()
1787 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
1788 i.statusErr = ""
1789 i.mu.Unlock()
1790 i.armCtrlCExit()
1791 return false
1792 }
1793 // Idle: first press clears the editor (and any queued
1794 // follow-up messages); a second press within ctrlCExitWindow
1795 // exits. With both an empty editor and no queue the first
1796 // press still just arms — require a deliberate double-tap.
1797 ag := i.agent
1798 pending := 0
1799 if ag != nil {
1800 pending = ag.QueuedMessageCount()
1801 }
1802 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
1803 if hadInput {
1804 i.ed.Clear()
1805 i.suggest.Reset()
1806 if ag != nil {
1807 ag.DrainQueuedMessages()
1808 }
1809 i.mu.Lock()
1810 i.queued = nil
1811 i.statusOK = "input cleared"
1812 i.statusErr = ""
1813 i.mu.Unlock()
1814 i.armCtrlCExit()
1815 return false
1816 }
1817 if i.ctrlCExitArmed() {
1818 return true
1819 }
1820 i.mu.Lock()
1821 i.statusOK = "press ctrl+c again to exit"
1822 i.statusErr = ""
1823 i.mu.Unlock()
1824 i.armCtrlCExit()
1825 return false
1826 case tui.KeyEsc:
1827 // Esc interrupts a running turn — but only when nothing
1828 // else on screen wants to consume the key first. The slash
1829 // popup has its own Esc behaviour (close + clear editor),
1830 // and transient overlays like the /help block and extension
1831 // notes should dismiss on Esc before we even consider the
1832 // turn. Without these guards, a casual Esc press after
1833 // running /help on a busy turn rips the turn away.
1834 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
1835 break
1836 }
1837 i.mu.Lock()
1838 hadHelp := len(i.helpBlock) > 0
1839 hadNotes := len(i.extNotes) > 0
1840 if hadHelp {
1841 i.helpBlock = nil
1842 }
1843 if hadNotes {
1844 i.extNotes = nil
1845 }
1846 i.mu.Unlock()
1847 if hadHelp || hadNotes {
1848 i.invalidate()
1849 return false
1850 }
1851 if i.busy && i.cancelTurn != nil {
1852 i.cancelTurn()
1853 // If a confirm dialog is pending, refuse it so the agent
1854 // goroutine unblocks and the context cancellation can
1855 // actually take effect.
1856 i.confirmDialog.CancelAll("turn cancelled")
1857 return false
1858 }
1859 case tui.KeyCtrlD:
1860 if i.ed.IsEmpty() && !i.busy {
1861 return true
1862 }
1863 case tui.KeyCtrlL:
1864 i.rend.Clear()
1865 i.invalidate()
1866 return false
1867 case tui.KeyCtrlO:
1868 // Toggle expansion of collapsed tool results. Affects every tool
1869 // call in the transcript — press again to re-collapse.
1870 // In main-screen scrollback mode this changes already-emitted
1871 // transcript rows, so do a full clear+replay instead of trying
1872 // to edit old scrollback in place.
1873 i.mu.Lock()
1874 i.view.ExpandAll = !i.view.ExpandAll
1875 if i.rend != nil {
1876 i.rend.Clear()
1877 }
1878 i.mu.Unlock()
1879 i.invalidate()
1880 return false
1881 case tui.KeyPageUp:
1882 i.scrollBy(+i.chatPage())
1883 return false
1884 case tui.KeyPageDown:
1885 i.scrollBy(-i.chatPage())
1886 return false
1887 case tui.KeyUp:
1888 // Alt/Option+Up: pop the most recently queued ("sliding in")
1889 // message back into the editor so the user can edit and
1890 // resend it. Repeated presses keep peeling messages off the
1891 // tail of the queue; each press *replaces* the editor
1892 // contents (we don't append/push). When the queue is empty
1893 // the keypress falls through to the normal scroll behavior.
1894 if k.Alt {
1895 i.mu.Lock()
1896 var text string
1897 if i.agent != nil {
1898 text, _ = i.agent.PopQueuedMessage()
1899 }
1900 if text == "" {
1901 if n := len(i.queued); n > 0 {
1902 text = i.queued[n-1]
1903 i.queued = i.queued[:n-1]
1904 }
1905 }
1906 i.mu.Unlock()
1907 if text != "" {
1908 i.ed.SetValue(text)
1909 i.inputHistoryIndex = -1
1910 i.invalidate()
1911 return false
1912 }
1913 i.mu.Unlock()
1914 }
1915 // In multi-line / wrapped input, Up first moves inside the editor.
1916 // At the editor's top edge it falls back to chat scrolling, preserving
1917 // the old single-line scroll behavior.
1918 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1919 if i.ed.MoveVertical(-1) {
1920 i.invalidate()
1921 return false
1922 }
1923 i.scrollBy(+3)
1924 return false
1925 }
1926 case tui.KeyDown:
1927 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1928 if i.ed.MoveVertical(+1) {
1929 i.invalidate()
1930 return false
1931 }
1932 if i.scrollOffset > 0 {
1933 i.scrollBy(-3)
1934 }
1935 return false
1936 }
1937 }
1938
1939 // Note: we intentionally do NOT gate the editor on i.busy here.
1940 // Typing while the agent is working is supported — submitted
1941 // messages are queued and delivered as follow-up turns when the
1942 // current turn ends. See the submit handler below.
1943
1944 if k.Kind == tui.KeyEnter && k.Alt {
1945 i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
1946 return false
1947 }
1948
1949 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
1950 if i.suggest.Active(i.ed.Value()) {
1951 switch k.Kind {
1952 case tui.KeyUp:
1953 i.suggest.Up()
1954 return false
1955 case tui.KeyDown:
1956 i.suggest.Down()
1957 return false
1958 case tui.KeyPageUp:
1959 i.suggest.PageUp()
1960 return false
1961 case tui.KeyPageDown:
1962 i.suggest.PageDown()
1963 return false
1964 case tui.KeyTab:
1965 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1966 i.ed.SetValue(name)
1967 i.suggest.Reset()
1968 }
1969 return false
1970 case tui.KeyEnter:
1971 // Enter on an ambiguous or partial slash prefix: complete to the
1972 // currently highlighted command and run it. That way typing
1973 // "/lo" + enter picks whichever of /login or /logout is selected
1974 // in the popup instead of submitting "/lo" as unknown. Also
1975 // clear the editor so the command doesn't linger after the
1976 // dialog opens/closes.
1977 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1978 i.ed.Clear()
1979 i.suggest.Reset()
1980 return i.runSlash(ctx, name)
1981 }
1982 case tui.KeyEsc:
1983 i.ed.Clear()
1984 i.suggest.Reset()
1985 return false
1986 }
1987 }
1988
1989 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
1990 if i.fileSuggest.Active(i.ed.Value()) {
1991 switch k.Kind {
1992 case tui.KeyUp:
1993 i.fileSuggest.Up()
1994 return false
1995 case tui.KeyDown:
1996 i.fileSuggest.Down()
1997 return false
1998 case tui.KeyRight:
1999 // Open selected directory.
2000 i.fileSuggest.Right()
2001 return false
2002 case tui.KeyLeft:
2003 // Go back to parent directory.
2004 i.fileSuggest.Left()
2005 return false
2006 case tui.KeyEnter:
2007 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2008 var chip string
2009 if entry.isDir {
2010 chip = "[dir:" + entry.rel + "/]"
2011 } else {
2012 chip = "[file:" + entry.rel + "]"
2013 }
2014 val := i.ed.Value()
2015 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2016 val = val[:idx]
2017 }
2018 i.ed.SetValue(val + chip + " ")
2019 i.fileSuggest.Reset()
2020 }
2021 return false
2022 case tui.KeyEsc:
2023 val := i.ed.Value()
2024 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2025 i.ed.SetValue(val[:idx])
2026 }
2027 i.fileSuggest.Reset()
2028 return false
2029 }
2030 }
2031
2032 // Tab-complete a path token in the editor when no popup is open.
2033 // Recognises tokens that look like paths (start with ~, /, ./, ../
2034 // or contain a slash); shell-style completion expands ~, lists the
2035 // parent dir, and completes the basename to the longest common
2036 // prefix. Single match: full replace and trailing / for dirs.
2037 // No match: no-op. Plain bare words (no slash, no tilde) fall
2038 // through so Tab keeps its current no-op behaviour outside paths.
2039 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2040 if i.tryPathTabComplete() {
2041 return false
2042 }
2043 }
2044
2045 if i.handleInputHistoryKey(k) {
2046 return false
2047 }
2048 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2049 i.inputHistoryIndex = -1
2050 }
2051
2052 if submit := i.ed.HandleKey(k); submit {
2053 // SubmitValue() expands any [pasted text #N +L lines]
2054 // placeholders back into their bodies; the raw Value()
2055 // is only what the user sees on screen.
2056 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2057 // Expand [file:name] and [dir:name/] chips to full paths.
2058 text = expandFileChips(text, i.cfg.CWD)
2059 if text == "" {
2060 return false
2061 }
2062 i.ed.Clear()
2063 i.inputHistoryIndex = -1
2064 i.suggest.Reset()
2065 i.fileSuggest.Reset()
2066
2067 if looksLikeSlashCommand(text) {
2068 head := text
2069 rest := ""
2070 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2071 head = text[:idx]
2072 rest = strings.TrimSpace(text[idx:])
2073 }
2074 if !isKnownSlashCommand(text) {
2075 // Try extensions before giving up. Extensions register
2076 // commands by bare name (no leading slash); strip it here.
2077 extName := strings.TrimPrefix(head, "/")
2078 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2079 go i.invokeExtensionCommand(ctx, extName, rest)
2080 return false
2081 }
2082 i.mu.Lock()
2083 i.statusErr = "unknown command " + head + " — type /help to see the list"
2084 i.statusOK = ""
2085 i.mu.Unlock()
2086 return false
2087 }
2088 // Slash commands run regardless of busy state. Commands that
2089 // would mutate the transcript or replace the agent (/clear,
2090 // /compact, /logout, /login, /model) cancel the active turn
2091 // first and wait for the goroutine to wind down so they don't
2092 // race with a streaming response. Safe commands (/help,
2093 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2094 // without disturbing the active turn.
2095 if slashCancelsTurn(head) {
2096 i.cancelAndWaitForIdle()
2097 }
2098 return i.runSlash(ctx, text)
2099 }
2100
2101 if i.agent == nil {
2102 i.mu.Lock()
2103 i.statusErr = "not logged in. type /login first."
2104 i.mu.Unlock()
2105 return false
2106 }
2107 // Mirror the user's typed prompt into the paired Telegram
2108 // chat (when the bridge is active) so the Telegram thread
2109 // stays a complete record of the session, not just the half
2110 // that originated on the phone. On a goroutine so the
2111 // network write doesn't delay the local turn.
2112 if i.telegramBridge != nil && i.telegramBridge.Active() {
2113 go i.telegramBridge.OnUserTyped(text)
2114 }
2115 // If a turn is already in flight, queue this prompt inside the
2116 // agent loop so it is delivered at the next safe model-call
2117 // boundary instead of waiting for the whole run to finish.
2118 i.mu.Lock()
2119 busy := i.busy
2120 ag := i.agent
2121 i.mu.Unlock()
2122 if busy {
2123 if ag != nil {
2124 ag.QueueMessage(text)
2125 } else {
2126 i.mu.Lock()
2127 i.queued = append(i.queued, text)
2128 i.mu.Unlock()
2129 }
2130 i.invalidate()
2131 return false
2132 }
2133 i.startTurn(ctx, text)
2134 }
2135 return false
2136}
2137
2138func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2139 if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight {
2140 return false
2141 }
2142 // Do not steal normal cursor movement. History browsing can only
2143 // start from an empty editor; once active, Left/Right keep walking
2144 // the ring so repeated presses work even though the editor now
2145 // contains the selected historical prompt.
2146 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2147 return false
2148 }
2149 hist := i.inputHistory()
2150 if len(hist) == 0 {
2151 return false
2152 }
2153
2154 if i.inputHistoryIndex < 0 {
2155 // Start just after the newest item so Left lands on the most
2156 // recent user prompt and Right keeps the editor empty.
2157 i.inputHistoryIndex = len(hist)
2158 }
2159
2160 switch k.Kind {
2161 case tui.KeyLeft:
2162 if i.inputHistoryIndex > 0 {
2163 i.inputHistoryIndex--
2164 }
2165 case tui.KeyRight:
2166 if i.inputHistoryIndex < len(hist) {
2167 i.inputHistoryIndex++
2168 }
2169 }
2170
2171 if i.inputHistoryIndex >= len(hist) {
2172 i.ed.Clear()
2173 } else {
2174 i.ed.SetValue(hist[i.inputHistoryIndex])
2175 }
2176 return true
2177}
2178
2179func (i *Interactive) inputHistory() []string {
2180 if i.agent == nil {
2181 return nil
2182 }
2183 msgs := i.agent.Messages()
2184 hist := make([]string, 0, len(msgs))
2185 for _, m := range msgs {
2186 if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
2187 continue
2188 }
2189 text := userMessageText(m)
2190 if strings.TrimSpace(text) == "" {
2191 continue
2192 }
2193 hist = append(hist, text)
2194 }
2195 return hist
2196}
2197
2198func userMessageText(m provider.Message) string {
2199 var sb strings.Builder
2200 for _, c := range m.Content {
2201 if tb, ok := c.(provider.TextBlock); ok {
2202 if sb.Len() > 0 {
2203 sb.WriteByte('\n')
2204 }
2205 sb.WriteString(tb.Text)
2206 }
2207 }
2208 return sb.String()
2209}
2210
2211// invokeExtensionCommand fires an extension-registered slash command
2212// in a background goroutine, awaits the response, and applies the
2213// requested action (prompt / insert / display / noop). Errors and
2214// timeouts surface as a status_err line.
2215
2216func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
2217 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
2218 if err != nil {
2219 i.mu.Lock()
2220 i.statusErr = "extension /" + name + ": " + err.Error()
2221 i.statusOK = ""
2222 i.mu.Unlock()
2223 i.invalidate()
2224 return
2225 }
2226 if resp.Error != "" {
2227 i.mu.Lock()
2228 i.statusErr = "extension /" + name + ": " + resp.Error
2229 i.statusOK = ""
2230 i.mu.Unlock()
2231 i.invalidate()
2232 return
2233 }
2234 switch resp.Action {
2235 case "open_panel":
2236 if resp.OpenPanel != nil {
2237 extName := name
2238 if i.cfg.Extensions != nil {
2239 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
2240 extName = owner
2241 }
2242 }
2243 i.OpenPanel(extName, *resp.OpenPanel)
2244 }
2245 case "prompt":
2246 if strings.TrimSpace(resp.Prompt) == "" {
2247 return
2248 }
2249 i.startTurn(i.runCtx, resp.Prompt)
2250 case "insert":
2251 i.ed.Insert(resp.Insert)
2252 i.invalidate()
2253 case "display":
2254 i.appendExtensionNote(name, resp.Display, "info")
2255 case "noop", "":
2256 // nothing
2257 default:
2258 i.mu.Lock()
2259 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
2260 i.mu.Unlock()
2261 i.invalidate()
2262 }
2263}
2264
2265// appendExtensionNote renders an extension-originated note in the
2266// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
2267// "success" (tool/ok green).
2268func (i *Interactive) appendExtensionNote(extName, msg, level string) {
2269 if msg == "" {
2270 return
2271 }
2272 i.mu.Lock()
2273 defer i.mu.Unlock()
2274 color := i.cfg.Theme.Muted
2275 switch level {
2276 case "warn":
2277 color = i.cfg.Theme.Warning
2278 case "error":
2279 color = i.cfg.Theme.Error
2280 case "success":
2281 color = i.cfg.Theme.Tool
2282 }
2283 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
2284 for _, line := range strings.Split(msg, "\n") {
2285 i.statusOK = "" // clear any stale ok
2286 i.statusErr = ""
2287 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
2288 }
2289}
2290
2291// HostHooks implementation for the extension manager. The manager
2292// holds an interface, not a concrete *Interactive, so these methods
2293// are the only thing the manager sees.
2294
2295// Notify is the manager's NotifyFromExt entry point.
2296func (i *Interactive) Notify(extName, level, message string) {
2297 i.appendExtensionNote(extName, message, level)
2298 i.invalidate()
2299}
2300
2301// Submit feeds text through the agent loop as if the user had typed it.
2302func (i *Interactive) Submit(text string) {
2303 i.startTurn(i.runCtx, text)
2304}
2305
2306// ApplyChangedCWD is called by the host after a successful /cd hook.
2307// The host has already rebuilt the agent and opened a fresh session
2308// in the new cwd; this method swaps the fresh agent into the running
2309// TUI, updates the displayed cwd, clears the transcript display
2310// caches, and points the file picker at the new directory.
2311//
2312// The fresh agent's transcript is empty (new session) so the chat
2313// view starts blank, matching what relaunching `zot --cwd <path>`
2314// would show. Cost meters reset.
2315func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
2316 i.mu.Lock()
2317 i.agent = ag
2318 i.cfg.CWD = cwd
2319 i.cfg.Provider = provider
2320 i.cfg.Model = model
2321 i.toolCalls = map[string]*tui.ToolCallView{}
2322 i.toolOrder = nil
2323 i.helpBlock = nil
2324 i.parkedTurn = 0
2325 i.statusErr = ""
2326 i.mu.Unlock()
2327 i.fileSuggest.Reset()
2328 i.fileSuggest.SetCWD(cwd)
2329 i.invalidate()
2330}
2331
2332// SubmitSlash runs text as a slash command in the TUI as if the user
2333// had typed it. text must start with '/' — callers that hand it
2334// plain prose silently get a no-op so a misbehaving extension can't
2335// run a stray prompt through this path. Read-only commands run in
2336// place; commands that would mutate the transcript or replace the
2337// agent cancel the active turn first via the same path the editor
2338// uses for typed slash commands.
2339func (i *Interactive) SubmitSlash(text string) {
2340 text = strings.TrimSpace(text)
2341 if !strings.HasPrefix(text, "/") {
2342 return
2343 }
2344 head := text
2345 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2346 head = text[:idx]
2347 }
2348 if slashCancelsTurn(head) {
2349 i.cancelAndWaitForIdle()
2350 }
2351 i.runSlash(i.runCtx, text)
2352 i.invalidate()
2353}
2354
2355// SubmitOrQueue runs text immediately if the agent is idle, or
2356// appends it to the pending queue if a turn is already in flight.
2357// Used by the telegram bridge (and by the editor submit path) so
2358// both input sources share the same "queue behind an active turn"
2359// semantics. Images are ignored for now — only the text prompt is
2360// forwarded — because the queued-prompt path is text-only; a
2361// follow-up can expand the queue entry to carry images.
2362func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
2363 i.mu.Lock()
2364 if i.agent == nil {
2365 i.statusErr = "not logged in. type /login first."
2366 i.mu.Unlock()
2367 i.invalidate()
2368 return
2369 }
2370 if i.busy {
2371 // Queue text only; images are dropped for queued prompts.
2372 ag := i.agent
2373 i.mu.Unlock()
2374 if ag != nil {
2375 ag.QueueMessage(text)
2376 } else {
2377 i.mu.Lock()
2378 i.queued = append(i.queued, text)
2379 i.mu.Unlock()
2380 }
2381 i.invalidate()
2382 return
2383 }
2384 i.mu.Unlock()
2385 i.startTurnWithImages(i.runCtx, text, images)
2386}
2387
2388// CancelTurn aborts the active turn if one is running. Used by the
2389// telegram bridge when the paired user sends /stop.
2390// ChangelogVersion returns the version string of the changelog
2391// currently shown (or last shown). Used by the dismiss callback
2392// to store the correct version for dev builds.
2393func (i *Interactive) ChangelogVersion() string {
2394 if i.changelogDialog != nil {
2395 return i.changelogDialog.version
2396 }
2397 return ""
2398}
2399
2400func (i *Interactive) CancelTurn() {
2401 i.mu.Lock()
2402 cancel := i.cancelTurn
2403 i.mu.Unlock()
2404 if cancel != nil {
2405 cancel()
2406 i.confirmDialog.CancelAll("turn cancelled")
2407 }
2408}
2409
2410// Insert places text at the cursor in the editor.
2411func (i *Interactive) Insert(text string) {
2412 i.ed.Insert(text)
2413 i.invalidate()
2414}
2415
2416// Display appends a styled note from extName to the chat without a
2417// model call.
2418func (i *Interactive) Display(extName, text string) {
2419 i.appendExtensionNote(extName, text, "info")
2420 i.invalidate()
2421}
2422
2423func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
2424 i.mu.Lock()
2425 defer i.mu.Unlock()
2426 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
2427 if i.cfg.Extensions != nil {
2428 cols, rows := i.cfg.Terminal.Size()
2429 _ = cols
2430 _ = rows
2431 }
2432 i.invalidate()
2433}
2434
2435func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
2436 i.mu.Lock()
2437 defer i.mu.Unlock()
2438 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2439 i.extPanel.Update(title, lines, footer)
2440 i.invalidate()
2441 }
2442}
2443
2444func (i *Interactive) ClosePanel(extName, panelID string) {
2445 i.mu.Lock()
2446 defer i.mu.Unlock()
2447 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
2448 i.extPanel.Close()
2449 i.invalidate()
2450 }
2451}
2452
2453func effectiveImageProtocol(override *bool) tui.ImageProtocol {
2454 detected := tui.DetectImageProtocol()
2455 if override == nil {
2456 return detected
2457 }
2458 if !*override {
2459 return tui.ImageProtocolNone
2460 }
2461 return detected
2462}
2463
2464func imageProtocolName(p tui.ImageProtocol) string {
2465 switch p {
2466 case tui.ImageProtocolKitty:
2467 return "kitty/ghostty"
2468 case tui.ImageProtocolITerm2:
2469 return "iTerm2"
2470 default:
2471 return "none"
2472 }
2473}
2474
2475func onOff(v bool) string {
2476 if v {
2477 return "enabled"
2478 }
2479 return "disabled"
2480}
2481
2482func (i *Interactive) openSettingsDialog() {
2483 detected := tui.DetectImageProtocol()
2484 imgEnabled := detected != tui.ImageProtocolNone
2485 if i.cfg.InlineImagesEnabled != nil {
2486 imgEnabled = *i.cfg.InlineImagesEnabled
2487 }
2488 imgDisabled := detected == tui.ImageProtocolNone
2489 imgHint := ""
2490 if imgDisabled {
2491 imgEnabled = false
2492 imgHint = "this terminal does not support inline images"
2493 } else {
2494 imgHint = "terminal supports " + imageProtocolName(detected)
2495 }
2496
2497 autoSwarm := false
2498 if i.cfg.AutoSwarmEnabled != nil {
2499 autoSwarm = *i.cfg.AutoSwarmEnabled
2500 }
2501 autoSwarmDisabled := i.cfg.Swarm == nil
2502 autoSwarmHint := ""
2503 if autoSwarmDisabled {
2504 autoSwarm = false
2505 autoSwarmHint = "swarm supervisor not available in this mode"
2506 }
2507
2508 reasoningOptions := []settingsOption{
2509 {value: "", label: "off", desc: "no reasoning"},
2510 {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
2511 {value: "low", label: "low", desc: "light (~2k tokens)"},
2512 {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
2513 {value: "high", label: "high", desc: "deep (~16k tokens)"},
2514 {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"},
2515 }
2516 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
2517 reasoningChoice := 0
2518 for idx, opt := range reasoningOptions {
2519 if opt.value == reasoning {
2520 reasoningChoice = idx
2521 break
2522 }
2523 }
2524 reasoningHint := ""
2525 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
2526 reasoningHint = "current model does not support thinking"
2527 }
2528
2529 i.settingsDialog.Open([]settingsItem{
2530 {
2531 key: "inline_images_enabled",
2532 label: "render images when supported",
2533 desc: "draw screenshots inline instead of showing a text placeholder",
2534 value: imgEnabled,
2535 disabled: imgDisabled,
2536 hint: imgHint,
2537 },
2538 {
2539 key: "auto_swarm_enabled",
2540 label: "auto-swarm",
2541 desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
2542 value: autoSwarm,
2543 disabled: autoSwarmDisabled,
2544 hint: autoSwarmHint,
2545 },
2546 {
2547 key: "reasoning",
2548 label: "thinking level",
2549 desc: "reasoning depth for thinking-capable models",
2550 options: reasoningOptions,
2551 choice: reasoningChoice,
2552 hint: reasoningHint,
2553 },
2554 })
2555}
2556
2557func (i *Interactive) applySettingChange(act settingsAction) {
2558 if act.Key == "reasoning" {
2559 i.applyReasoningSetting(act.StringValue)
2560 return
2561 }
2562 i.applySettingToggle(act.Key, act.Value)
2563}
2564
2565func (i *Interactive) applySettingToggle(key string, value bool) {
2566 // Every setting toggle forces a full repaint at the end — same
2567 // effect as the user pressing Ctrl+L — so any per-setting visual
2568 // change (image rendering, status copy, future toggles) lands
2569 // immediately instead of waiting for the next diff frame.
2570 defer func() {
2571 if i.rend != nil {
2572 i.rend.Clear()
2573 }
2574 i.invalidate()
2575 }()
2576 switch key {
2577 case "inline_images_enabled":
2578 val := value
2579 i.cfg.InlineImagesEnabled = &val
2580 if i.cfg.SettingsStore != nil {
2581 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
2582 i.mu.Lock()
2583 i.statusErr = "settings: " + err.Error()
2584 i.mu.Unlock()
2585 return
2586 }
2587 }
2588 i.mu.Lock()
2589 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
2590 i.view.InvalidateRenderCache()
2591 i.statusOK = "inline image rendering " + onOff(value)
2592 i.statusErr = ""
2593 i.mu.Unlock()
2594 case "auto_swarm_enabled":
2595 val := value
2596 i.cfg.AutoSwarmEnabled = &val
2597 if i.cfg.SettingsStore != nil {
2598 if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
2599 i.mu.Lock()
2600 i.statusErr = "settings: " + err.Error()
2601 i.mu.Unlock()
2602 return
2603 }
2604 }
2605 // Add/remove the swarm_spawn tool on the live agent so the
2606 // model's tools[] list reflects the toggle on the next turn.
2607 // Without this the tool stays advertised after a disable and
2608 // the model keeps trying to call it.
2609 i.applyAutoSwarmTool(value)
2610 // Also swap the system-prompt addendum in/out so the model
2611 // knows to use the tool proactively (or stops referencing it
2612 // after a disable).
2613 i.applyAutoSwarmSystemPrompt(value)
2614 i.mu.Lock()
2615 i.statusOK = "auto-swarm " + onOff(value)
2616 i.statusErr = ""
2617 i.mu.Unlock()
2618 }
2619}
2620
2621func (i *Interactive) applyReasoningSetting(level string) {
2622 defer func() {
2623 if i.rend != nil {
2624 i.rend.Clear()
2625 }
2626 i.invalidate()
2627 }()
2628 level = provider.NormalizeReasoning(level)
2629 i.cfg.Reasoning = level
2630 if i.cfg.SettingsStore != nil {
2631 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
2632 i.mu.Lock()
2633 i.statusErr = "settings: " + err.Error()
2634 i.mu.Unlock()
2635 return
2636 }
2637 }
2638 i.mu.Lock()
2639 if i.agent != nil {
2640 i.agent.Reasoning = level
2641 }
2642 label := level
2643 if label == "" {
2644 label = "off"
2645 }
2646 i.statusOK = "thinking level " + label
2647 i.statusErr = ""
2648 i.mu.Unlock()
2649}
2650
2651// buildStudyPrompt returns the canned prompt the /study command
2652// submits to the agent.
2653//
2654// With no argument, /study targets the current directory — the
2655// historical behaviour. With an argument, /study targets that path
2656// instead; either a directory ("read every file in here") or a
2657// single file ("read this file"). The argument can be:
2658//
2659// - a relative path (resolved against cwd)
2660// - an absolute path
2661// - an @-picker chip, which has already been expanded to an
2662// absolute path by expandFileChips before runSlash sees it
2663//
2664// The path is stat'd to pick the right wording ("directory" vs
2665// "file"). If the path doesn't exist, we still build a sensible
2666// prompt rather than erroring — the agent will surface the
2667// missing-file failure itself when it tries to read it, which is
2668// more useful than a refusal here.
2669func buildStudyPrompt(arg, cwd string) string {
2670 arg = strings.TrimSpace(arg)
2671 if arg == "" {
2672 return "Read and understand everything in the current directory."
2673 }
2674 abs := arg
2675 if !filepath.IsAbs(abs) {
2676 abs = filepath.Join(cwd, abs)
2677 }
2678 display := arg
2679 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
2680 display = rel
2681 }
2682 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
2683 return "Read and understand the file " + display + "."
2684 }
2685 return "Read and understand everything in the directory " + display + "."
2686}
2687
2688// tryPathTabCompleteEditor looks at ed's current value, finds the
2689// path-like token immediately before the cursor (the cursor is always
2690// at the end of the buffer after a keystroke, so "before the cursor"
2691// is the trailing non-whitespace run), and rewrites it to its shell-
2692// style completion against the filesystem.
2693//
2694// Returns true when it consumed the Tab keystroke (token recognised,
2695// completion attempted — even if no candidates matched, the keystroke
2696// is still consumed so it doesn't insert a literal tab character).
2697// Returns false when the token doesn't look like a path; callers then
2698// let Tab fall through to its normal no-op.
2699//
2700// Recognised path shapes:
2701// - ~ or ~/foo expanded via os.UserHomeDir()
2702// - /abs/path or /abs/path/foo absolute
2703// - ./foo, ../foo, foo/bar relative to cwd
2704//
2705// A bare word like "hello" is not treated as a path so plain text
2706// keeps Tab as a literal no-op.
2707//
2708// Free function (not a method) so the same logic runs against the
2709// editor instances owned by btwDialog and swarmDialog without each
2710// dialog needing its own copy.
2711func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
2712 if ed == nil {
2713 return false
2714 }
2715 val := ed.Value()
2716 // Find the trailing run of non-whitespace.
2717 start := len(val)
2718 for start > 0 {
2719 r := val[start-1]
2720 if r == ' ' || r == '\t' || r == '\n' {
2721 break
2722 }
2723 start--
2724 }
2725 token := val[start:]
2726 if token == "" {
2727 return false
2728 }
2729 if !looksLikePathToken(token) {
2730 return false
2731 }
2732
2733 // Resolve the absolute parent directory + base prefix to match.
2734 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
2735 if !ok {
2736 return true
2737 }
2738 entries, err := os.ReadDir(parentAbs)
2739 if err != nil {
2740 return true
2741 }
2742 var names []string
2743 var isDir []bool
2744 for _, e := range entries {
2745 name := e.Name()
2746 if !strings.HasPrefix(name, basePrefix) {
2747 continue
2748 }
2749 // Hide dotfiles unless the user explicitly typed a leading dot,
2750 // mirroring bash's default behaviour.
2751 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
2752 continue
2753 }
2754 names = append(names, name)
2755 isDir = append(isDir, e.IsDir())
2756 }
2757 if len(names) == 0 {
2758 return true
2759 }
2760
2761 var completed string
2762 var completedIsDir bool
2763 if len(names) == 1 {
2764 completed = names[0]
2765 completedIsDir = isDir[0]
2766 } else {
2767 completed = longestCommonPrefix(names)
2768 if completed == basePrefix {
2769 // Already at the deepest unambiguous prefix; nothing to add.
2770 return true
2771 }
2772 }
2773
2774 // Build the replacement token in the same display form the user
2775 // typed (preserve ~ vs absolute vs relative).
2776 newToken := displayParent + completed
2777 if len(names) == 1 && completedIsDir {
2778 newToken += "/"
2779 }
2780
2781 ed.SetValue(val[:start] + newToken)
2782 return true
2783}
2784
2785// tryPathTabComplete is the Interactive-bound convenience wrapper.
2786// It calls the free helper against the main editor and invalidates
2787// the frame on a successful rewrite.
2788func (i *Interactive) tryPathTabComplete() bool {
2789 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
2790 i.invalidate()
2791 return true
2792 }
2793 return false
2794}
2795
2796// looksLikePathToken reports whether tok is shaped like a filesystem
2797// path. Paths must either start with ~, /, ./, ../ or contain a /.
2798// Plain words are excluded so Tab on "hello" stays a no-op.
2799func looksLikePathToken(tok string) bool {
2800 if tok == "" {
2801 return false
2802 }
2803 if tok[0] == '~' || tok[0] == '/' {
2804 return true
2805 }
2806 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
2807 return true
2808 }
2809 return strings.Contains(tok, "/")
2810}
2811
2812// resolvePathTabToken splits tok into (absolute parent dir, basename
2813// prefix to match, display-form parent the user typed). ok is false
2814// when the parent dir can't be resolved (e.g. ~ with no $HOME).
2815func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
2816 // Detect ~ expansion.
2817 expanded := tok
2818 homePrefix := ""
2819 if tok == "~" {
2820 home, err := os.UserHomeDir()
2821 if err != nil || home == "" {
2822 return "", "", "", false
2823 }
2824 // "~" alone: complete in $HOME. parent = home, base = "".
2825 return home, "", "~/", true
2826 }
2827 if strings.HasPrefix(tok, "~/") {
2828 home, err := os.UserHomeDir()
2829 if err != nil || home == "" {
2830 return "", "", "", false
2831 }
2832 expanded = home + tok[1:]
2833 homePrefix = "~"
2834 }
2835
2836 dir, base := splitDirBase(expanded)
2837 if !filepath.IsAbs(dir) {
2838 dir = filepath.Join(cwd, dir)
2839 }
2840
2841 // Reconstruct the display form the user typed for the parent,
2842 // keeping ~ when they used it. The base is dropped — the caller
2843 // substitutes the completed name.
2844 display := tok[:len(tok)-len(base)]
2845 if homePrefix != "" && !strings.HasPrefix(display, "~") {
2846 display = homePrefix + display[len(homePrefix):]
2847 }
2848 return dir, base, display, true
2849}
2850
2851// splitDirBase is like filepath.Split but preserves the trailing
2852// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
2853// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
2854// the trailing separator when non-empty so callers can rebuild paths
2855// by concatenation.
2856func splitDirBase(p string) (dir, base string) {
2857 if p == "" {
2858 return ".", ""
2859 }
2860 i := strings.LastIndex(p, "/")
2861 if i < 0 {
2862 return ".", p
2863 }
2864 return p[:i+1], p[i+1:]
2865}
2866
2867func longestCommonPrefix(ss []string) string {
2868 if len(ss) == 0 {
2869 return ""
2870 }
2871 prefix := ss[0]
2872 for _, s := range ss[1:] {
2873 n := 0
2874 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
2875 n++
2876 }
2877 prefix = prefix[:n]
2878 if prefix == "" {
2879 return ""
2880 }
2881 }
2882 return prefix
2883}
2884
2885func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2886 parts := strings.Fields(cmd)
2887 switch parts[0] {
2888 case "/exit":
2889 return true
2890 case "/clear":
2891 if i.agent != nil {
2892 i.agent.SetMessages(nil)
2893 }
2894 i.mu.Lock()
2895 i.toolCalls = map[string]*tui.ToolCallView{}
2896 i.toolOrder = nil
2897 i.statusErr = ""
2898 i.statusOK = ""
2899 i.helpBlock = nil
2900 i.parkedTurn = 0
2901 i.parkedTotal = 0
2902 i.scrollOffset = 0
2903 i.extNotes = nil
2904 i.view.InvalidateRenderCache()
2905 i.mu.Unlock()
2906 case "/help":
2907 i.mu.Lock()
2908 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
2909 i.statusErr = ""
2910 i.statusOK = ""
2911 // Pin the viewport to the newest content so the help block,
2912 // which we just appended to the end of the transcript, is
2913 // what the user actually sees.
2914 i.scrollOffset = 0
2915 i.mu.Unlock()
2916 case "/login":
2917 i.dialog.Open(i.cfg.ZotHome)
2918 case "/logout":
2919 if len(parts) >= 2 {
2920 // Explicit target: /logout anthropic | openai | all
2921 i.doLogout(parts[1])
2922 break
2923 }
2924 // No arg: open the picker over whichever providers are
2925 // currently logged in. If nothing's logged in, bail with a
2926 // status line.
2927 i.openLogoutDialog()
2928 case "/model":
2929 if len(parts) >= 2 {
2930 i.applyModelSelection("", parts[1])
2931 } else {
2932 var loggedIn []string
2933 if i.cfg.LoggedInProviders != nil {
2934 loggedIn = i.cfg.LoggedInProviders()
2935 }
2936 i.modelDialog.Open(i.cfg.Model, loggedIn)
2937 }
2938 case "/settings":
2939 i.openSettingsDialog()
2940 case "/sessions":
2941 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
2942 case "/jump":
2943 i.openJumpDialog(parts[1:])
2944 case "/btw":
2945 i.openBtwDialog(parts[1:])
2946 case "/skills":
2947 i.openSkillsDialog()
2948 case "/compact":
2949 i.runCompact(ctx, false)
2950 case "/study":
2951 // Canned prompt that tells the agent to read every file
2952 // in some target so its later turns have the whole thing
2953 // in context. With no argument, the target is the current
2954 // directory. With an argument, the target is whatever the
2955 // user passed — typed by hand, drag-dropped, or selected
2956 // via the @ file picker (which is why we accept both files
2957 // and directories; the @-picker chips for both have already
2958 // been expanded to absolute paths by expandFileChips above).
2959 // Dispatched through the normal queue-or-start path so it
2960 // behaves identically to typing the prompt by hand.
2961 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
2962 i.mu.Lock()
2963 busy := i.busy
2964 ag := i.agent
2965 i.mu.Unlock()
2966 if busy {
2967 if ag != nil {
2968 ag.QueueMessage(studyPrompt)
2969 } else {
2970 i.mu.Lock()
2971 i.queued = append(i.queued, studyPrompt)
2972 i.mu.Unlock()
2973 }
2974 i.invalidate()
2975 break
2976 }
2977 i.startTurn(ctx, studyPrompt)
2978 case "/cd":
2979 // Hidden command: switch the running session's cwd. Not in
2980 // slash_suggest, not in /help. Used by the workspaces
2981 // extension's panel-key Enter handler so picking a row
2982 // jumps zot into that directory without relaunching.
2983 //
2984 // Recovers the raw argument (path) from the original cmd
2985 // string rather than parts, so paths with spaces survive.
2986 // The host's ChangeCWD hook handles validation, session
2987 // close + reopen, agent rebuild, sandbox re-rooting, and
2988 // re-jail-if-jailed semantics.
2989 if i.cfg.ChangeCWD == nil {
2990 i.mu.Lock()
2991 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
2992 i.mu.Unlock()
2993 break
2994 }
2995 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
2996 if path == "" {
2997 i.mu.Lock()
2998 i.statusErr = "/cd: missing path"
2999 i.mu.Unlock()
3000 break
3001 }
3002 if err := i.cfg.ChangeCWD(path); err != nil {
3003 i.mu.Lock()
3004 i.statusErr = "/cd: " + err.Error()
3005 i.statusOK = ""
3006 i.mu.Unlock()
3007 break
3008 }
3009 // ChangeCWD has already updated i.cfg.CWD and swapped the
3010 // agent + session. Reset transient TUI state so the new
3011 // session opens clean.
3012 i.mu.Lock()
3013 i.toolCalls = map[string]*tui.ToolCallView{}
3014 i.toolOrder = nil
3015 i.helpBlock = nil
3016 i.parkedTurn = 0
3017 i.statusOK = "cwd " + i.cfg.CWD
3018 i.statusErr = ""
3019 i.mu.Unlock()
3020 i.fileSuggest.Reset()
3021 i.fileSuggest.SetCWD(i.cfg.CWD)
3022 i.invalidate()
3023 case "/jail":
3024 if i.cfg.Sandbox == nil {
3025 i.mu.Lock()
3026 i.statusErr = "sandbox not available in this build"
3027 i.mu.Unlock()
3028 break
3029 }
3030 i.cfg.Sandbox.Lock()
3031 i.mu.Lock()
3032 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
3033 i.statusErr = ""
3034 i.mu.Unlock()
3035 case "/unjail":
3036 if i.cfg.Sandbox == nil {
3037 i.mu.Lock()
3038 i.statusErr = "sandbox not available in this build"
3039 i.mu.Unlock()
3040 break
3041 }
3042 i.cfg.Sandbox.Unlock()
3043 i.mu.Lock()
3044 i.statusOK = "unjailed"
3045 i.statusErr = ""
3046 i.mu.Unlock()
3047 case "/reload-ext":
3048 i.runReloadExt(ctx)
3049 case "/telegram", "/tg":
3050 if len(parts) >= 2 {
3051 i.doTelegram(parts[1])
3052 break
3053 }
3054 i.openTelegramDialog()
3055 case "/session":
3056 if len(parts) >= 2 {
3057 action := parts[1]
3058 arg := ""
3059 if len(parts) >= 3 {
3060 arg = strings.Join(parts[2:], " ")
3061 }
3062 i.doSessionOp(action, arg)
3063 break
3064 }
3065 i.openSessionOpsDialog()
3066 case "/swarm":
3067 i.runSwarm(ctx, parts[1:])
3068 default:
3069 // Last-resort fallback: try the extension manager. Built-in
3070 // cases above always win; this branch only fires for slash
3071 // commands the extension manager registered. Same routing as
3072 // the editor's submit-handler dispatch path so the autocomplete
3073 // "enter on highlighted suggestion" flow also works.
3074 extName := strings.TrimPrefix(parts[0], "/")
3075 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
3076 rest := ""
3077 if len(parts) > 1 {
3078 rest = strings.Join(parts[1:], " ")
3079 }
3080 go i.invokeExtensionCommand(ctx, extName, rest)
3081 return false
3082 }
3083 i.mu.Lock()
3084 i.statusErr = "unknown command: " + parts[0]
3085 i.mu.Unlock()
3086 }
3087 return false
3088}
3089
3090// openLogoutDialog shows the provider picker for `/logout` with no
3091// argument. Only providers the user is currently logged into are
3092// listed, plus an "all" entry when more than one is present. If
3093// nothing's logged in, writes a status line instead of opening an
3094// empty dialog.
3095func (i *Interactive) openLogoutDialog() {
3096 if i.cfg.AuthManager == nil {
3097 i.mu.Lock()
3098 i.statusErr = "no auth manager configured"
3099 i.mu.Unlock()
3100 i.invalidate()
3101 return
3102 }
3103 store := i.cfg.AuthManager.Store()
3104 if store == nil {
3105 i.mu.Lock()
3106 i.statusErr = "auth store is not available"
3107 i.mu.Unlock()
3108 i.invalidate()
3109 return
3110 }
3111 creds, err := store.Load()
3112 if err != nil {
3113 i.mu.Lock()
3114 i.statusErr = "read auth store: " + err.Error()
3115 i.mu.Unlock()
3116 i.invalidate()
3117 return
3118 }
3119
3120 var items []logoutItem
3121 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
3122 if creds.Has(p) {
3123 method := creds.Method(p)
3124 if method == "oauth" {
3125 method = "subscription"
3126 }
3127 items = append(items, logoutItem{
3128 label: providerLabel(p),
3129 target: p,
3130 method: method,
3131 })
3132 }
3133 }
3134 if creds.OpenAI.APIKey != "" {
3135 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
3136 }
3137 if creds.OpenAI.OAuth != nil {
3138 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
3139 }
3140 for p, c := range creds.AdditionalAPIKeyCreds {
3141 if c.APIKey != "" {
3142 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
3143 }
3144 }
3145 if len(items) == 0 {
3146 i.mu.Lock()
3147 i.statusOK = "no credentials stored; already logged out"
3148 i.statusErr = ""
3149 i.mu.Unlock()
3150 i.invalidate()
3151 return
3152 }
3153 if len(items) > 1 {
3154 items = append(items, logoutItem{label: "all", target: "all"})
3155 }
3156
3157 i.logoutDialog.Open(items)
3158 i.invalidate()
3159}
3160
3161// doLogout clears credentials for the given provider (or all providers)
3162// from auth.json. If the active agent was using those credentials, it
3163// is torn down so the user is forced through /login before their next
3164// prompt.
3165//
3166// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
3167func (i *Interactive) doLogout(target string) {
3168 if i.cfg.AuthManager == nil {
3169 i.mu.Lock()
3170 i.statusErr = "no auth manager configured"
3171 i.mu.Unlock()
3172 return
3173 }
3174 store := i.cfg.AuthManager.Store()
3175 if store == nil {
3176 i.mu.Lock()
3177 i.statusErr = "auth store is not available"
3178 i.mu.Unlock()
3179 return
3180 }
3181
3182 var providers []string
3183 switch target {
3184 case "", "all":
3185 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
3186 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
3187 providers = []string{target}
3188 default:
3189 known := false
3190 for _, p := range auth.APIKeyProviders() {
3191 if target == p {
3192 known = true
3193 break
3194 }
3195 }
3196 if !known {
3197 i.mu.Lock()
3198 i.statusErr = "unknown provider: " + target
3199 i.mu.Unlock()
3200 return
3201 }
3202 providers = []string{target}
3203 }
3204
3205 var errs []string
3206 clearedCurrent := false
3207 for _, p := range providers {
3208 var err error
3209 switch p {
3210 case "openai":
3211 err = store.ClearAPIKey("openai")
3212 case "openai-codex":
3213 err = store.ClearOAuth("openai")
3214 default:
3215 err = store.Clear(p)
3216 }
3217 if err != nil {
3218 errs = append(errs, p+": "+err.Error())
3219 continue
3220 }
3221 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
3222 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
3223 errs = append(errs, p+": "+err.Error())
3224 continue
3225 }
3226 }
3227 if p == i.cfg.Provider {
3228 clearedCurrent = true
3229 }
3230 }
3231
3232 i.mu.Lock()
3233 defer i.mu.Unlock()
3234 if len(errs) > 0 {
3235 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
3236 return
3237 }
3238 i.statusErr = ""
3239 if clearedCurrent {
3240 // The running agent was using a credential we just wiped. Drop
3241 // it so prompts can't go out with the stale client, and hint at
3242 // /login.
3243 i.agent = nil
3244 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
3245 } else {
3246 i.statusOK = "logged out of " + strings.Join(providers, ", ")
3247 }
3248}
3249
3250func (i *Interactive) startAPIKeyFlow(provider string) {
3251 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
3252 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
3253 }
3254 url, err := i.cfg.AuthManager.StartAPIKey(provider)
3255 if err != nil {
3256 i.dialog.ShowResult(false, err.Error())
3257 return
3258 }
3259 i.dialog.ShowWaiting(url)
3260}
3261
3262func (i *Interactive) startOAuthFlow(provider string) {
3263 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
3264 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
3265 }
3266 // Always run the manual/copy-code flow in parallel with the local
3267 // callback server so headless environments (docker, SSH) can paste
3268 // the authorization code directly without first pressing 'p'.
3269 _, err := i.cfg.AuthManager.StartOAuth(provider)
3270 if err != nil {
3271 i.dialog.ShowResult(false, err.Error())
3272 return
3273 }
3274 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
3275 if mErr == nil {
3276 i.dialog.ShowWaiting(manualURL)
3277 } else {
3278 i.dialog.ShowResult(false, mErr.Error())
3279 }
3280}
3281
3282func (i *Interactive) startManualOAuthFlow(provider string) {
3283 if i.cfg.AuthManager == nil {
3284 return
3285 }
3286 i.cfg.AuthManager.CancelOAuth()
3287 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
3288 if err != nil {
3289 i.dialog.ShowResult(false, err.Error())
3290 return
3291 }
3292 i.dialog.url = url
3293 i.invalidate()
3294}
3295
3296func (i *Interactive) submitManualOAuthCode(code string) {
3297 if i.cfg.AuthManager == nil {
3298 return
3299 }
3300 go func() {
3301 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
3302 i.dialog.ShowResult(false, err.Error())
3303 i.invalidate()
3304 }
3305 }()
3306}
3307
3308// applyModelSelection switches the active model (and provider, if the
3309// new model belongs to a different one). It rebuilds the underlying
3310// client when needed so the provider wire-protocol matches.
3311// cancelAndWaitForIdle cancels the active turn (if any) and blocks
3312// briefly until the turn goroutine has updated i.busy = false. Used
3313// before destructive slash commands so transcript-mutating work
3314// (/clear, /compact, /logout, /login completion, cross-provider
3315// /model swap) doesn't race with the still-running stream.
3316//
3317// The wait is bounded; if the turn doesn't release within the timeout
3318// we proceed anyway. Worst case is a brief overlap that the agent's
3319// own mutex protects against.
3320func (i *Interactive) cancelAndWaitForIdle() {
3321 i.mu.Lock()
3322 busy := i.busy
3323 cancel := i.cancelTurn
3324 i.mu.Unlock()
3325 if !busy {
3326 return
3327 }
3328 if cancel != nil {
3329 cancel()
3330 }
3331 deadline := time.Now().Add(2 * time.Second)
3332 for time.Now().Before(deadline) {
3333 i.mu.Lock()
3334 done := !i.busy
3335 i.mu.Unlock()
3336 if done {
3337 return
3338 }
3339 time.Sleep(10 * time.Millisecond)
3340 }
3341}
3342
3343// openBtwDialog opens the side-chat overlay with a frozen snapshot
3344// of the current main session. The optional argument is auto-
3345// submitted as the first question, so '/btw does X work?' fires the
3346// model call immediately instead of just opening an empty dialog.
3347func (i *Interactive) openBtwDialog(args []string) {
3348 if i.agent == nil {
3349 i.mu.Lock()
3350 i.statusErr = "not logged in. type /login first."
3351 i.mu.Unlock()
3352 return
3353 }
3354 seed := strings.TrimSpace(strings.Join(args, " "))
3355 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)
3356 i.invalidate()
3357}
3358
3359// openSkillsDialog opens the skill inspector. The picker reflects
3360// whatever SkillSnapshot returns at call time, so edits to a
3361// SKILL.md made during a session show up on the next /skills.
3362func (i *Interactive) openSkillsDialog() {
3363 var list []*skills.Skill
3364 if i.cfg.SkillSnapshot != nil {
3365 list = i.cfg.SkillSnapshot()
3366 }
3367 i.skillsDialog.Open(list)
3368 i.invalidate()
3369}
3370
3371// openJumpDialog builds a /jump picker from the current transcript.
3372// If the user typed "/jump foo" with a filter and it matches exactly
3373// one turn, jump there directly without showing the dialog.
3374func (i *Interactive) openJumpDialog(args []string) {
3375 if i.view == nil || len(i.view.Messages) == 0 {
3376 i.mu.Lock()
3377 i.statusErr = "nothing to jump to \u2014 the session is empty"
3378 i.mu.Unlock()
3379 return
3380 }
3381 filter := strings.TrimSpace(strings.Join(args, " "))
3382 i.jumpDialog.Open(i.view.Messages, filter)
3383 // Shortcut: with a filter argument that matches exactly one turn,
3384 // jump immediately and skip the picker.
3385 if filter != "" {
3386 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
3387 t := tgts[0]
3388 i.jumpDialog.Close()
3389 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
3390 }
3391 }
3392}
3393
3394// applyJumpSelection scrolls the chat viewport so the user message at
3395// msgIdx is visible at (or near) the top of the chat area. Uses the
3396// anchor slice returned by view.BuildWithAnchors so the mapping from
3397// message index to row is exact, regardless of variable-height tool
3398// blocks above the target.
3399func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
3400 cols := i.lastCols()
3401 chat, anchors := i.view.BuildWithAnchors(cols)
3402 var row int
3403 found := false
3404 for _, a := range anchors {
3405 if a.MessageIdx == msgIdx {
3406 row = a.Row
3407 found = true
3408 break
3409 }
3410 }
3411 if !found {
3412 i.mu.Lock()
3413 i.statusErr = "could not resolve jump target"
3414 i.mu.Unlock()
3415 return
3416 }
3417
3418 chatLen := len(chat)
3419 page := i.chatPage()
3420 if page < 1 {
3421 page = 1
3422 }
3423 // scrollOffset is measured from the bottom of the chat slice, so
3424 // to place `row` at the top of the viewport we want:
3425 // chatLen - scrollOffset - page == row
3426 // Solve for scrollOffset and clamp to [0, chatLen-page].
3427 offset := chatLen - (row + page)
3428 if offset < 0 {
3429 offset = 0
3430 }
3431 maxOffset := chatLen - page
3432 if maxOffset < 0 {
3433 maxOffset = 0
3434 }
3435 if offset > maxOffset {
3436 offset = maxOffset
3437 }
3438
3439 i.mu.Lock()
3440 i.scrollOffset = offset
3441 i.parkedTurn = turnNo
3442 i.parkedTotal = totalTurnsLocked(i.view.Messages)
3443 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
3444 i.statusErr = ""
3445 i.mu.Unlock()
3446}
3447
3448// totalTurnsLocked counts user messages in the transcript. Caller is
3449// assumed to hold i.mu (the name is a mild reminder; this function
3450// itself doesn't touch shared state beyond the slice it's handed).
3451func totalTurnsLocked(msgs []provider.Message) int {
3452 n := 0
3453 for _, m := range msgs {
3454 if m.Role == provider.RoleUser {
3455 n++
3456 }
3457 }
3458 return n
3459}
3460
3461// applySessionSelection loads the given session via the cli-provided
3462// callback and snaps the viewport to the bottom (the latest message)
3463// so the user lands at the live tail of the resumed conversation.
3464func (i *Interactive) applySessionSelection(path string) {
3465 if i.cfg.LoadSession == nil {
3466 i.mu.Lock()
3467 i.statusErr = "session loading is not wired in this build"
3468 i.mu.Unlock()
3469 return
3470 }
3471 i.mu.Lock()
3472 if i.sessionLoading {
3473 i.statusErr = "already resuming a session"
3474 i.mu.Unlock()
3475 i.invalidate()
3476 return
3477 }
3478 i.sessionLoading = true
3479 i.statusOK = "resuming session: " + path
3480 i.statusErr = ""
3481 i.mu.Unlock()
3482 i.invalidate()
3483
3484 go func() {
3485 err := i.cfg.LoadSession(path)
3486 i.mu.Lock()
3487 defer i.mu.Unlock()
3488 i.sessionLoading = false
3489 if err != nil {
3490 i.statusErr = err.Error()
3491 i.statusOK = ""
3492 i.invalidate()
3493 return
3494 }
3495 i.statusOK = "resumed session: " + path
3496 i.statusErr = ""
3497 i.parkedTurn = 0
3498 i.parkedTotal = 0
3499 i.scrollOffset = 0
3500 i.extNotes = nil
3501 i.view.InvalidateRenderCache()
3502 if i.agent != nil {
3503 i.view.Messages = i.agent.Messages()
3504 i.cumUsage = i.agent.Cost()
3505 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
3506 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
3507 } else {
3508 i.lastCtxInput = 0
3509 }
3510 // Snap to the tail again — the swap brought in a fresh
3511 // transcript whose markdown / chroma cost we don't want
3512 // blocking the redraw.
3513 if len(i.view.Messages) > initialResumeTailLimit {
3514 i.view.TailLimit = initialResumeTailLimit
3515 } else {
3516 i.view.TailLimit = 0
3517 }
3518 }
3519 i.invalidate()
3520 }()
3521}
3522
3523// scrollToLastTurn parks the viewport at the most recent user turn,
3524// or at the top if the transcript has no user messages. Used after
3525// resume so the user lands looking at where they left off.
3526func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
3527 if len(msgs) == 0 {
3528 i.mu.Lock()
3529 i.scrollOffset = 0
3530 i.mu.Unlock()
3531 return
3532 }
3533 // Find the last user message index.
3534 lastUser := -1
3535 turnNo, totalTurns := 0, 0
3536 for idx, m := range msgs {
3537 if m.Role == provider.RoleUser {
3538 totalTurns++
3539 lastUser = idx
3540 }
3541 }
3542 if lastUser < 0 {
3543 i.mu.Lock()
3544 i.scrollOffset = 0
3545 i.mu.Unlock()
3546 return
3547 }
3548 turnNo = totalTurns
3549
3550 cols := i.lastCols()
3551 chat, anchors := i.view.BuildWithAnchors(cols)
3552 var row int
3553 found := false
3554 for _, a := range anchors {
3555 if a.MessageIdx == lastUser {
3556 row = a.Row
3557 found = true
3558 break
3559 }
3560 }
3561 if !found {
3562 i.mu.Lock()
3563 i.scrollOffset = 0
3564 i.mu.Unlock()
3565 return
3566 }
3567
3568 chatLen := len(chat)
3569 page := i.chatPage()
3570 if page < 1 {
3571 page = 1
3572 }
3573 offset := chatLen - (row + page)
3574 if offset < 0 {
3575 offset = 0
3576 }
3577 maxOffset := chatLen - page
3578 if maxOffset < 0 {
3579 maxOffset = 0
3580 }
3581 if offset > maxOffset {
3582 offset = maxOffset
3583 }
3584
3585 i.mu.Lock()
3586 i.scrollOffset = offset
3587 // Mark the parked-turn footer so the user sees "viewing turn N of
3588 // M - pgdn to catch up" — same affordance as /jump. Tells them at
3589 // a glance that they're looking at history, not the live tail.
3590 if offset > 0 {
3591 i.parkedTurn = turnNo
3592 i.parkedTotal = totalTurns
3593 }
3594 i.mu.Unlock()
3595 i.invalidate()
3596}
3597
3598func (i *Interactive) applyModelSelection(prov, model string) {
3599 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
3600}
3601
3602// applyRescueModelSelection is like applyModelSelection but routes
3603// through BuildAgentForRescue so launch-time --api-key / --base-url
3604// overrides are dropped before the new agent is built. Falls back to
3605// the regular builder when the host doesn't wire a rescue builder.
3606func (i *Interactive) applyRescueModelSelection(prov, model string) {
3607 builder := i.cfg.BuildAgentForRescue
3608 if builder == nil {
3609 builder = i.cfg.BuildAgentFor
3610 }
3611 i.swapModel(prov, model, builder, true)
3612}
3613
3614// swapModel applies a /model selection (or a rescue selection) using
3615// the supplied builder. rescue=true tags the success message so the
3616// user can see that launch-time overrides were ignored.
3617func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
3618 if model == "" {
3619 return
3620 }
3621 m, err := provider.FindModel(prov, model)
3622 if err != nil {
3623 i.mu.Lock()
3624 i.statusErr = err.Error()
3625 i.mu.Unlock()
3626 return
3627 }
3628 // Same provider AND not a rescue retry: just swap the model on
3629 // the existing agent — no rebuild needed because the underlying
3630 // client is reusable. Rescue retries always rebuild so a stale
3631 // auth header / base URL can't carry over.
3632 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
3633 i.mu.Lock()
3634 i.cfg.Model = m.ID
3635 i.agent.Model = m.ID
3636 i.statusOK = "model: " + m.ID
3637 i.statusErr = ""
3638 i.mu.Unlock()
3639 if i.cfg.PersistModel != nil {
3640 i.cfg.PersistModel(i.cfg.Provider, m.ID)
3641 }
3642 return
3643 }
3644 if builder == nil {
3645 i.mu.Lock()
3646 i.statusErr = "cannot switch provider: no builder configured"
3647 i.mu.Unlock()
3648 return
3649 }
3650 // Snapshot the current transcript and cumulative usage BEFORE we
3651 // build the replacement agent so we can hand them off. Without
3652 // this the user perceives the entire session as wiped on a
3653 // cross-provider /model swap.
3654 var carryMsgs []provider.Message
3655 var carryCost provider.Usage
3656 if i.agent != nil {
3657 carryMsgs = i.agent.Messages()
3658 carryCost = i.agent.Cost()
3659 }
3660
3661 ag, p, md, err := builder(m.Provider, m.ID)
3662 if err != nil {
3663 i.mu.Lock()
3664 i.statusErr = err.Error()
3665 i.mu.Unlock()
3666 return
3667 }
3668
3669 // Replay the transcript and seed the cost on the freshly-built
3670 // agent. Messages travel cleanly between providers because they
3671 // use the same provider.Message shape; tool-call ids are local
3672 // to a turn so cross-provider continuation never confuses the
3673 // new model (it just sees the assistant's reply, no orphan
3674 // tool_use blocks because /model swaps are gated to idle state).
3675 if len(carryMsgs) > 0 {
3676 ag.SetMessages(carryMsgs)
3677 }
3678 ag.SeedCost(carryCost)
3679
3680 i.mu.Lock()
3681 i.agent = ag
3682 i.cfg.Provider = p
3683 i.cfg.Model = md
3684 if rescue {
3685 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
3686 } else {
3687 i.statusOK = "switched to " + p + " / " + md
3688 }
3689 i.statusErr = ""
3690 // Render cache keys are width+content based, so the new agent's
3691 // identical messages will reuse the existing entries. Nothing
3692 // to invalidate.
3693 i.mu.Unlock()
3694 // The new agent was built off the base tool registry, so any
3695 // dynamically-registered tools (telegram_send_*) need to be
3696 // reattached. applyTelegramTools is a no-op when the bridge is
3697 // idle so the cross-provider path still works on a vanilla setup.
3698 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3699 if i.cfg.PersistModel != nil {
3700 i.cfg.PersistModel(p, md)
3701 }
3702}
3703
3704func (i *Interactive) handleAuthEvent(ev auth.Event) {
3705 switch ev.Kind {
3706 case "started":
3707 i.dialog.ShowWaiting(ev.URL)
3708 case "browser_open":
3709 // no-op
3710 case "error":
3711 i.dialog.ShowResult(false, ev.Message)
3712 case "success":
3713 // Rebuild the agent with the fresh credential.
3714 ag, prov, model, err := i.cfg.BuildAgent()
3715 if err != nil {
3716 i.dialog.ShowResult(false, err.Error())
3717 return
3718 }
3719 i.mu.Lock()
3720 i.agent = ag
3721 i.cfg.Provider = prov
3722 i.cfg.Model = model
3723 i.statusErr = ""
3724 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
3725 i.mu.Unlock()
3726 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
3727 i.dialog.ShowResult(true, "")
3728 }
3729}
3730
3731// runCompact invokes core.Agent.Compact and reflects the progress in
3732// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
3733// cancel via the same cancelTurn channel used for normal turns.
3734//
3735// When auto is true the spinner message is pinned to "condensing
3736// history" and the status bar surfaces "(auto)" next to the context
3737// percentage so it's obvious the system triggered this, not the user.
3738func (i *Interactive) runCompact(parent context.Context, auto bool) {
3739 if i.agent == nil {
3740 i.mu.Lock()
3741 i.statusErr = "not logged in. type /login first."
3742 i.mu.Unlock()
3743 return
3744 }
3745 ctx, cancel := context.WithCancel(parent)
3746 i.mu.Lock()
3747 i.busy = true
3748 if auto {
3749 i.spin.StartFixed("condensing history")
3750 i.autoCompacting = true
3751 } else {
3752 i.spin.StartFixed("compacting")
3753 }
3754 i.cancelTurn = cancel
3755 i.statusErr = ""
3756 i.statusOK = ""
3757 // Do NOT set streamOn: the summary text should not be visible
3758 // in the chat while compacting. The user just sees the spinner
3759 // and can keep typing / queue prompts.
3760 i.scrollOffset = 0
3761 i.helpBlock = nil
3762 i.mu.Unlock()
3763 i.invalidate()
3764
3765 go func() {
3766 // Sink discards deltas — we don't stream the summary to the UI.
3767 sink := func(delta string) {}
3768 summary, err := i.agent.Compact(ctx, 4, sink)
3769 _ = summary
3770 i.mu.Lock()
3771 i.busy = false
3772 i.resetStreamingStateLocked()
3773 i.cancelTurn = nil
3774 i.autoCompacting = false
3775
3776 // Drain the queue: if the user typed a prompt while compacting,
3777 // fire it now that the transcript is clean.
3778 var next string
3779 var hasNext bool
3780
3781 switch {
3782 case err != nil && ctx.Err() != nil:
3783 i.statusErr = ""
3784 if auto {
3785 i.statusOK = "auto-condense cancelled"
3786 } else {
3787 i.statusOK = "compaction cancelled"
3788 }
3789 i.queued = nil // drop queue on cancel
3790 if i.agent != nil {
3791 i.agent.DrainQueuedMessages()
3792 }
3793 case err != nil:
3794 i.statusErr = "compaction failed: " + err.Error()
3795 i.statusOK = ""
3796 i.queued = nil // drop queue on error
3797 if i.agent != nil {
3798 i.agent.DrainQueuedMessages()
3799 }
3800 default:
3801 i.statusErr = ""
3802 // Read token count from the compaction message meta.
3803 tokens := ""
3804 msgs := i.agent.Messages()
3805 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
3806 tokens = msgs[0].Meta["tokens_before"]
3807 }
3808 switch {
3809 case i.pendingPostCompactNote != "":
3810 i.statusOK = i.pendingPostCompactNote
3811 case tokens != "":
3812 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
3813 default:
3814 i.statusOK = "compacted (ctrl+o to expand)"
3815 }
3816 i.pendingPostCompactNote = ""
3817 i.extNotes = stripAutoCompactNotes(i.extNotes)
3818 i.lastCtxInput = 0
3819 i.toolCalls = map[string]*tui.ToolCallView{}
3820 i.toolOrder = nil
3821 i.view.InvalidateRenderCache()
3822 // Pop queued prompt if any.
3823 if len(i.queued) > 0 {
3824 next, i.queued = i.queued[0], i.queued[1:]
3825 hasNext = true
3826 }
3827 }
3828 i.mu.Unlock()
3829 i.invalidate()
3830
3831 if hasNext {
3832 p := i.runCtx
3833 if p == nil {
3834 p = context.Background()
3835 }
3836 i.startTurn(p, next)
3837 }
3838 }()
3839}
3840
3841func (i *Interactive) startTurn(parent context.Context, prompt string) {
3842 i.startTurnWithImages(parent, prompt, nil)
3843}
3844
3845func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
3846 if i.agent == nil {
3847 return
3848 }
3849 // Force a full repaint when a new turn begins so any stray dialog,
3850 // popup, or stale tool-progress rows don't leak into the visible
3851 // chat area before the assistant starts streaming. Equivalent to
3852 // the user pressing ctrl+l right before submit.
3853 if i.rend != nil {
3854 i.rend.Clear()
3855 }
3856 // Pre-turn safety: if the most recent context measurement is
3857 // already past the auto-compact threshold, condense before
3858 // sending so the next outbound request stays under the limit.
3859 // The condense flow re-fires the user's queued prompt for us, so
3860 // we just hand it off and exit.
3861 i.mu.Lock()
3862 needsPreCompact := !i.autoCompacting && i.shouldAutoCompactLocked()
3863 if needsPreCompact {
3864 if prompt != "" {
3865 i.queued = append([]string{prompt}, i.queued...)
3866 }
3867 i.statusErr = ""
3868 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
3869 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
3870 i.mu.Unlock()
3871 i.invalidate()
3872 i.runCompact(parent, true)
3873 return
3874 }
3875 i.mu.Unlock()
3876
3877 ctx, cancel := context.WithCancel(parent)
3878 i.mu.Lock()
3879 i.busy = true
3880 i.spin.Start()
3881 i.cancelTurn = cancel
3882 i.statusErr = ""
3883 i.statusOK = ""
3884 i.streaming.Reset()
3885 i.streamOn = true
3886 i.toolCalls = map[string]*tui.ToolCallView{}
3887 i.toolOrder = nil
3888 i.scrollOffset = 0 // jump back to the bottom on new turn
3889 // Reset the auto-follow baseline so the very next render at
3890 // interactive.go:1053 doesn't see a synthetic shrink between
3891 // "last frame had the previous turn's tool overlay" and
3892 // "this frame had it cleared above". Without this, the guard
3893 // reads delta = -(rows in cleared overlay) and decrements
3894 // scrollOffset, which on terminals that mirror zot's pane
3895 // scroll into the host scrollbar visibly yanks the viewport.
3896 // See autofollow_shrink_test.go for the exact arithmetic.
3897 i.prevChatLen = 0
3898 i.prevChatCols = 0
3899 i.parkedTurn = 0 // starting a turn clears the /jump parked state
3900 i.parkedTotal = 0
3901 i.helpBlock = nil // hide the help block once the user asks something
3902 i.mu.Unlock()
3903 i.invalidate()
3904
3905 sink := func(ev core.AgentEvent) {
3906 i.handleEvent(ev)
3907 i.invalidate()
3908 }
3909
3910 go func() {
3911 err := i.agent.Prompt(ctx, prompt, images, sink)
3912 i.mu.Lock()
3913 i.busy = false
3914 // Don't touch streamPending / streamFlushPending here — the
3915 // pacer may still be draining the final deltas and needs to
3916 // paint them even though Prompt has returned. It will reset
3917 // streamOn on its own once the buffer empties.
3918 if len(i.streamPending) == 0 {
3919 i.streamOn = false
3920 }
3921 i.cancelTurn = nil
3922 if err != nil && ctx.Err() == nil {
3923 i.statusErr = err.Error()
3924 }
3925 // Decide whether to offer a model rescue picker for recoverable
3926 // provider failures (auth/rate/temporary). The picker opens after
3927 // the mutex is released so it can take its own locks freely.
3928 var (
3929 offer bool
3930 rescueWhy string
3931 rescueImgs []provider.ImageBlock
3932 rescueModel string
3933 rescueProv string
3934 rescueFprov string
3935 )
3936 if err != nil && ctx.Err() == nil {
3937 if ok, reason := classifyRescueError(err); ok {
3938 offer = true
3939 rescueWhy = reason
3940 rescueImgs = images
3941 rescueModel = i.cfg.Model
3942 rescueProv = i.cfg.Provider
3943 rescueFprov = extractFailedProvider(err)
3944 if rescueFprov == "" {
3945 rescueFprov = i.cfg.Provider
3946 }
3947 // Suppress the red banner — the rescue dialog already
3948 // surfaces the failure.
3949 i.statusErr = ""
3950 }
3951 }
3952 // Detect HTTP 413 "payload too large" responses. The provider
3953 // rejected the request because the request body exceeded its
3954 // per-request limit. Token-based auto-compact can miss this
3955 // because the limit is on raw bytes, not tokens. Re-queue the
3956 // prompt so it survives the condense pass and trigger one.
3957 payloadTooLarge := err != nil && ctx.Err() == nil && isPayloadTooLargeError(err)
3958 if payloadTooLarge {
3959 i.statusErr = ""
3960 i.queued = append([]string{prompt}, i.queued...)
3961 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
3962 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
3963 }
3964 // Persist the assistant's reply (and every tool row before
3965 // it) to the session file while the turn memory is hot.
3966 // Without this, WriteNewTranscript only fires at zot exit,
3967 // meaning a crash or ungraceful kill drops the whole
3968 // conversation. FlushSession is idempotent (it advances the
3969 // baseline so subsequent flushes only write new rows).
3970 flush := i.cfg.FlushSession
3971 i.mu.Unlock()
3972 if flush != nil {
3973 flush()
3974 }
3975 i.mu.Lock()
3976 // Pop the next queued message, if any, and relaunch.
3977 var next string
3978 var hasNext bool
3979 if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
3980 next, i.queued = i.queued[0], i.queued[1:]
3981 hasNext = true
3982 }
3983 // If the turn was cancelled or errored, drop the queue so the
3984 // user isn't bombarded with stale messages after an interrupt.
3985 if ctx.Err() != nil || err != nil {
3986 i.queued = nil
3987 if i.agent != nil {
3988 i.agent.DrainQueuedMessages()
3989 }
3990 }
3991 // Decide whether the next thing to do is an auto-compaction.
3992 // Only fires when the turn completed cleanly AND no host-side
3993 // or agent-side queued messages are waiting (otherwise a queued
3994 // message would race the condense).
3995 agentQueued := 0
3996 if i.agent != nil {
3997 agentQueued = i.agent.QueuedMessageCount()
3998 }
3999 shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
4000 i.mu.Unlock()
4001 i.invalidate()
4002 parent := i.runCtx
4003 if parent == nil {
4004 parent = context.Background()
4005 }
4006 switch {
4007 case hasNext:
4008 i.startTurn(parent, next)
4009 case offer:
4010 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
4011 case payloadTooLarge:
4012 i.runCompact(parent, true)
4013 case shouldAutoCompact:
4014 i.runCompact(parent, true)
4015 }
4016 }()
4017}
4018
4019// openRescueDialog surfaces the rescue model picker after a recoverable
4020// provider failure. The pending prompt + images are stashed on the
4021// Interactive so a later applyRescueSelection can re-run the same turn
4022// against the freshly-picked model. activeProvider/failedProvider are
4023// usually the same, but some clients embed different prefixes in their
4024// errors than the configured provider id, so we accept both.
4025func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
4026 if i.rescueDialog == nil {
4027 return
4028 }
4029 loggedIn := []string{}
4030 if i.cfg.LoggedInProviders != nil {
4031 loggedIn = i.cfg.LoggedInProviders()
4032 }
4033 fprov := failedProvider
4034 if fprov == "" {
4035 fprov = activeProvider
4036 }
4037 i.mu.Lock()
4038 i.pendingRescuePrompt = prompt
4039 i.pendingRescueImages = images
4040 i.mu.Unlock()
4041 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
4042 i.invalidate()
4043}
4044
4045// applyRescueSelection switches model (cross-provider if needed) and
4046// re-runs the same prompt+images that just failed. Mirrors
4047// applyModelSelection's transcript-carry logic so the user keeps full
4048// session continuity across the swap.
4049func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
4050 if model == "" {
4051 return
4052 }
4053 i.applyRescueModelSelection(prov, model)
4054 i.mu.Lock()
4055 images := i.pendingRescueImages
4056 if prompt == "" {
4057 prompt = i.pendingRescuePrompt
4058 }
4059 i.pendingRescuePrompt = ""
4060 i.pendingRescueImages = nil
4061 i.mu.Unlock()
4062 parent := i.runCtx
4063 if parent == nil {
4064 parent = context.Background()
4065 }
4066 i.startTurnWithImages(parent, prompt, images)
4067}
4068
4069func stripAutoCompactNotes(notes []string) []string {
4070 if len(notes) == 0 {
4071 return notes
4072 }
4073 out := notes[:0]
4074 for _, n := range notes {
4075 if strings.Contains(n, "condensing history") {
4076 continue
4077 }
4078 out = append(out, n)
4079 }
4080 return out
4081}
4082
4083// autoCompactNoteLine returns a styled chat-area note for the
4084// inline auto-compact heads-up. Lives in extNotes so it survives
4085// the busy-spinner overwrite of the status row.
4086func autoCompactNoteLine(th tui.Theme, msg string) string {
4087 return " " + th.FG256(th.Warning, "⚠ "+msg)
4088}
4089
4090// isPayloadTooLargeError matches HTTP 413 responses surfaced by the
4091// provider clients. The error formatting differs slightly between
4092// providers (anthropic and openai both prepend the status code), so
4093// we look for the canonical 413 marker as well as the conventional
4094// 'payload too large' phrase.
4095func isPayloadTooLargeError(err error) bool {
4096 if err == nil {
4097 return false
4098 }
4099 msg := strings.ToLower(err.Error())
4100 return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large")
4101}
4102
4103// autoCompactThreshold is the context-window fraction at which the
4104// agent will auto-compact after a turn ends. 0.85 leaves enough
4105// headroom for one more user prompt + response before we bump the
4106// hard limit.
4107const autoCompactThreshold = 0.85
4108
4109// shouldAutoCompactLocked reports whether the last turn pushed context
4110// usage past the auto-compact threshold. Must be called with i.mu
4111// held; it reads lastCtxInput and the current model's context window.
4112func (i *Interactive) shouldAutoCompactLocked() bool {
4113 if i.agent == nil {
4114 return false
4115 }
4116 if i.autoCompacting {
4117 return false
4118 }
4119 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
4120 if err != nil || m.ContextWindow <= 0 {
4121 return false
4122 }
4123 if i.lastCtxInput <= 0 {
4124 return false
4125 }
4126 return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
4127}
4128
4129func (i *Interactive) handleEvent(ev core.AgentEvent) {
4130 i.mu.Lock()
4131 defer i.mu.Unlock()
4132 switch e := ev.(type) {
4133 case core.EvAssistantStart:
4134 // Fires at the top of every oneTurn, including follow-up
4135 // turns after tool use. Without this, the streaming buffer
4136 // is still marked off from the previous assistant message
4137 // and the final summary text pops in all at once instead
4138 // of typewriter-streaming delta by delta.
4139 i.streaming.Reset()
4140 i.streamPending = i.streamPending[:0]
4141 i.streamFlushPending = false
4142 i.streamOn = true
4143 // Clear the live tool-call overlay. Any tools from the
4144 // previous round are now fully folded into the transcript
4145 // (assistant tool_use block + tool role message with the
4146 // result), so keeping them in the overlay would duplicate
4147 // them in the view — once inside the finalised transcript
4148 // and once below the streaming block, with the streaming
4149 // summary sandwiched in between. The next EvToolUseStart
4150 // will populate fresh entries for this turn's tools.
4151 i.toolCalls = map[string]*tui.ToolCallView{}
4152 i.toolOrder = nil
4153 case core.EvTextDelta:
4154 // Buffer into streamPending; the paintPace ticker drains
4155 // it into i.streaming a few runes at a time for a smooth
4156 // typewriter effect independent of upstream chunk size.
4157 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
4158 i.streamOn = true
4159 case core.EvAssistantMessage:
4160 // OnAssistant + telegram mirroring always fire on message
4161 // arrival — they read the FINAL message content, which is
4162 // complete regardless of what's still in the pacer.
4163 i.assistantMessageSideEffects(e.Message)
4164 // If the pacer still has characters to drain, keep streamOn
4165 // true and mark flush pending; the paintPace ticker will
4166 // drain the remainder and reset streaming state when done.
4167 // Otherwise (rare: full-replay sessions, abort paths) clear
4168 // synchronously so a later render doesn't show stale text.
4169 if len(i.streamPending) > 0 {
4170 i.streamFlushPending = true
4171 return
4172 }
4173 i.resetStreamingStateLocked()
4174 case core.EvToolUseStart:
4175 // Live streaming: pre-create the view so the user sees the
4176 // tool call being composed in real time. Any subsequent
4177 // EvToolCall for the same ID updates the same struct (the
4178 // final parsed args + name are already known here).
4179 if _, exists := i.toolCalls[e.ID]; !exists {
4180 i.toolCalls[e.ID] = &tui.ToolCallView{
4181 ID: e.ID,
4182 Name: e.Name,
4183 Streaming: true,
4184 }
4185 i.toolOrder = append(i.toolOrder, e.ID)
4186 }
4187 case core.EvToolUseArgs:
4188 if tc, ok := i.toolCalls[e.ID]; ok {
4189 tc.RawJSONBuf += e.Delta
4190 // Refresh the live path as soon as it parses; used in
4191 // the header (write /Users/pat/Desktop/demo.ts)
4192 // while the content is still streaming.
4193 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
4194 tc.LivePath = p
4195 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
4196 tc.LivePath = p
4197 }
4198 }
4199 case core.EvToolUseEnd:
4200 if tc, ok := i.toolCalls[e.ID]; ok {
4201 tc.Streaming = false
4202 }
4203 case core.EvToolCall:
4204 // If we already pre-created the view during streaming, just
4205 // refresh the final Args summary. Otherwise create a new one
4206 // (non-streaming providers or legacy paths).
4207 if tc, ok := i.toolCalls[e.ID]; ok {
4208 tc.Args = tui.ShortArgs(e.Name, e.Args)
4209 tc.Streaming = false
4210 } else {
4211 i.toolCalls[e.ID] = &tui.ToolCallView{
4212 ID: e.ID,
4213 Name: e.Name,
4214 Args: tui.ShortArgs(e.Name, e.Args),
4215 }
4216 i.toolOrder = append(i.toolOrder, e.ID)
4217 }
4218 case core.EvToolResult:
4219 if tc, ok := i.toolCalls[e.ID]; ok {
4220 tc.Done = true
4221 tc.Error = e.Result.IsError
4222 var text strings.Builder
4223 for _, c := range e.Result.Content {
4224 if tb, ok := c.(provider.TextBlock); ok {
4225 if text.Len() > 0 {
4226 text.WriteString("\n")
4227 }
4228 text.WriteString(tb.Text)
4229 }
4230 }
4231 tc.Result = text.String()
4232 }
4233 if i.cfg.OnToolResult != nil {
4234 i.cfg.OnToolResult(e.ID, e.Result)
4235 }
4236 case core.EvUsage:
4237 i.cumUsage = e.Cumulative
4238 if e.Usage.InputTokens > 0 {
4239 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
4240 }
4241 case core.EvTurnEnd:
4242 if e.Stop == provider.StopAborted {
4243 i.resetStreamingStateLocked()
4244 i.statusErr = ""
4245 i.statusOK = "cancelled"
4246 return
4247 }
4248 // Don't surface mid-loop stream errors as a red banner here.
4249 // EvTurnEnd fires after every step in a multi-step tool loop,
4250 // so a transient 503 / network blip would briefly paint a red
4251 // banner over the still-streaming chat before the agent loop
4252 // either retries or exits. The final error (if any) is set by
4253 // startTurnWithImages once Prompt() returns, and recoverable
4254 // failures are routed to the rescue picker instead — which
4255 // keeps the chat clean while the agent is working.
4256 _ = e.Err
4257 }
4258}
4259
4260// Agent returns the current agent, if any. Used by cli.go to flush the
4261// final transcript to the session file.
4262func (i *Interactive) Agent() *core.Agent {
4263 i.mu.Lock()
4264 defer i.mu.Unlock()
4265 return i.agent
4266}
4267
4268// silence unused import in some build configs
4269var _ = fmt.Sprintf
4270
4271// runReloadExt triggers a live reload of every extension (discovered
4272// + explicit). Runs on a goroutine so the TUI stays responsive; the
4273// Manager.Reload takes a couple of hundred ms to shut down subprocs
4274// and respawn them. Shows a status line throughout.
4275func (i *Interactive) runReloadExt(ctx context.Context) {
4276 if i.cfg.Extensions == nil {
4277 i.mu.Lock()
4278 i.statusErr = "no extension manager in this build"
4279 i.mu.Unlock()
4280 i.invalidate()
4281 return
4282 }
4283 i.mu.Lock()
4284 i.statusOK = "reloading extensions..."
4285 i.statusErr = ""
4286 i.mu.Unlock()
4287 i.invalidate()
4288
4289 go func() {
4290 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
4291 msg := fmt.Sprintf("reloaded: %d stopped, %d loaded (%d ready)", stats.Stopped, stats.Loaded, stats.Ready)
4292 if len(stats.Errors) > 0 {
4293 msg += fmt.Sprintf(", %d error(s)", len(stats.Errors))
4294 }
4295 i.mu.Lock()
4296 i.statusOK = msg
4297 i.statusErr = ""
4298 i.mu.Unlock()
4299 i.invalidate()
4300 }()
4301}
4302
4303// Confirm implements core.Confirmer. The agent goroutine calls
4304// this synchronously before every tool invocation when --no-yolo is
4305// active. We push the request onto the confirmDialog queue, trigger
4306// a redraw, and block the caller until the user answers.
4307//
4308// If the session is cancelled or the TUI exits mid-prompt, any
4309// pending request is refused via CancelAll so the agent doesn't
4310// deadlock.
4311func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
4312 resp := make(chan core.ConfirmDecision, 1)
4313 i.confirmDialog.Enqueue(&confirmRequest{
4314 toolName: toolName,
4315 preview: preview,
4316 resp: resp,
4317 })
4318 i.invalidate()
4319 return <-resp
4320}
4321
4322// openTelegramDialog shows the picker for `/telegram` with no arg.
4323// Items depend on current state: disconnect + status when running,
4324// connect + status when stopped.
4325func (i *Interactive) openTelegramDialog() {
4326 items := i.telegramMenuItems()
4327 if len(items) == 0 {
4328 i.mu.Lock()
4329 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
4330 i.mu.Unlock()
4331 i.invalidate()
4332 return
4333 }
4334 i.telegramDialog.Open(items)
4335 i.invalidate()
4336}
4337
4338// telegramMenuItems builds the dialog entries for the current
4339// bridge state. Returns empty when no bot.json exists so the
4340// caller can show a helpful status line instead of an empty menu.
4341func (i *Interactive) telegramMenuItems() []telegramItem {
4342 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
4343 if err != nil || cfg.BotToken == "" {
4344 return nil
4345 }
4346 var items []telegramItem
4347 if i.telegramBridge != nil && i.telegramBridge.Active() {
4348 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
4349 st := i.telegramBridge.State()
4350 hint := "active"
4351 if st.Username != "" {
4352 hint += " as @" + st.Username
4353 }
4354 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
4355 } else {
4356 label := "connect"
4357 hint := "start mirroring dms into this session"
4358 if cfg.AllowedUserID == 0 {
4359 hint = "awaiting pairing (send /start to the bot once connected)"
4360 }
4361 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
4362 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
4363 }
4364 return items
4365}
4366
4367// doTelegram dispatches one of the three explicit actions. Called
4368// from /telegram <action> or after the picker selects a row.
4369func (i *Interactive) doTelegram(action string) {
4370 switch action {
4371 case "connect":
4372 i.telegramConnect()
4373 case "disconnect":
4374 i.telegramDisconnect()
4375 case "status":
4376 i.telegramStatus()
4377 default:
4378 i.mu.Lock()
4379 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
4380 i.mu.Unlock()
4381 i.invalidate()
4382 }
4383}
4384
4385// telegramConnect starts the bridge. Refuses if it's already
4386// running or if the on-disk bot.json is missing a token.
4387func (i *Interactive) telegramConnect() {
4388 if i.telegramBridge != nil && i.telegramBridge.Active() {
4389 i.mu.Lock()
4390 i.statusOK = "telegram already connected"
4391 i.statusErr = ""
4392 i.mu.Unlock()
4393 i.invalidate()
4394 return
4395 }
4396 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
4397 if err != nil {
4398 i.mu.Lock()
4399 i.statusErr = "telegram: " + err.Error()
4400 i.mu.Unlock()
4401 i.invalidate()
4402 return
4403 }
4404 if cfg.BotToken == "" {
4405 i.mu.Lock()
4406 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
4407 i.mu.Unlock()
4408 i.invalidate()
4409 return
4410 }
4411 // Refuse to start when a background daemon is already polling
4412 // the same bot. Two concurrent long-poll consumers race each
4413 // update and one always loses, so DMs get half-delivered. The
4414 // user can `zot telegram-bot stop` first, then /telegram connect.
4415 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
4416 i.mu.Lock()
4417 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
4418 i.mu.Unlock()
4419 i.invalidate()
4420 return
4421 }
4422 i.telegramBridge = &telegram.Bridge{
4423 Client: telegram.NewClient(cfg.BotToken),
4424 Config: cfg,
4425 Save: func(next telegram.Config) error {
4426 return telegram.SaveConfig(i.cfg.ZotHome, next)
4427 },
4428 Host: &telegramHost{iv: i},
4429 }
4430 if err := i.telegramBridge.Start(i.runCtx); err != nil {
4431 i.telegramBridge = nil
4432 i.mu.Lock()
4433 i.statusErr = "telegram connect failed: " + err.Error()
4434 i.mu.Unlock()
4435 i.invalidate()
4436 return
4437 }
4438 i.applyTelegramTools(true)
4439 state := i.telegramBridge.State()
4440 label := "telegram connected"
4441 if state.Username != "" {
4442 label += " as @" + state.Username
4443 }
4444 if state.PairedID == 0 {
4445 label += " — send /start to the bot from your phone to claim it"
4446 }
4447 i.mu.Lock()
4448 i.statusOK = label
4449 i.statusErr = ""
4450 i.mu.Unlock()
4451 i.invalidate()
4452}
4453
4454// telegramDisconnect stops the bridge. No-op when already stopped.
4455func (i *Interactive) telegramDisconnect() {
4456 if i.telegramBridge == nil || !i.telegramBridge.Active() {
4457 i.mu.Lock()
4458 i.statusOK = "telegram already disconnected"
4459 i.statusErr = ""
4460 i.mu.Unlock()
4461 i.invalidate()
4462 return
4463 }
4464 i.telegramBridge.Stop()
4465 i.applyTelegramTools(false)
4466 i.mu.Lock()
4467 i.statusOK = "telegram disconnected"
4468 i.statusErr = ""
4469 i.mu.Unlock()
4470 i.invalidate()
4471}
4472
4473// telegramSenderAdapter wraps the bridge so the tools package can
4474// drive it without importing telegram directly. The Active() check
4475// is forwarded to the bridge so the tool can fail clearly with a
4476// model-readable error when the user disconnected mid-turn.
4477type telegramSenderAdapter struct {
4478 bridge *telegram.Bridge
4479}
4480
4481func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
4482 if a.bridge == nil {
4483 return fmt.Errorf("telegram bridge is not connected")
4484 }
4485 return a.bridge.SendImage(ctx, path, caption)
4486}
4487
4488func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
4489 if a.bridge == nil {
4490 return fmt.Errorf("telegram bridge is not connected")
4491 }
4492 return a.bridge.SendDocument(ctx, path, caption)
4493}
4494
4495func (a telegramSenderAdapter) Active() bool {
4496 return a.bridge != nil && a.bridge.Active()
4497}
4498
4499// swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at
4500// spawn time and finalised in trackSwarmAgent's waiter goroutine.
4501type swarmWatchEntry struct {
4502 agent *swarm.Agent
4503 task string
4504 done bool
4505 err string
4506}
4507
4508// TrackSwarmAgent is the exported entry point used by the cli to
4509// hand a freshly-spawned auto-swarm agent off to the watcher.
4510func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
4511 i.trackSwarmAgent(a, task)
4512}
4513
4514// trackSwarmAgent records a freshly-spawned auto-swarm agent and
4515// subscribes to its turn_end events. Sub-agents are long-lived
4516// daemons that keep running on the inbox after the initial task,
4517// so we can't wait on agent.Wait() — it never returns until the
4518// whole daemon dies. Instead we mark each entry done on its first
4519// turn_end (the initial task finishing), and when every tracked
4520// entry has reported in, flush a single summary into the main chat.
4521//
4522// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
4523// swarm is enabled, so this is a no-op when the feature is off.
4524func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
4525 if i == nil || a == nil {
4526 return
4527 }
4528 entry := &swarmWatchEntry{agent: a, task: task}
4529 i.swarmWatchMu.Lock()
4530 i.swarmWatch = append(i.swarmWatch, entry)
4531 i.swarmWatchMu.Unlock()
4532
4533 a.SetOnTurnEnd(func(step int, errMsg string) {
4534 i.swarmWatchMu.Lock()
4535 if entry.done {
4536 i.swarmWatchMu.Unlock()
4537 return
4538 }
4539 entry.done = true
4540 entry.err = errMsg
4541 allDone := true
4542 for _, e := range i.swarmWatch {
4543 if !e.done {
4544 allDone = false
4545 break
4546 }
4547 }
4548 var batch []*swarmWatchEntry
4549 if allDone {
4550 batch = i.swarmWatch
4551 i.swarmWatch = nil
4552 }
4553 i.swarmWatchMu.Unlock()
4554 if len(batch) == 0 {
4555 return
4556 }
4557 i.flushSwarmSummary(batch)
4558 })
4559}
4560
4561// flushSwarmSummary composes a synthetic user turn describing every
4562// sub-agent's outcome and injects it via SubmitOrQueue so the main
4563// agent picks it up at the next safe boundary. The summary is
4564// phrased as a system update ("Auto-swarm finished: ...") so the
4565// model treats it as observed state, not as a fresh user request.
4566func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
4567 if len(batch) == 0 {
4568 return
4569 }
4570 var sb strings.Builder
4571 fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
4572 for idx, e := range batch {
4573 snap := e.agent.Snapshot()
4574 status := string(snap.Status)
4575 task := snap.Task
4576 if task == "" {
4577 task = e.task
4578 }
4579 fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status)
4580 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
4581 if snap.Err != "" {
4582 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
4583 } else if e.err != "" {
4584 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
4585 }
4586 if tail := strings.TrimSpace(snap.Tail); tail != "" {
4587 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
4588 }
4589 sb.WriteString("\n")
4590 }
4591 sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
4592 i.SubmitOrQueue(sb.String(), nil)
4593}
4594
4595func truncateForSummary(s string, n int) string {
4596 s = strings.TrimSpace(s)
4597 if len(s) <= n {
4598 return s
4599 }
4600 return s[:n-3] + "..."
4601}
4602
4603// applyAutoSwarmSystemPrompt appends (active=true) or strips
4604// (active=false) the auto-swarm system-prompt block on the running
4605// agent so the model proactively considers swarm_spawn when the user
4606// flips the toggle. The block lives at the tail of agent.System so
4607// stripping is a plain suffix-trim; idempotent in both directions.
4608func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
4609 if i.agent == nil {
4610 return
4611 }
4612 addendum := i.cfg.AutoSwarmSystemAddendum
4613 if addendum == "" {
4614 return
4615 }
4616 sys := i.agent.System
4617 has := strings.Contains(sys, addendum)
4618 switch {
4619 case active && !has:
4620 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
4621 sys += "\n\n"
4622 }
4623 i.agent.System = sys + addendum
4624 case !active && has:
4625 i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
4626 }
4627}
4628
4629// applyAutoSwarmTool registers (active=true) or removes (active=false)
4630// the swarm_spawn tool on the running agent so the model only sees it
4631// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
4632// snapshot+mutate pattern so extension tools and /reload-ext additions
4633// survive a toggle.
4634func (i *Interactive) applyAutoSwarmTool(active bool) {
4635 if i.agent == nil {
4636 return
4637 }
4638 current := i.agent.Tools
4639 next := core.Registry{}
4640 for name, t := range current {
4641 if name == "swarm_spawn" {
4642 continue
4643 }
4644 next[name] = t
4645 }
4646 if active && i.cfg.Swarm != nil {
4647 next["swarm_spawn"] = &tools.SwarmSpawnTool{
4648 Swarm: i.cfg.Swarm,
4649 Enabled: func() bool { return true },
4650 OnSpawned: i.trackSwarmAgent,
4651 }
4652 }
4653 i.agent.SetTools(next)
4654}
4655
4656// applyTelegramTools registers (active=true) or removes (active=false)
4657// the telegram_send_image and telegram_send_file tools on the running
4658// agent so the model only sees them while the bridge is connected.
4659// Snapshots and mutates the live tool registry so any extension or
4660// /reload-ext additions made while Telegram is connected survive a
4661// later /telegram disconnect (we only add or strip the two telegram
4662// entries, never the rest).
4663func (i *Interactive) applyTelegramTools(active bool) {
4664 if i.agent == nil {
4665 return
4666 }
4667 current := i.agent.Tools
4668 next := core.Registry{}
4669 for name, t := range current {
4670 if name == "telegram_send_image" || name == "telegram_send_file" {
4671 continue
4672 }
4673 next[name] = t
4674 }
4675 if active {
4676 sender := telegramSenderAdapter{bridge: i.telegramBridge}
4677 next["telegram_send_image"] = &tools.TelegramSendImageTool{
4678 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
4679 }
4680 next["telegram_send_file"] = &tools.TelegramSendFileTool{
4681 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
4682 }
4683 }
4684 i.agent.SetTools(next)
4685}
4686
4687// telegramStatus writes a one-liner describing the bridge state.
4688// Reports on both the in-tui bridge and the background daemon so
4689// the user isn't confused when the daemon owns the poll loop.
4690func (i *Interactive) telegramStatus() {
4691 var msg string
4692 if i.telegramBridge != nil && i.telegramBridge.Active() {
4693 s := i.telegramBridge.State()
4694 msg = "telegram: connected (tui bridge)"
4695 if s.Username != "" {
4696 msg += " as @" + s.Username
4697 }
4698 if s.PairedID != 0 {
4699 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
4700 } else {
4701 msg += " - awaiting pairing"
4702 }
4703 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
4704 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
4705 } else {
4706 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
4707 if cfg.BotToken == "" {
4708 msg = "telegram: not configured. run `zot telegram-bot setup` first."
4709 } else {
4710 msg = "telegram: disconnected"
4711 if cfg.BotUsername != "" {
4712 msg += " (@" + cfg.BotUsername + " ready to connect)"
4713 }
4714 }
4715 }
4716 i.mu.Lock()
4717 i.statusOK = msg
4718 i.statusErr = ""
4719 i.mu.Unlock()
4720 i.invalidate()
4721}
4722
4723// telegramHost adapts *Interactive to telegram.Host so the bridge
4724// can call back into the TUI without importing modes directly.
4725type telegramHost struct{ iv *Interactive }
4726
4727func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
4728 h.iv.SubmitOrQueue(prompt, images)
4729}
4730
4731func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
4732
4733func (h *telegramHost) Status() string {
4734 h.iv.mu.Lock()
4735 providerName := h.iv.cfg.Provider
4736 model := h.iv.cfg.Model
4737 cwd := h.iv.cfg.CWD
4738 usage := h.iv.cumUsage
4739 subscription := h.iv.cfg.AuthMethod == "oauth"
4740 ctxUsed := h.iv.lastCtxInput
4741 busy := h.iv.busy
4742 queued := len(h.iv.queued)
4743 h.iv.mu.Unlock()
4744
4745 ctxMax := 0
4746 if m, err := provider.FindModel(providerName, model); err == nil {
4747 ctxMax = m.ContextWindow
4748 }
4749 return telegram.FormatStatus(telegram.StatusSnapshot{
4750 Provider: providerName,
4751 Model: model,
4752 CWD: cwd,
4753 Usage: usage,
4754 Subscription: subscription,
4755 ContextUsed: ctxUsed,
4756 ContextMax: ctxMax,
4757 Busy: busy,
4758 Queued: queued,
4759 })
4760}
4761
4762func (h *telegramHost) Notify(level, message string) {
4763 h.iv.mu.Lock()
4764 switch level {
4765 case "error", "warn":
4766 h.iv.statusErr = message
4767 h.iv.statusOK = ""
4768 default:
4769 h.iv.statusOK = message
4770 h.iv.statusErr = ""
4771 }
4772 h.iv.mu.Unlock()
4773 h.iv.invalidate()
4774}
4775
4776// openSessionOpsDialog shows the picker for `/session` with no arg.
4777// Always offers export, import, fork, tree; the handlers bail with
4778// a clear status message when the precondition isn't met (empty
4779// transcript on fork; no parent/siblings on tree).
4780func (i *Interactive) openSessionOpsDialog() {
4781 items := []sessionOpsItem{
4782 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
4783 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
4784 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
4785 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
4786 }
4787 i.sessionOpsDialog.Open(items)
4788 i.invalidate()
4789}
4790
4791// doSessionOp dispatches export, import, fork, or tree. arg is the
4792// optional positional argument from e.g. /session export <path>
4793// or /session import <path>; fork and tree ignore it.
4794func (i *Interactive) doSessionOp(action, arg string) {
4795 switch action {
4796 case "export":
4797 i.doSessionExport(arg)
4798 case "import":
4799 i.doSessionImport(arg)
4800 case "fork":
4801 i.doSessionFork()
4802 case "tree":
4803 i.doSessionTree()
4804 default:
4805 i.mu.Lock()
4806 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
4807 i.mu.Unlock()
4808 i.invalidate()
4809 }
4810}
4811
4812// doSessionExport writes the live session file to destination path
4813// dst. When dst is empty we default to ~/Downloads (falling back to
4814// the user's home directory if it doesn't exist). The helper
4815// expands a leading `~` and creates any missing parent directories.
4816func (i *Interactive) doSessionExport(dst string) {
4817 if i.cfg.CurrentSessionPath == nil {
4818 i.mu.Lock()
4819 i.statusErr = "export: no session is active (running with --no-session?)"
4820 i.mu.Unlock()
4821 i.invalidate()
4822 return
4823 }
4824 src := i.cfg.CurrentSessionPath()
4825 if src == "" {
4826 i.mu.Lock()
4827 i.statusErr = "export: no session is active (running with --no-session?)"
4828 i.mu.Unlock()
4829 i.invalidate()
4830 return
4831 }
4832 // Persist any in-memory agent messages to the session file so
4833 // the export carries the full conversation. Without this, the
4834 // default lazy-flush-at-exit strategy leaves most of a running
4835 // session unwritten and the export ends up with only the meta.
4836 if i.cfg.FlushSession != nil {
4837 i.cfg.FlushSession()
4838 }
4839 dst = unquotePath(dst)
4840 if dst == "" {
4841 dst = defaultExportDir()
4842 } else {
4843 dst = expandTilde(dst)
4844 }
4845 out, err := core.ExportSession(src, dst)
4846 if err != nil {
4847 i.mu.Lock()
4848 i.statusErr = "export: " + err.Error()
4849 i.mu.Unlock()
4850 i.invalidate()
4851 return
4852 }
4853 i.mu.Lock()
4854 i.statusOK = "exported session to " + friendlyPath(out)
4855 i.statusErr = ""
4856 i.mu.Unlock()
4857 i.invalidate()
4858}
4859
4860// doSessionImport copies the .zotsession file at src into the
4861// running cwd's sessions directory and loads it as the active
4862// session, same as `/sessions` -> pick. When src is empty we ask
4863// the user to pass a path (no usable default here).
4864func (i *Interactive) doSessionImport(src string) {
4865 src = unquotePath(src)
4866 if src == "" {
4867 i.mu.Lock()
4868 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
4869 i.mu.Unlock()
4870 i.invalidate()
4871 return
4872 }
4873 src = expandTilde(src)
4874 if _, err := os.Stat(src); err != nil {
4875 i.mu.Lock()
4876 i.statusErr = "import: " + err.Error()
4877 i.mu.Unlock()
4878 i.invalidate()
4879 return
4880 }
4881 newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version)
4882 if err != nil {
4883 i.mu.Lock()
4884 i.statusErr = "import: " + err.Error()
4885 i.mu.Unlock()
4886 i.invalidate()
4887 return
4888 }
4889 if i.cfg.LoadSession == nil {
4890 i.mu.Lock()
4891 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
4892 i.statusErr = ""
4893 i.mu.Unlock()
4894 i.invalidate()
4895 return
4896 }
4897 if err := i.cfg.LoadSession(newPath); err != nil {
4898 i.mu.Lock()
4899 i.statusErr = "import: load failed: " + err.Error()
4900 i.mu.Unlock()
4901 i.invalidate()
4902 return
4903 }
4904 i.mu.Lock()
4905 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
4906 i.statusErr = ""
4907 i.mu.Unlock()
4908 i.invalidate()
4909}
4910
4911// defaultExportDir returns ~/Downloads when it exists, or ~ as a
4912// fallback, or /tmp on exotic machines with no home dir.
4913func defaultExportDir() string {
4914 home, err := os.UserHomeDir()
4915 if err != nil || home == "" {
4916 return os.TempDir()
4917 }
4918 downloads := filepath.Join(home, "Downloads")
4919 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
4920 return downloads
4921 }
4922 return home
4923}
4924
4925// expandTilde turns a leading ~ into the user's home directory.
4926// Returns the input unchanged when there's no tilde or no home.
4927func expandTilde(p string) string {
4928 if p == "" || p[0] != '~' {
4929 return p
4930 }
4931 home, err := os.UserHomeDir()
4932 if err != nil || home == "" {
4933 return p
4934 }
4935 if len(p) == 1 {
4936 return home
4937 }
4938 if p[1] == '/' || p[1] == filepath.Separator {
4939 return filepath.Join(home, p[2:])
4940 }
4941 return p
4942}
4943
4944// unquotePath strips a matching pair of surrounding single or
4945// double quotes. Drag-drop paste in the tui auto-quotes dropped
4946// file paths so the shell-like `/session import 'foo bar.zs'`
4947// stays well-formed; when the TUI's own slash handler consumes
4948// the arg, we want the raw path back.
4949func unquotePath(p string) string {
4950 p = strings.TrimSpace(p)
4951 if len(p) >= 2 {
4952 first := p[0]
4953 last := p[len(p)-1]
4954 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
4955 p = p[1 : len(p)-1]
4956 }
4957 }
4958 return p
4959}
4960
4961// friendlyPath collapses the user's home directory to a leading ~
4962// so status messages read cleanly. Falls back to the raw path when
4963// the home dir is unknown.
4964func friendlyPath(p string) string {
4965 home, err := os.UserHomeDir()
4966 if err != nil || home == "" {
4967 return p
4968 }
4969 if strings.HasPrefix(p, home+string(filepath.Separator)) {
4970 return "~" + p[len(home):]
4971 }
4972 return p
4973}
4974
4975// doSessionFork opens the /jump turn picker in "fork mode". The
4976// next selection branches the current session at that user turn
4977// instead of scrolling the viewport to it.
4978func (i *Interactive) doSessionFork() {
4979 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
4980 i.mu.Lock()
4981 i.statusErr = "fork: no session is active (running with --no-session?)"
4982 i.mu.Unlock()
4983 i.invalidate()
4984 return
4985 }
4986 msgs := []provider.Message{}
4987 if i.agent != nil {
4988 msgs = i.agent.Messages()
4989 }
4990 if len(msgs) == 0 {
4991 i.mu.Lock()
4992 i.statusErr = "fork: transcript is empty; nothing to fork from"
4993 i.mu.Unlock()
4994 i.invalidate()
4995 return
4996 }
4997 i.pendingFork = true
4998 i.jumpDialog.Open(msgs, "")
4999 i.invalidate()
5000}
5001
5002// doSessionTree shows the branch topology picker for this cwd.
5003// Pick an entry to switch into it (same semantics as /sessions
5004// picking a past session, but with the parent/child indentation).
5005func (i *Interactive) doSessionTree() {
5006 if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
5007 i.mu.Lock()
5008 i.statusErr = "tree: session storage not configured"
5009 i.mu.Unlock()
5010 i.invalidate()
5011 return
5012 }
5013 // Flush the running agent's transcript first so its message
5014 // count + latest preview are accurate in the tree.
5015 if i.cfg.FlushSession != nil {
5016 i.cfg.FlushSession()
5017 }
5018 roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
5019 if len(roots) == 0 {
5020 i.mu.Lock()
5021 i.statusErr = "tree: no sessions in this directory yet"
5022 i.mu.Unlock()
5023 i.invalidate()
5024 return
5025 }
5026 current := ""
5027 if i.cfg.CurrentSessionPath != nil {
5028 current = i.cfg.CurrentSessionPath()
5029 }
5030 if !i.sessionTreeDialog.Open(roots, current) {
5031 i.mu.Lock()
5032 i.statusErr = "tree: no branches to show"
5033 i.mu.Unlock()
5034 }
5035 i.invalidate()
5036}
5037
5038// applySessionTreeSelection switches the running agent to the
5039// session file at path. Thin wrapper around LoadSession that also
5040// writes a status line.
5041func (i *Interactive) applySessionTreeSelection(path string) {
5042 if i.cfg.LoadSession == nil {
5043 i.mu.Lock()
5044 i.statusErr = "tree: session swap not available in this build"
5045 i.mu.Unlock()
5046 i.invalidate()
5047 return
5048 }
5049 if err := i.cfg.LoadSession(path); err != nil {
5050 i.mu.Lock()
5051 i.statusErr = "tree: load failed: " + err.Error()
5052 i.mu.Unlock()
5053 i.invalidate()
5054 return
5055 }
5056 i.mu.Lock()
5057 i.statusOK = "switched to branch " + friendlyPath(path)
5058 i.statusErr = ""
5059 i.mu.Unlock()
5060 i.invalidate()
5061}
5062
5063// applyForkSelection branches the current session at msgIdx+1 (so
5064// the selected user message and everything before it is included
5065// in the new branch), then switches the running agent to the new
5066// file. Called from the jump-dialog handler when pendingFork=true.
5067func (i *Interactive) applyForkSelection(msgIdx int) {
5068 i.pendingFork = false
5069 if i.cfg.CurrentSessionPath == nil {
5070 i.mu.Lock()
5071 i.statusErr = "fork: no session is active"
5072 i.mu.Unlock()
5073 i.invalidate()
5074 return
5075 }
5076 src := i.cfg.CurrentSessionPath()
5077 if src == "" {
5078 i.mu.Lock()
5079 i.statusErr = "fork: no session is active"
5080 i.mu.Unlock()
5081 i.invalidate()
5082 return
5083 }
5084 if i.cfg.FlushSession != nil {
5085 i.cfg.FlushSession()
5086 }
5087 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
5088 // the selected user message is included.
5089 upTo := msgIdx + 1
5090 newPath, err := core.BranchSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
5091 if err != nil {
5092 i.mu.Lock()
5093 i.statusErr = "fork: " + err.Error()
5094 i.mu.Unlock()
5095 i.invalidate()
5096 return
5097 }
5098 if i.cfg.LoadSession == nil {
5099 i.mu.Lock()
5100 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
5101 i.statusErr = ""
5102 i.mu.Unlock()
5103 i.invalidate()
5104 return
5105 }
5106 if err := i.cfg.LoadSession(newPath); err != nil {
5107 i.mu.Lock()
5108 i.statusErr = "fork: switch failed: " + err.Error()
5109 i.mu.Unlock()
5110 i.invalidate()
5111 return
5112 }
5113 i.mu.Lock()
5114 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
5115 i.statusErr = ""
5116 i.mu.Unlock()
5117 i.invalidate()
5118}
5119
5120// formatInt is a tiny strconv.Itoa shim; keeps the handler above
5121// from needing a strconv import just for one call.
5122func formatInt(n int) string {
5123 return fmt.Sprintf("%d", n)
5124}
5125
5126// assistantText returns the concatenated text of every TextBlock in
5127// m. Used by the streaming-view dedupe guard to tell when a live
5128// streamed reply has already been promoted into the transcript.
5129func assistantText(m provider.Message) string {
5130 var sb strings.Builder
5131 for _, c := range m.Content {
5132 if tb, ok := c.(provider.TextBlock); ok {
5133 if sb.Len() > 0 {
5134 sb.WriteByte('\n')
5135 }
5136 sb.WriteString(tb.Text)
5137 }
5138 }
5139 return sb.String()
5140}
5141
5142// resetStreamingStateLocked clears every piece of streaming state
5143// in one shot. Used by abort paths (turn cancel, compact finish,
5144// queue drain) so the pacer doesn't keep draining stale runes from
5145// a prior turn. Must be called with i.mu held.
5146func (i *Interactive) resetStreamingStateLocked() {
5147 i.streaming.Reset()
5148 i.streamPending = i.streamPending[:0]
5149 i.streamFlushPending = false
5150 i.streamOn = false
5151}
5152
5153// assistantMessageSideEffects runs the non-visual hooks attached to
5154// EvAssistantMessage: the host-provided OnAssistant callback and the
5155// telegram-bridge mirror. Called with i.mu held.
5156//
5157// Factored out of handleEvent because the streaming pacer may defer
5158// visual reset until after the last buffered rune has painted, but
5159// the callbacks themselves must fire on message arrival so
5160// downstream observers (session persistence, telegram, cost panels)
5161// don't wait on a UI animation to catch up.
5162func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
5163 if i.cfg.OnAssistant != nil {
5164 i.cfg.OnAssistant(m)
5165 }
5166 if i.telegramBridge != nil && i.telegramBridge.Active() {
5167 var sb strings.Builder
5168 for _, c := range m.Content {
5169 if tb, ok := c.(provider.TextBlock); ok {
5170 if sb.Len() > 0 {
5171 sb.WriteString("\n")
5172 }
5173 sb.WriteString(tb.Text)
5174 }
5175 }
5176 if text := sb.String(); strings.TrimSpace(text) != "" {
5177 go i.telegramBridge.OnAssistantText(text)
5178 }
5179 }
5180}
5181
5182// paintPaceRate is how many runes the streaming pacer releases per
5183// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
5184// that a 500-rune summary finishes in ~1.3s, slow enough to look
5185// like a human typing. Empirically matches the feel of provider
5186// paths that already drip-stream natively.
5187const paintPaceRate = 6
5188
5189// paintPaceInterval is the tick interval for the streaming pacer.
5190// 16ms lines up with the redraw throttle so we never paint faster
5191// than the terminal can keep up.
5192const paintPaceInterval = 16 * time.Millisecond
5193
5194// runStreamPacer drains buffered deltas from streamPending into
5195// streaming a small batch per tick, invalidating after each move.
5196// It stops when the context cancels (tui shutdown).
5197//
5198// Why a pacer: providers differ wildly in how they chunk their
5199// text_delta events. The API-key path on Anthropic emits ~30 drips
5200// for a 400-token summary; the OAuth path can coalesce the same
5201// summary into 3 fat chunks, visually indistinguishable from "the
5202// whole reply just appeared". The pacer normalizes that so every
5203// path looks the same on screen.
5204func (i *Interactive) runStreamPacer(ctx context.Context) {
5205 t := time.NewTicker(paintPaceInterval)
5206 defer t.Stop()
5207 for {
5208 select {
5209 case <-ctx.Done():
5210 return
5211 case <-t.C:
5212 i.mu.Lock()
5213 if len(i.streamPending) == 0 {
5214 // EvAssistantMessage already fired but the pacer
5215 // was still draining a tick ago. Everything is now
5216 // painted; clear the streaming flags so the next
5217 // redraw shows the finalised transcript message
5218 // and hides the streaming overlay.
5219 if i.streamFlushPending {
5220 i.streamFlushPending = false
5221 i.streaming.Reset()
5222 i.streamOn = false
5223 i.mu.Unlock()
5224 i.invalidate()
5225 continue
5226 }
5227 i.mu.Unlock()
5228 continue
5229 }
5230 n := paintPaceRate
5231 if n > len(i.streamPending) {
5232 n = len(i.streamPending)
5233 }
5234 i.streaming.WriteString(string(i.streamPending[:n]))
5235 i.streamPending = i.streamPending[n:]
5236 i.mu.Unlock()
5237 i.invalidate()
5238 }
5239 }
5240}