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