1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/patriceckhart/zot/packages/agent/extensions"
16 "github.com/patriceckhart/zot/packages/agent/extproto"
17 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
18 "github.com/patriceckhart/zot/packages/agent/skills"
19 "github.com/patriceckhart/zot/packages/agent/swarm"
20 "github.com/patriceckhart/zot/packages/agent/tools"
21 "github.com/patriceckhart/zot/packages/core"
22 "github.com/patriceckhart/zot/packages/provider"
23 "github.com/patriceckhart/zot/packages/provider/auth"
24 "github.com/patriceckhart/zot/packages/tui"
25)
26
27// InteractiveConfig configures the interactive loop.
28type InteractiveConfig struct {
29 Terminal tui.Terminal
30 Theme tui.Theme
31 Model string
32 Provider string
33 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
34 BaseURL string
35 Reasoning string
36 SystemPrompt string
37 Tools core.Registry
38 MaxSteps int
39 CWD string
40
41 // Startup resource fields are loaded inputs available to list before the
42 // transcript. They are never sent to the provider or persisted by the view.
43 StartupAgentName string
44 StartupContextPaths []string
45 StartupExtensionNames []string
46 StartupSkillNames []string
47
48 // StartupExtensionErrors contains non-fatal extension and skill discovery
49 // failures collected before the TUI starts. It is display-only and never
50 // enters the transcript. The historical field name is retained for callers.
51 StartupExtensionErrors []string
52
53 // ShowInstructionsAtStartup controls whether startup resources are visible.
54 // nil means the default, off.
55 ShowInstructionsAtStartup *bool
56
57 // InlineImagesEnabled overrides terminal image rendering. nil means
58 // auto-detect and render when supported; false disables; true uses
59 // the detected protocol when available.
60 InlineImagesEnabled *bool
61
62 // AutoSwarmEnabled mirrors the persisted config flag at startup so
63 // the /settings dialog can render the current state without
64 // re-reading config.json on every open.
65 AutoSwarmEnabled *bool
66
67 // AutoCompactThreshold is the context-window percentage that triggers
68 // automatic compaction. nil means 85; zero disables percentage-based
69 // triggers while preserving payload-too-large recovery.
70 AutoCompactThreshold *int
71
72 // JailByDefault mirrors the persisted jail_by_default preference.
73 // The current sandbox may differ after a session-scoped /jail or /unjail.
74 JailByDefault *bool
75
76 // PowerShellEnabled mirrors the Windows-only default tool preference.
77 PowerShellEnabled *bool
78
79 // ToolSelectionExplicit preserves an explicit --tools selection.
80 ToolSelectionExplicit bool
81
82 // OpenRouterServerToolsEnabled mirrors the persisted preference.
83 // nil means the default, off.
84 OpenRouterServerToolsEnabled *bool
85
86 // OpenRouterServerToolCallLimit mirrors the persisted call limit.
87 // nil means the built-in default (4).
88 OpenRouterServerToolCallLimit *int
89
90 // NoTools preserves the launch-time promise that all tools stay disabled.
91 NoTools bool
92
93 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
94 // flag at startup. When true the @-mention picker fuzzy-searches the
95 // whole project tree instead of browsing one directory at a time.
96 RecursiveFileSuggest *bool
97
98 // FuzzySkillSuggest opts into ranked skill-name subsequence suggestions.
99 FuzzySkillSuggest *bool
100
101 // RespectGitignore mirrors the persisted respect_gitignore flag at
102 // startup. nil means the default (on); when false the @-mention
103 // picker shows files matched by the project's root .gitignore.
104 RespectGitignore *bool
105
106 // ThemeName mirrors the persisted config theme value. Empty means auto.
107 ThemeName string
108
109 // FlatTools renders tool calls without the bordered panel (a quiet
110 // header line plus indented, frameless output). Mirrors the
111 // resolved tool_render config / ZOT_FLAT_TOOLS env at startup.
112 FlatTools bool
113
114 // CompactUser renders sent user messages as a single quiet gutter
115 // line instead of a padded, tinted bubble. Mirrors the resolved
116 // compact_input config / ZOT_COMPACT_INPUT env at startup.
117 CompactUser bool
118
119 // CompactMode mirrors the persisted compact_mode flag at startup.
120 // When true, it enables both flat tool calls and compact user turns.
121 CompactMode *bool
122
123 // CollapseToolCall retains each tool-call header, a final preview line,
124 // and failure status while hiding the rest of the body.
125 CollapseToolCall *bool
126
127 ChatTimestamps bool
128 ChatTimestampIntervalMinutes *int
129 ChatTimestampDate string
130
131 // TUIInputStyle controls the main input rendering: plain or lines.
132 TUIInputStyle string
133
134 // TUIStatusPosition controls whether the status block renders above
135 // or below the main input.
136 TUIStatusPosition string
137
138 // TUIWorkingPosition controls whether the busy/working spinner renders
139 // above or below the main input.
140 TUIWorkingPosition string
141
142 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
143 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
144 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
145 QuickModelShortcuts []QuickModelShortcut
146
147 // Keymap maps configured key chords to slash commands. It is copied from
148 // config.json by the CLI and is only active in the main interactive input.
149 Keymap map[string]string
150
151 // ExtensionThemes returns themes bundled with loaded extensions.
152 ExtensionThemes func() []tui.ThemeOption
153
154 // AutoSwarmSystemAddendum is the system-prompt block that gets
155 // appended/stripped when the user toggles auto-swarm at runtime.
156 // Plumbed in from the cli so this package doesn't have to import
157 // agent (cycle).
158 AutoSwarmSystemAddendum string
159 SettingsStore SettingsStore
160
161 // Agent is optional. If nil, zot opens without credentials; the
162 // user must /login before they can prompt.
163 Agent *core.Agent
164
165 InitialInput string
166
167 // StartupPre is auto-submitted once when the interactive session
168 // opens, before InitialInput is applied. Uses the same path as a
169 // typed Enter submit ("!" → shell escape, otherwise a model turn).
170 StartupPre string
171
172 // OnStartupPreDone runs after StartupPre finishes (shell escape or
173 // model turn), before deferred InitialInput is applied. Hosts use
174 // it to rediscover skills / reload extensions installed by pre.
175 OnStartupPreDone func()
176
177 // AutoSubmitInitial, when true, auto-submits InitialInput after
178 // StartupPre completes (or immediately when StartupPre is empty).
179 // When false, InitialInput only pre-fills the editor.
180 AutoSubmitInitial bool
181
182 // Auth is required. When the user runs /login, Interactive talks to
183 // AuthManager to open a browser and wait for the callback.
184 AuthManager *auth.Manager
185
186 // LlamaCPPConfig resolves the router URL and optional API key from env or
187 // the credential saved by /login.
188 LlamaCPPConfig func() (baseURL, apiKey string, err error)
189
190 // RefreshLlamaCPPModels synchronizes currently loaded router models into
191 // the catalog before /model opens.
192 RefreshLlamaCPPModels func(context.Context) error
193
194 // RefreshLMStudioModels is a no-op until a server URL is registered.
195 RefreshLMStudioModels func(context.Context) error
196 LMStudioConfigured func() bool
197
198 // RefreshCustomProviderModels lists models from models.json providers
199 // that enabled discovery. CustomDiscoveryConfigured gates the refresh
200 // so /model opens immediately when nothing opted in.
201 RefreshCustomProviderModels func(context.Context) error
202 CustomDiscoveryConfigured func() bool
203
204 // BuildAgent is called after a successful login to (re)construct the
205 // agent with the fresh credential. It returns the new agent and
206 // the concrete provider/model in use.
207 BuildAgent func() (*core.Agent, string, string, error)
208
209 // SetKimiCLIFallbackDisabled controls whether zot may fall back to
210 // the official Kimi Code CLI token when zot has no stored Kimi token.
211 SetKimiCLIFallbackDisabled func(disabled bool) error
212
213 // BuildAgentFor rebuilds the agent with an explicit provider/model
214 // override (used by the /model picker when switching providers).
215 // If providerOverride is empty, the current provider is kept.
216 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
217
218 // BuildAgentForRescue rebuilds the agent for the rescue picker that
219 // opens after a recoverable provider failure. Unlike BuildAgentFor,
220 // this builder drops launch-time --api-key and --base-url overrides
221 // because those are usually the reason rescue triggered. Re-resolves
222 // credentials from env vars / auth.json / provider defaults so the
223 // retry has a real chance of succeeding. Falls back to BuildAgentFor
224 // when nil so embedders that don't wire it keep working.
225 BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
226
227 // LoggedInProviders returns the list of provider names that
228 // currently have credentials. Used by /model to filter the
229 // picker to only show reachable models.
230 LoggedInProviders func() []string
231
232 // ZotHome is zot's global state directory, used by authentication,
233 // themes, extensions, and other shared configuration.
234 ZotHome string
235
236 // SessionsRoot is the root passed to core session operations. It differs
237 // from ZotHome for Zotfile agents, whose sessions are isolated by agent
238 // name. Empty falls back to ZotHome for embedders and tests.
239 SessionsRoot string
240
241 // Version is the binary's current version (from main.version).
242 // Used only for display; the update check itself is done outside
243 // this package to avoid an import cycle.
244 Version string
245
246 // UpdateInfoChan is an optional channel that delivers the result
247 // of the github-release update check. Interactive reads at most
248 // one value, drops it if the check reported nothing, and otherwise
249 // surfaces a yellow "update available" banner at the top of the
250 // chat. Nil channel = no banner, no startup cost.
251 UpdateInfoChan <-chan UpdateInfo
252
253 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
254 Sandbox *tools.Sandbox
255
256 // LoadSession swaps the current session for the one at path. The
257 // callback returns the new agent message slice so the TUI can invalidate.
258 LoadSession func(path string) error
259
260 // ChangeCWD switches the running zot session's working directory
261 // to path. The host closes the current session, rebuilds the
262 // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
263 // opens a fresh session there. Returns an error if path doesn't
264 // exist, isn't a directory, or the host can't rebuild the agent.
265 //
266 // Optional: not wired by every embedder. When nil the hidden /cd
267 // command surfaces a clear error rather than no-oping.
268 ChangeCWD func(path string) error
269
270 // CurrentSessionPath returns the path of the live session file
271 // on disk (the one every AppendMessage writes to). Used by
272 // /session export so the exporter ships the exact bytes on
273 // disk. Returns an empty string when --no-session is set or
274 // no session is open.
275 CurrentSessionPath func() string
276
277 // FlushSession writes any in-memory agent messages to the
278 // session file that haven't been persisted yet. Called by
279 // /session export right before reading the file so the
280 // exported bytes reflect the full current conversation, not
281 // just the rows the agent happened to write synchronously.
282 // The default WriteNewTranscript-at-exit strategy means most
283 // of a running session lives only in memory until the tui
284 // closes; without a flush hook, /session export writes a
285 // file that only has the meta row.
286 FlushSession func()
287
288 // PersistModel is called whenever the user switches model or provider.
289 // It should update config.json and (if there's an active session)
290 // write a new meta row so resume picks up the same model.
291 PersistModel func(providerName, model string)
292
293 // OnReasoningChanged reports the normalized level after updating the live agent.
294 OnReasoningChanged func(level string)
295
296 OnAssistant func(m provider.Message)
297 OnToolResult func(id string, r core.ToolResult)
298
299 // Extensions, if non-nil, lets users invoke extension-registered
300 // slash commands. Commands declared by extensions are looked up
301 // AFTER the built-in catalog so a built-in name always wins.
302 Extensions *extensions.Manager
303
304 // Swarm, if non-nil, enables the /swarm slash command and the
305 // dashboard dialog. The cli constructs the Swarm once per
306 // interactive run and tears it down on exit. nil disables the
307 // feature entirely (used by embedders / tests that don't want
308 // subprocesses).
309 Swarm *swarm.Swarm
310
311 // SkillSnapshot, if non-nil, returns the current list of
312 // discovered SKILL.md files. Re-invoked each time /skills opens
313 // so the picker reflects edits made during the session.
314 SkillSnapshot func() []*skills.Skill
315
316 // Pin preferences are owned by the host, not the picker.
317 LoadSkillPins func(cwd string) (skills.Pins, error)
318 ToggleSkillPin func(cwd, name string, global bool) error
319 PreloadPinnedSkills bool
320
321 // ChangelogChan, if non-nil, delivers release-notes for the
322 // current binary version once at startup. Interactive opens a
323 // dismissible overlay when the channel produces a non-empty
324 // body. Receiver fires at most once.
325 ChangelogChan <-chan ChangelogPayload
326
327 // OnChangelogDismiss, if non-nil, is called once the user
328 // closes the changelog overlay. The cli wires this to a
329 // MarkChangelogShown call so the same version doesn't show
330 // again on the next launch.
331 OnChangelogDismiss func()
332
333 // NoYolo is true when --no-yolo was passed. Interactive opens
334 // a confirmation dialog before every tool call and blocks the
335 // tool until the user picks yes / always-this-tool /
336 // always-all / no. When false (default), tools run freely.
337 NoYolo bool
338
339 // ConfirmGate is the session-scoped gate wrapping this
340 // interactive's Confirmer. When non-nil, /yolo can call
341 // AllowAll() on it to disable confirmation for the rest of the
342 // session. When nil (yolo mode), /yolo reports that there's
343 // nothing to disable.
344 ConfirmGate *core.ConfirmGate
345}
346
347// ChangelogPayload mirrors agent.ChangelogInfo without the import
348// cycle. The cli builds one from the http response, the tui opens
349// the overlay when one arrives.
350type ChangelogPayload struct {
351 Version string
352 Body string
353 URL string
354}
355
356type modelRefreshResult struct{ err error }
357
358// Interactive is the TUI chat loop.
359type chatCacheKey struct {
360 cols int
361 agentRev uint64
362 statusOK string
363 statusErr string
364 help string
365 extNotes string
366 reloadErrors string
367 updateAvailable bool
368 updateCurrent string
369 updateLatest string
370 updateURL string
371 welcomeShowVer bool
372 expandAll bool
373 tailLimit int
374 chatTimestamps bool
375 chatTimestampInterval int
376 chatTimestampDate string
377}
378
379// QuickModelShortcut is one configured quick model switch slot.
380type QuickModelShortcut struct {
381 Provider string
382 Model string
383}
384
385// SettingsStore persists user-toggleable settings surfaced by /settings.
386type SettingsStore interface {
387 SetQuickModelShortcut(slot int, providerName, model string) error
388 SetInlineImages(enabled bool) error
389 SetAutoSwarm(enabled bool) error
390 SetJailByDefault(enabled bool) error
391 SetOpenRouterServerTools(enabled bool) error
392 SetRecursiveFileSuggest(enabled bool) error
393 SetFuzzySkillSuggest(enabled bool) error
394 SetRespectGitignore(enabled bool) error
395 SetCompactMode(enabled bool) error
396 SetCollapseToolCall(enabled bool) error
397 SetTUIInputStyle(style string) error
398 SetTUIStatusPosition(position string) error
399 SetTUIWorkingPosition(position string) error
400 SetReasoning(level string) error
401 SetTheme(name string) error
402}
403
404type showInstructionsSettingsStore interface {
405 SetShowInstructionsAtStartup(enabled bool) error
406}
407
408type autoCompactThresholdSettingsStore interface {
409 SetAutoCompactThreshold(percent int) error
410}
411
412type Interactive struct {
413 cfg InteractiveConfig
414 view *tui.View
415 ed *tui.Editor
416 rend *tui.Renderer
417
418 mu sync.Mutex
419 agent *core.Agent
420 streaming strings.Builder // what's currently painted on screen
421 streamOn bool
422
423 // streamPending is the runes buffered after each EvTextDelta that
424 // haven't yet been promoted into `streaming` for rendering. It
425 // exists because some provider paths (notably Anthropic via the
426 // oauth/subscription channel) coalesce the model's output into a
427 // few fat chunks instead of drip-streaming. Painting those fat
428 // chunks verbatim looks like the summary "just appears". The
429 // paintPace goroutine drains a handful of runes per tick from
430 // this buffer into `streaming`, giving every path the same
431 // typewriter feel regardless of upstream chunk size.
432 streamPending []rune
433 // streamFlushPending is set when EvAssistantMessage fires while
434 // streamPending still has unrendered runes. The ticker flushes
435 // them, then closes out the stream (clearing flags, resetting
436 // buffers) so the final paint matches the on-disk message.
437 streamFlushPending bool
438 toolCalls map[string]*tui.ToolCallView
439 toolOrder []string
440 // toolGate records, per tool-call id, how many runes of paced
441 // assistant text must have drained into `streaming` before that
442 // tool block may appear. It exists to make stream ordering
443 // deterministic: a tool call can arrive from the provider while
444 // the prose that logically precedes it is still being typed out
445 // by the pacer. Without gating, the tool block would render
446 // immediately while the intro paragraph keeps filling in below
447 // it. We snapshot the total expected stream length (already
448 // streamed + still pending) at the moment the tool starts, and
449 // hold the block back until the pacer reaches it.
450 toolGate map[string]int
451 statusErr string
452 statusOK string
453 reloadStatusSeq uint64
454 liveBlock []string // live streaming/tool progress rendered outside scrollback
455 helpBlock []string // rendered above the chat when /help was typed
456 cumUsage provider.Usage
457 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
458 busy bool
459 dirty chan struct{}
460 modelRefresh chan modelRefreshResult
461 startupPreDone chan startupPreResult
462 cancelTurn context.CancelFunc
463 scrollOffset int // rows from the bottom; 0 = pinned to latest
464 prevScrollOffset int // last value redraw snapped against; tracks intent
465
466 // prevChatLen and prevChatCols track the chat buffer's size at the
467 // last redraw so that when content grows below the user's viewport
468 // while they're scrolled up reading history, we can bump
469 // scrollOffset by exactly the growth and keep the visible content
470 // pinned. Without this, every streamed line shifts the visible
471 // window down through the buffer (because scrollOffset is measured
472 // from the bottom) and the user's reading position drifts upward
473 // and off the top.
474 prevChatLen int
475 prevChatCols int
476 prevChatRows int
477 prevOverlayOpen bool
478
479 // chatCache stores the built transcript/status-note rows for idle
480 // frames. Editor typing changes only the bottom input region, so
481 // reusing this cache avoids copying/walking/reassembling a long
482 // session on every keypress.
483 chatCache []string
484 chatCacheKey chatCacheKey
485 chatCacheValid bool
486
487 // Messages typed while a turn is in flight. Each is delivered as
488 // its own follow-up turn once the current one finishes. Rendered
489 // above the status bar as "sliding in: ..." chips.
490 queued []string
491
492 // runCtx is the top-level context passed to Run(). Follow-up turns
493 // drained from `queued` are started against this context so they
494 // survive past the ctx of the key event that enqueued them.
495 runCtx context.Context
496
497 // pendingPostCompactNote is a status_ok message to surface after
498 // a successful auto-compact pass triggered by a 413 or by the
499 // pre-turn fraction guard. Cleared by runCompact once shown.
500 pendingPostCompactNote string
501
502 // A pre-turn compaction must preserve the complete pending request,
503 // including images. Overflow recovery instead continues the user message
504 // already appended to the agent transcript, avoiding a duplicate message.
505 pendingCompactPrompt string
506 pendingCompactImages []provider.ImageBlock
507 hasPendingCompactPrompt bool
508 continueAfterCompact bool
509
510 // compacting is true for both manual and automatic compaction. Prompts
511 // submitted while it is set stay in the host queue because Compact does
512 // not run the agent loop that drains the agent-owned queue.
513 compacting bool
514
515 // autoCompacting is true while a model-triggered compaction is in
516 // flight. Surfaced in the status bar so the user can tell a
517 // condense pass from a regular assistant turn.
518 autoCompacting bool
519
520 // updateInfo is the result of the async update check. Zero value
521 // while the check hasn't completed or nothing is available.
522 updateInfo UpdateInfo
523
524 dialog *loginDialog
525 modelDialog *modelDialog
526 llamaDialog *llamaDialog
527 rescueDialog *rescueDialog
528 sessionDialog *sessionDialog
529 swarmDialog *swarmDialog
530 jumpDialog *jumpDialog
531 btwDialog *btwDialog
532 skillsDialog *skillsDialog
533 changelogDialog *changelogDialog
534 confirmDialog *confirmDialog
535 logoutDialog *logoutDialog
536 telegramDialog *telegramDialog
537 settingsDialog *settingsDialog
538 quickModelAssign int
539 telegramBridge *telegram.Bridge
540 sessionOpsDialog *sessionOpsDialog
541 sessionTreeDialog *sessionTreeDialog
542 timeline *timelineView
543 extPanel *extPanelDialog
544 llamaConfigured bool
545
546 pinnedSkillsPending bool
547 pinnedSkills []*skills.Skill
548
549 // swarmWatch tracks auto-swarm sub-agents the main agent spawned
550 // via swarm_spawn. Each entry holds the agent + the task text;
551 // a per-entry goroutine waits on the agent's terminal state. When
552 // every tracked entry has finished, the watcher flushes a single
553 // summary turn into the main chat (queued if the main agent is
554 // busy, run immediately if idle).
555 swarmWatchMu sync.Mutex
556 swarmWatch []*swarmWatchEntry
557
558 // pendingFork is true when the user ran /session fork: the next
559 // jump-picker selection should branch off that message instead
560 // of scrolling. Flag resets after the action fires or the dialog
561 // is dismissed, so repeated /jump calls don't turn into forks.
562 pendingFork bool
563 suggest *slashSuggester
564 fileSuggest *fileSuggester
565 spin *spinner
566
567 // parkedTurn is the 1-based turn number the viewport is currently
568 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
569 // Rendered as a muted footer at the bottom of the chat so users
570 // don't forget they're looking at history.
571 parkedTurn int
572 parkedTotal int
573 jumpRow *int // pending target, applied after the picker closes and layout is known
574
575 // inputHistoryIndex is -1 when not browsing history. When the
576 // editor is empty, Up/Down can walk previous user prompts without
577 // stealing normal vertical cursor movement in non-empty input.
578 inputHistoryIndex int
579
580 // lastCtrlC is when the user last pressed ctrl+c. The first press
581 // clears the editor / cancels a turn / shows a hint; a second press
582 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
583 lastCtrlC time.Time
584
585 // welcomeStart is when the interactive run began. The welcome
586 // banner shows the binary version for welcomeVersionDuration
587 // after this point and reverts to plain text after.
588 welcomeStart time.Time
589
590 // extNotes are one-shot styled lines pushed by extensions via
591 // Notify / Display. They live above the editor (just below the
592 // transcript) until cleared by /clear or another reset.
593 extNotes []string
594
595 // reloadErrors are host-only resource discovery and reload failures. They
596 // stay in the scrolling chat until /clear without entering the transcript.
597 reloadErrors []string
598
599 // keymap holds the compiled config.json keymap bindings, sorted by
600 // chord name. Invalid entries are reported through reloadErrors.
601 keymap []keymapBinding
602
603 // shellRunning is true while a !command is executing. It shares
604 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
605 // escape can start while one is in flight.
606 shellRunning bool
607 // shellLive is the accumulating stdout/stderr of the in-flight
608 // shell escape, updated via BashTool progress for live rendering.
609 shellLive string
610
611 // awaitingStartupPre is true while the zotfile entry.pre auto-submit
612 // is in flight. When it clears, deferredInitialInput is applied.
613 awaitingStartupPre bool
614 deferredInitialInput string
615 autoSubmitDeferred bool
616
617 // sessionLoading is true while a /sessions selection is being read
618 // on a background goroutine. Keeping this off the input goroutine
619 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
620 sessionLoading bool
621
622 // pendingRescuePrompt / pendingRescueImages stash the prompt and
623 // images that should be re-run after the user picks a model in
624 // the rescue dialog. Cleared once applyRescueSelection consumes
625 // them (or when the dialog is dismissed via esc).
626 pendingRescuePrompt string
627 pendingRescueImages []provider.ImageBlock
628
629 clipboardImages []clipboardImageAttachment
630}
631
632// welcomeVersionDuration is how long the welcome banner shows the
633// version suffix before reverting to the plain headline. 1.5s is
634// enough to read at a glance and keeps the splash short.
635const welcomeVersionDuration = 1500 * time.Millisecond
636
637// initialResumeTailLimit caps how many messages from a freshly-resumed
638// transcript we render on the first paint. The full transcript is
639// still in memory; older messages are rendered (and their cached
640// lines kept for the lifetime of the View) as soon as the user
641// scrolls past the rendered tail. Picked to comfortably fill the
642// largest realistic terminal viewport while keeping first paint
643// snappy on multi-thousand-message sessions where markdown / syntax
644// highlighting dominates the redraw cost.
645const initialResumeTailLimit = 80
646
647// resumeTailExpandStep is how many additional messages the tail
648// limit grows by each time the user scrolls past the currently
649// rendered top. Pre-rendering this many messages on a single tick
650// keeps scroll-up smooth without falling back to a one-by-one
651// reveal that would feel jerky.
652const resumeTailExpandStep = 80
653
654type startupPreResult struct {
655 deferred string
656 autoSubmit bool
657}
658
659// NewInteractive constructs an Interactive from cfg.
660func NewInteractive(cfg InteractiveConfig) *Interactive {
661 renderer := tui.NewRenderer(cfg.Terminal)
662 renderer.SetTheme(cfg.Theme)
663 startupAgentName := ""
664 startupContextPaths := []string(nil)
665 startupExtensionNames := []string(nil)
666 startupSkillNames := []string(nil)
667 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
668 startupAgentName = cfg.StartupAgentName
669 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
670 startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
671 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
672 }
673 i := &Interactive{
674 cfg: cfg,
675 view: &tui.View{
676 Theme: cfg.Theme,
677 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
678 FlatTools: cfg.FlatTools,
679 CompactUser: cfg.CompactUser,
680 CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
681 CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall,
682 ChatTimestamps: cfg.ChatTimestamps,
683 ChatTimestampDate: tui.ChatTimestampDateMode(cfg.ChatTimestampDate),
684 ChatTimestampIntervalMinutes: cfg.ChatTimestampIntervalMinutes,
685 StartupAgentName: startupAgentName,
686 StartupContextPaths: startupContextPaths,
687 StartupExtensionNames: startupExtensionNames,
688 StartupSkillNames: startupSkillNames,
689 },
690 // Prompt is the standard half-block accent bar used by chat
691 // speaker labels too, so the input gutter matches the rest
692 // of the UI.
693 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
694 rend: renderer,
695 toolCalls: map[string]*tui.ToolCallView{},
696 toolGate: map[string]int{},
697 dirty: make(chan struct{}, 8),
698 modelRefresh: make(chan modelRefreshResult, 1),
699 startupPreDone: make(chan startupPreResult, 1),
700 dialog: newLoginDialog(),
701 modelDialog: newModelDialog(),
702 llamaDialog: newLlamaDialog(),
703 rescueDialog: newRescueDialog(),
704 sessionDialog: newSessionDialog(),
705 swarmDialog: newSwarmDialog(),
706 jumpDialog: newJumpDialog(),
707 btwDialog: newBtwDialog(),
708 skillsDialog: newSkillsDialog(),
709 changelogDialog: newChangelogDialog(),
710 confirmDialog: newConfirmDialog(),
711 logoutDialog: newLogoutDialog(),
712 telegramDialog: newTelegramDialog(),
713 settingsDialog: newSettingsDialog(),
714 sessionOpsDialog: newSessionOpsDialog(),
715 sessionTreeDialog: newSessionTreeDialog(),
716 timeline: newTimelineView(),
717 extPanel: newExtPanelDialog(),
718 suggest: newSlashSuggester(),
719 fileSuggest: newFileSuggester(),
720 spin: newSpinner(cfg.Theme),
721 inputHistoryIndex: -1,
722 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
723 }
724 i.SetPinnedSkillsPending(cfg.PreloadPinnedSkills)
725 var keymapIssues []string
726 i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
727 i.reloadErrors = append(i.reloadErrors, keymapIssues...)
728 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
729 i.suggest.SetFuzzySkills(cfg.FuzzySkillSuggest != nil && *cfg.FuzzySkillSuggest)
730 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
731 if cfg.LlamaCPPConfig != nil {
732 baseURL, _, err := cfg.LlamaCPPConfig()
733 i.llamaConfigured = err == nil && baseURL != ""
734 }
735 if cfg.Agent != nil {
736 i.agent = cfg.Agent
737 i.view.Messages = cfg.Agent.Messages()
738 i.cumUsage = cfg.Agent.Cost()
739 // Rehydrate the "context used" gauge from the last persisted
740 // turn. Without this the status bar reads 0.0% after a resume
741 // until the next turn lands a usage event.
742 if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
743 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
744 }
745 // Cap the first paint at the tail of the transcript so
746 // resuming a multi-thousand-message session doesn't block
747 // on rendering every prior turn before showing anything.
748 if len(i.view.Messages) > initialResumeTailLimit {
749 i.view.TailLimit = initialResumeTailLimit
750 }
751 }
752 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
753 i.applyAutoSwarmTool(true)
754 }
755 i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
756 return i
757}
758
759// Run blocks until the user quits.
760func (i *Interactive) Run(ctx context.Context) error {
761 i.runCtx = ctx
762 term := i.cfg.Terminal
763 restore, err := term.EnterRaw()
764 if err != nil {
765 return err
766 }
767 defer restore()
768 defer func() {
769 if i.telegramBridge != nil {
770 i.telegramBridge.Stop()
771 }
772 }()
773
774 // Enabling mouse reporting steals click-drag selection from the
775 // host terminal (VS Code, Ghostty, iTerm). The user prefers native
776 // selection over the wheel-speed boost, so we no longer turn it
777 // on automatically. Wheel events fall through to the terminal's
778 // own scrollback handler.
779 // Keep the live log on the terminal's main screen. Only explicit
780 // history browsing uses the alternate screen. The renderer emits
781 // chat as normal terminal flow/scrollback and redraws only the live
782 // input/status block on normal typing.
783 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
784 // Tell the terminal our working directory (OSC 7) so "new tab /
785 // split here" opens in the launch cwd instead of inheriting a stale
786 // directory from an extension subprocess (see issue #38). Harmless
787 // on terminals that ignore it.
788 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
789 _, _ = term.Write([]byte(seq))
790 }
791 // Erase the live frame on exit without moving the cursor. The shell can
792 // then draw its prompt on the row where zot ended instead of leaving the
793 // inactive TUI visible above it. Do not erase scrollback: users should
794 // still be able to review the session after closing zot.
795 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.SeqShowCursor))
796 defer i.rend.CloseHistory()
797 i.applyInputCursorColor()
798
799 // Streaming pacer: drains buffered text deltas at a steady rate
800 // so typewriter feel is identical across providers regardless of
801 // upstream chunk size. Starts here so it lives for the whole
802 // session and exits with ctx.
803 go i.runStreamPacer(ctx)
804
805 cols, rows := term.Size()
806 i.rend.Resize(cols, rows)
807 term.OnResize(func() {
808 c, r := term.Size()
809 i.rend.Resize(c, r)
810 // Force an immediate redraw on resize. The throttled invalidate
811 // path is fine for animation, but a window resize is a discrete
812 // user action where any visible delay (or stale frame) reads as
813 // brokenness. redraw() is mutex-safe; the worst that happens is
814 // a duplicate paint if the throttler is mid-flight, which is
815 // invisible.
816 i.redraw()
817 })
818
819 if i.cfg.StartupPre != "" {
820 i.deferredInitialInput = i.cfg.InitialInput
821 i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
822 i.awaitingStartupPre = true
823 i.Submit(i.cfg.StartupPre)
824 } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
825 i.Submit(i.cfg.InitialInput)
826 } else if i.cfg.InitialInput != "" {
827 i.ed.SetValue(i.cfg.InitialInput)
828 }
829
830 // Stamp the welcome time and schedule a one-shot redraw at the
831 // expiry so the version suffix disappears on its own even if the
832 // user hasn't typed anything yet.
833 i.welcomeStart = time.Now()
834 time.AfterFunc(welcomeVersionDuration, i.invalidate)
835
836 // If the agent was constructed with a pre-loaded transcript
837 // (--continue, --resume, --session) pin the viewport at the
838 // bottom so the most recent reply (and any prompt the user just
839 // typed) is fully visible. Earlier behaviour parked the view at
840 // the last user turn, which could leave the latest message clipped
841 // off the bottom of the page on long sessions.
842 if i.agent != nil {
843 if msgs := i.agent.Messages(); len(msgs) > 0 {
844 i.scrollToBottom()
845 }
846 }
847
848 // No credential at startup? Auto-open the login dialog, and mark
849 // the status line. The user can Esc out of the dialog if they
850 // want to dismiss it (e.g. to check /help or /exit first).
851 if i.agent == nil {
852 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
853 i.dialog.Open(i.cfg.ZotHome)
854 }
855
856 // Input goroutine. Buffered generously so a drag-drop that the
857 // terminal delivers as a burst of single-character key events
858 // (no bracketed paste) can be drained in one main-loop pass
859 // instead of triggering a redraw per character.
860 keys := make(chan tui.Key, 256)
861 go func() {
862 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
863 for {
864 k, err := reader.Read()
865 if err != nil {
866 return
867 }
868 keys <- k
869 }
870 }()
871
872 // Subscribe to auth events.
873 var authEvents <-chan auth.Event
874 if i.cfg.AuthManager != nil {
875 authEvents = i.cfg.AuthManager.Events()
876 }
877
878 // Animation ticker: drives spinner and dialog-related redraws when
879 // nothing else changed. 120ms is slow enough that highlighting a huge
880 // transcript doesn't spin the cpu.
881 tick := time.NewTicker(120 * time.Millisecond)
882 defer tick.Stop()
883
884 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
885 // at most once every redrawMinInterval. Huge tool-result dumps can
886 // fire hundreds of invalidations while the user is typing; without
887 // this, the input goroutine never gets CPU and keystrokes lag.
888 const redrawMinInterval = 16 * time.Millisecond
889 var lastRedraw time.Time
890 var pendingRedraw bool
891 var pendingTimer *time.Timer
892
893 drainPending := func() {
894 if pendingTimer != nil {
895 pendingTimer.Stop()
896 pendingTimer = nil
897 }
898 if pendingRedraw {
899 pendingRedraw = false
900 lastRedraw = time.Now()
901 i.redraw()
902 }
903 }
904
905 requestRedraw := func() {
906 since := time.Since(lastRedraw)
907 if since >= redrawMinInterval {
908 // Redrawing right now subsumes any pending redraw, so clear
909 // the throttle state. Without this, a pending flag stays
910 // stuck at true and subsequent invalidate() calls within
911 // redrawMinInterval get dropped — which is exactly how the
912 // final "turn finished" frame went missing until the user
913 // nudged the ui by typing or scrolling.
914 if pendingTimer != nil {
915 pendingTimer.Stop()
916 }
917 pendingRedraw = false
918 lastRedraw = time.Now()
919 i.redraw()
920 return
921 }
922 if pendingRedraw {
923 return // already scheduled
924 }
925 pendingRedraw = true
926 wait := redrawMinInterval - since
927 if pendingTimer == nil {
928 pendingTimer = time.AfterFunc(wait, func() {
929 // Poke the dirty channel so the main loop wakes and
930 // drains the pending redraw on its own goroutine. We
931 // can't call drainPending here directly — it touches
932 // closure state shared with the main loop.
933 i.invalidate()
934 })
935 } else {
936 pendingTimer.Reset(wait)
937 }
938 }
939
940 i.invalidate()
941
942 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
943 changelog := i.cfg.ChangelogChan // single-shot, see case below
944
945 for {
946 select {
947 case <-ctx.Done():
948 return ctx.Err()
949 case k := <-keys:
950 if done := i.handleKey(ctx, k); done {
951 return nil
952 }
953 // Drain any keystrokes that arrived during this iteration.
954 // VS Code (and other terminals that don't bracket drops as
955 // paste) deliver a path one rune at a time — without this
956 // loop the editor would render between every rune and a
957 // long path on a heavy transcript would visibly type in.
958 drain:
959 for {
960 select {
961 case k2 := <-keys:
962 if done := i.handleKey(ctx, k2); done {
963 return nil
964 }
965 default:
966 break drain
967 }
968 }
969 i.invalidate()
970 case ev := <-authEvents:
971 i.handleAuthEvent(ev)
972 i.invalidate()
973 case result := <-i.modelRefresh:
974 i.openModelPickerAfterRefresh(result.err)
975 i.invalidate()
976 case result := <-i.startupPreDone:
977 i.applyStartupPreResult(result)
978 i.invalidate()
979 case info, ok := <-updates:
980 if ok && info.Available {
981 i.mu.Lock()
982 i.updateInfo = info
983 i.mu.Unlock()
984 i.invalidate()
985 }
986 updates = nil // single-shot; subsequent iterations skip this case
987 case cl, ok := <-changelog:
988 if ok && cl.Body != "" {
989 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
990 i.invalidate()
991 }
992 changelog = nil // single-shot
993 case <-i.dirty:
994 requestRedraw()
995 case <-tick.C:
996 // Always drain a pending redraw on the tick. This is the
997 // safety net that catches the case where the dirty channel
998 // was saturated when the final "turn finished" invalidate
999 // fired, or where the throttle scheduled a deferred redraw
1000 // and the AfterFunc-driven invalidate got dropped on a
1001 // full channel.
1002 drainPending()
1003 // Only force a periodic redraw when something is actually
1004 // animating (the main spinner during a busy turn, or the
1005 // btw side-chat spinner while it's awaiting a response).
1006 // Static pickers (model, session, jump, etc.) don't need
1007 // the tick and firing it cancels the terminal's cursor
1008 // blink inside dialogs that host their own editor (btw),
1009 // because each frame re-emits hide-cursor + show-cursor.
1010 //
1011 // The swarm dashboard is also animated: its rows reflect
1012 // background subprocesses whose activity / age change
1013 // without any user input. Without the tick redraw the
1014 // dashboard freezes on the snapshot taken when the user
1015 // last pressed a key. We exclude the dashboard when one
1016 // of its inline editors (spawn task or prompt composer)
1017 // is active so the cursor blink in those editors works
1018 // the same way it does inside btw.
1019 if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
1020 requestRedraw()
1021 }
1022 }
1023 }
1024}
1025
1026func (i *Interactive) invalidate() {
1027 select {
1028 case i.dirty <- struct{}{}:
1029 default:
1030 }
1031}
1032
1033func (i *Interactive) cachedChatLocked(cols int) []string {
1034 key, cacheable := i.chatCacheKeyLocked(cols)
1035 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
1036 return append([]string(nil), i.chatCache...)
1037 }
1038 chat := i.buildChatLocked(cols)
1039 if cacheable {
1040 i.chatCache = append(i.chatCache[:0], chat...)
1041 i.chatCacheKey = key
1042 i.chatCacheValid = true
1043 } else {
1044 i.chatCacheValid = false
1045 }
1046 return chat
1047}
1048
1049func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1050 // Live turns mutate streaming/tool-call state at high frequency;
1051 // keep those on the old rebuild path. The cache targets the common
1052 // idle case where only the editor contents changed between redraws.
1053 if i.busy || i.streamOn || i.streamFlushPending || i.timeline.Active() {
1054 return chatCacheKey{}, false
1055 }
1056 var rev uint64
1057 if i.agent != nil {
1058 rev = i.agent.Revision()
1059 }
1060 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1061 return chatCacheKey{
1062 cols: cols,
1063 agentRev: rev,
1064 statusOK: i.statusOK,
1065 statusErr: i.statusErr,
1066 help: strings.Join(i.helpBlock, "\n"),
1067 extNotes: strings.Join(i.extNotes, "\n"),
1068 reloadErrors: strings.Join(i.reloadErrors, "\n"),
1069 updateAvailable: i.updateInfo.Available,
1070 updateCurrent: i.updateInfo.Current,
1071 updateLatest: i.updateInfo.Latest,
1072 updateURL: i.updateInfo.URL,
1073 welcomeShowVer: showVer,
1074 expandAll: i.view.ExpandAll,
1075 tailLimit: i.view.TailLimit,
1076 chatTimestamps: i.view.ChatTimestamps,
1077 chatTimestampDate: i.view.ChatTimestampDate,
1078 chatTimestampInterval: tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes),
1079 }, true
1080}
1081
1082func (i *Interactive) buildChatLocked(cols int) []string {
1083 if i.agent != nil {
1084 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1085 } else {
1086 i.view.Messages = nil
1087 }
1088 if i.timeline.Active() {
1089 _, rows := i.cfg.Terminal.Size()
1090 return i.timeline.Render(i.cfg.Theme, cols, rows, i.timelineDataLocked())
1091 }
1092 // Pacer flush: while the streaming pacer is still draining the
1093 // buffer (i.e. EvAssistantMessage already fired but more runes
1094 // are queued), the final assistant message is already in
1095 // i.agent.Messages() in full. Painting it in the transcript
1096 // AND the streaming block at the same time shows the user the
1097 // complete text immediately — which defeats the whole pacer.
1098 // Hide the last message until the pacer catches up; once the
1099 // flush-pending latch clears, the message is revealed (the
1100 // streaming block disappears the same frame because streamOn
1101 // flips off, so the transition is seamless).
1102 if i.streamFlushPending && len(i.view.Messages) > 0 {
1103 i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
1104 }
1105 i.view.Streaming = i.streaming.String()
1106 i.view.StreamingActive = i.streamOn
1107 // Guard against the narrow race where EvAssistantMessage has
1108 // just promoted a streaming reply into the transcript but a
1109 // render tick hasn't flipped streamOn off yet. Without the
1110 // guard, the same text would appear twice (once as the
1111 // in-flight streaming block, once as the last transcript
1112 // message). We detect the duplicate strictly: the last
1113 // assistant message's visible text must equal the streaming
1114 // buffer. Just matching on role is too broad — it also hides
1115 // the next round's typewriter streaming after a tool turn,
1116 // because the last transcript message is always assistant
1117 // (the tool-use block) until the follow-up summary lands.
1118 if i.streamOn && i.streaming.Len() > 0 {
1119 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
1120 if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
1121 i.view.StreamingActive = false
1122 }
1123 }
1124 }
1125 // Live tool-call view: only shown while a turn is in flight. Once
1126 // the agent is idle, every tool call has already been folded into
1127 // the transcript (as assistant.ToolCallBlock + a tool-role message),
1128 // so showing v.ToolCalls a second time would duplicate them below
1129 // the final assistant text — which looks like the summary came
1130 // "before" the tools.
1131 i.view.ToolCalls = i.view.ToolCalls[:0]
1132 if i.busy {
1133 for _, id := range i.toolOrder {
1134 // Deterministic ordering: a tool block stays hidden until
1135 // the paced assistant text that preceded it has finished
1136 // typing out. toolOrder is append-only in arrival order,
1137 // so once one tool is still gated, every later tool is too,
1138 // so stop here to avoid showing a tool out of sequence.
1139 if !i.toolGateOpenLocked(id) {
1140 break
1141 }
1142 if tc, ok := i.toolCalls[id]; ok {
1143 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
1144 }
1145 }
1146 }
1147 i.view.Err = i.statusErr
1148 // Live streaming/tool rows are appended to the chat buffer (not
1149 // hoisted into a separate live block above the editor). That keeps
1150 // the renderer's diff view append-only: when a tool finishes the
1151 // rows update in place at the end of the buffer, instead of the
1152 // whole bottom band shrinking and shifting chat lines around.
1153 i.liveBlock = nil
1154 chat := i.view.Build(cols)
1155
1156 // Welcome banner: shown at the top of the chat area when there is
1157 // no transcript yet. Disappears after the first message is sent.
1158 // The version suffix is shown for welcomeVersionDuration after
1159 // startup, then drops off automatically.
1160 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
1161 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1162 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
1163 }
1164
1165 // Update-available banner: prepended above everything else so it's
1166 // the first thing the user sees when opening a new zot session.
1167 // Once rendered, it stays until the user updates to a newer
1168 // version — we don't persist a "dismissed" flag because this is
1169 // cheap and re-showing it is how most users remember to update.
1170 if i.updateInfo.Available {
1171 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
1172 chat = append(banner, chat...)
1173 }
1174
1175 // /help block: appended to the transcript so it appears at the
1176 // bottom of the chat area (right above the status bar / editor).
1177 // Prepending it would push long conversations off the top of the
1178 // viewport, which users would miss entirely.
1179 if len(i.helpBlock) > 0 {
1180 chat = append(chat, i.helpBlock...)
1181 }
1182
1183 if i.statusOK != "" {
1184 // Hard-truncate the OK line to the visible width so a long
1185 // session path ("resumed session: /Users/.../sessions/...")
1186 // doesn't overflow past the right edge and look broken on a
1187 // narrow terminal.
1188 line := "✓ " + i.statusOK
1189 if cols > 4 && len(line) > cols {
1190 line = line[:cols-3] + "..."
1191 }
1192 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
1193 }
1194
1195 // Live shell-escape output (!command / entry.pre) streams into the
1196 // transcript area while the command runs, then is replaced by the
1197 // final user-context message when it finishes.
1198 if i.shellRunning && i.shellLive != "" {
1199 for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") {
1200 chat = append(chat, line)
1201 }
1202 chat = append(chat, "")
1203 }
1204
1205 // Extension notes (notify / display) live just under the
1206 // transcript, above the dialog/editor band. Cleared by /clear.
1207 if len(i.extNotes) > 0 {
1208 chat = append(chat, i.extNotes...)
1209 chat = append(chat, "")
1210 }
1211
1212 // Reload failures remain in the scrolling chat until /clear. They are
1213 // host-only display state, not provider messages or persisted context.
1214 // While the latest failure is still the temporary status error, hide its
1215 // stored copy to avoid painting the same message twice.
1216 reloadErrors := i.reloadErrors
1217 if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1218 reloadErrors = reloadErrors[:n-1]
1219 }
1220 if len(reloadErrors) > 0 {
1221 chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1222 chat = append(chat, "")
1223 }
1224
1225 // Strip trailing blank rows so the chat content sits flush
1226 // against the new "blank above status bar" row added by the
1227 // bottom-region assembly. Build() ends every message with a
1228 // blank separator; without this trim, the final message in
1229 // the transcript would have its own trailing blank plus the
1230 // status block's leading blank, doubling the gap.
1231 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1232 chat = chat[:len(chat)-1]
1233 }
1234 return chat
1235}
1236
1237// sessionsRoot returns the session namespace for this run.
1238func (i *Interactive) sessionsRoot() string {
1239 if i.cfg.SessionsRoot != "" {
1240 return i.cfg.SessionsRoot
1241 }
1242 return i.cfg.ZotHome
1243}
1244
1245// lastCols returns the current terminal width in columns.
1246func (i *Interactive) lastCols() int {
1247 cols, _ := i.cfg.Terminal.Size()
1248 return cols
1249}
1250
1251func (i *Interactive) timelineData() timelineData {
1252 i.mu.Lock()
1253 defer i.mu.Unlock()
1254 return i.timelineDataLocked()
1255}
1256
1257// timelineDataLocked snapshots the current provider-neutral context for the
1258// read-only timeline. The caller must hold i.mu.
1259func (i *Interactive) timelineDataLocked() timelineData {
1260 data := timelineData{ContextUsed: i.lastCtxInput}
1261 if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1262 data.ContextMax = model.ContextWindow
1263 }
1264 if i.agent != nil {
1265 data.System, data.Tools, data.Messages = i.agent.ContextSnapshot()
1266 }
1267 return data
1268}
1269
1270// chatPage returns the number of chat rows currently visible, used
1271// as the page size for PageUp/PageDown.
1272func (i *Interactive) chatPage() int {
1273 _, rows := i.cfg.Terminal.Size()
1274 p := rows - 6 // rough reservation for status + editor + a dialog line
1275 if p < 4 {
1276 p = 4
1277 }
1278 return p
1279}
1280
1281// scrollBy adjusts the scroll offset. Positive = up (into history).
1282// Clearing the parked-turn label when we're back at the bottom means
1283// the "viewing turn N" footer goes away automatically as soon as you
1284// scroll back to the live tail.
1285func (i *Interactive) scrollBy(delta int) {
1286 i.mu.Lock()
1287 i.scrollOffset += delta
1288 if i.scrollOffset < 0 {
1289 i.scrollOffset = 0
1290 }
1291 if i.scrollOffset == 0 {
1292 i.parkedTurn = 0
1293 i.parkedTotal = 0
1294 }
1295 if i.rend != nil {
1296 // VS Code's terminal is especially prone to leaving stray
1297 // wrapped-character fragments behind during scroll-driven
1298 // viewport changes. Force a full repaint on scroll, but
1299 // avoid a whole-screen clear because that visibly flickers.
1300 i.rend.Invalidate()
1301 }
1302 i.mu.Unlock()
1303 i.invalidate()
1304}
1305
1306// anchorScrollOffset keeps the user's reading position pinned when the
1307// chat buffer grows/shrinks or the viewport height changes between two
1308// redraws while they're scrolled up.
1309//
1310// scrollOffset is measured from the bottom of the chat buffer, so the
1311// top visible row is start = chatLen - scrollOffset - chatRows. To hold
1312// `start` constant we adjust the offset by the buffer-length delta minus
1313// the viewport-height delta. The result is clamped to [0, newLen] so a
1314// shrinking buffer can't push it negative.
1315func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1316 adj := (newLen - prevLen) - (newRows - prevRows)
1317 offset += adj
1318 if offset < 0 {
1319 offset = 0
1320 }
1321 if offset > newLen {
1322 offset = newLen
1323 }
1324 return offset
1325}
1326
1327// scrollToBottom pins the view to the latest content.
1328func (i *Interactive) scrollToBottom() {
1329 i.mu.Lock()
1330 i.jumpRow = nil
1331 i.scrollOffset = 0
1332 i.parkedTurn = 0
1333 i.parkedTotal = 0
1334 // Reset the auto-follow baseline. scrollToBottom is the resume /
1335 // session-swap snap point, where the chat buffer changes length
1336 // wholesale. Without zeroing these, the next render's follow guard
1337 // compares the fresh transcript's length against a stale baseline
1338 // and nudges scrollOffset, which reads as a viewport jump right
1339 // after resume. See commit 43da5e5 for the same fix on new turns.
1340 i.prevChatLen = 0
1341 i.prevChatCols = 0
1342 if i.rend != nil {
1343 i.rend.Invalidate()
1344 }
1345 i.mu.Unlock()
1346 i.invalidate()
1347}
1348
1349func (i *Interactive) redraw() {
1350 i.mu.Lock()
1351 defer i.mu.Unlock()
1352
1353 cols, _ := i.cfg.Terminal.Size()
1354 chat := i.cachedChatLocked(cols)
1355
1356 // Dialogs (login or model picker) render between chat and the editor.
1357 var dialog []string
1358 switch {
1359 case i.dialog.Active():
1360 dialog = i.dialog.Render(i.cfg.Theme, cols)
1361 case i.modelDialog.Active():
1362 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
1363 case i.llamaDialog.Active():
1364 dialog = i.llamaDialog.Render(i.cfg.Theme, cols)
1365 case i.rescueDialog.Active():
1366 dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
1367 case i.sessionDialog.Active():
1368 // Reserve rows for the editor (~3), status line (1-2),
1369 // dialog chrome (header + hint + rule + indicators, ~5),
1370 // and leave the remainder for session rows. Minimum of 3
1371 // rows so even a very small terminal shows something.
1372 _, rows := i.cfg.Terminal.Size()
1373 avail := rows - 12
1374 if avail < 3 {
1375 avail = 3
1376 }
1377 i.sessionDialog.MaxRows = avail
1378 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1379 case i.swarmDialog.Active():
1380 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
1381 case i.jumpDialog.Active():
1382 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1383 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1384 dialog = i.extPanel.Render(i.cfg.Theme, cols)
1385 case i.confirmDialog.Active():
1386 if i.btwDialog.Active() {
1387 // Keep the side-chat transcript and its live tool preview visible
1388 // above confirmation, matching how the main transcript remains
1389 // visible while --no-yolo waits for a decision.
1390 dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1391 } else {
1392 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1393 }
1394 case i.btwDialog.Active():
1395 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
1396 case i.skillsDialog.Active():
1397 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
1398 case i.changelogDialog.Active():
1399 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1400 case i.logoutDialog.Active():
1401 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
1402 case i.telegramDialog.Active():
1403 dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1404 case i.settingsDialog.Active():
1405 dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1406 case i.sessionOpsDialog.Active():
1407 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1408 case i.sessionTreeDialog.Active():
1409 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1410 }
1411 if len(dialog) > 0 {
1412 dialog = padDialogFrame(dialog)
1413 }
1414
1415 // Slash-command autocomplete: popup above the status line, only
1416 // when the editor starts with "/" and no dialog is already open.
1417 // Feed extension-registered commands into the suggester first so
1418 // they show up in tab-complete + the popup alongside the built-ins.
1419 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
1420 i.suggest.SetLlamaConfigured(i.llamaConfigured)
1421 if i.cfg.Extensions != nil {
1422 catalog := i.cfg.Extensions.Commands()
1423 extra := make([]slashCommand, 0, len(catalog))
1424 for _, c := range catalog {
1425 // The popup renders extension commands under a dedicated
1426 // "── extensions ───" divider, so the description doesn't
1427 // need to repeat the source. If the description is empty,
1428 // fall back to the extension name so the row isn't blank.
1429 desc := c.Description
1430 if strings.TrimSpace(desc) == "" {
1431 desc = "(" + c.Extension + ")"
1432 }
1433 extra = append(extra, slashCommand{
1434 Name: "/" + c.Name,
1435 Desc: desc,
1436 })
1437 }
1438 i.suggest.SetExtra(extra)
1439 }
1440 var suggest []string
1441 currentInput := i.ed.Value()
1442 if i.suggest.SkillInputStarted(currentInput) {
1443 var list []*skills.Skill
1444 if i.cfg.SkillSnapshot != nil {
1445 list = i.cfg.SkillSnapshot()
1446 }
1447 i.suggest.SetSkills(list)
1448 }
1449 // Slash popup renders even while the agent is busy so the user
1450 // can queue a destructive command (/clear, /compact, /logout,
1451 // /model) or a read-only one (/help, /jump, /sessions, etc.)
1452 // without waiting for the current turn to finish. The dispatcher
1453 // in runSlash already handles the busy case per-command: safe
1454 // ones run immediately, destructive ones cancel the turn first.
1455 i.fileSuggest.SetCWD(i.cfg.CWD)
1456 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())
1457 if mainInputFocused && i.suggest.Active(currentInput) {
1458 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1459 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1460 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1461 }
1462
1463 // Detect overlay close (any dialog or slash/file suggestion popup
1464 // just transitioned from open to closed). Force a full redraw so
1465 // the rows the overlay occupied are guaranteed to be repainted
1466 // from the chat below, instead of the diff path leaving stale
1467 // dialog content behind. Equivalent to the user pressing ctrl+l.
1468 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1469 if i.rend != nil && i.prevOverlayOpen && !overlayOpen && i.jumpRow == nil {
1470 // An overlay (dialog or slash/file popup) just closed, so the
1471 // bottom band shrinks. On terminals where we can drop
1472 // scrollback, a full Clear is the simplest way to guarantee
1473 // the vacated rows are repainted from the chat below.
1474 //
1475 // On VS Code's terminal closing a dialog leaves the stale
1476 // overlay rows in the retained scrollback (we can't drop them
1477 // with the quiet in-place diff). Run the same full Clear() that
1478 // Ctrl+L uses so the scrollback is purged and the conversation
1479 // is repainted clean, matching what the user expects after
1480 // dismissing a picker. Clear() is keepScrollback-aware and
1481 // emits \x1b[3J there.
1482 i.rend.Clear()
1483 }
1484 i.prevOverlayOpen = overlayOpen
1485 if len(suggest) > 0 {
1486 // One blank row above the popup so it doesn't sit flush
1487 // against the chat / welcome content above.
1488 suggest = append([]string{""}, suggest...)
1489 }
1490
1491 // Busy prefix shown at the far left of the status bar. The
1492 // spinner glyph and its funny-line message share the `zot`
1493 // label colour (Theme.Assistant) so the whole "who's working"
1494 // band reads at a glance. Elapsed time stays muted because it
1495 // drifts every second and shouldn't grab focus.
1496 var busyPrefix string
1497 if i.busy {
1498 busyPrefix = fmt.Sprintf("%s %s %s %s",
1499 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()),
1500 i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()),
1501 i.cfg.Theme.FG256(i.cfg.Theme.Muted, "-"),
1502 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1503 )
1504 }
1505
1506 ctxMax := 0
1507 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
1508 ctxMax = m.ContextWindow
1509 }
1510 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
1511 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
1512 workingWithStatus := statusPosition == workingPosition
1513 statusBusyPrefix := ""
1514 if workingWithStatus {
1515 statusBusyPrefix = busyPrefix
1516 }
1517 statusLines := tui.StatusBar(tui.StatusBarParams{
1518 Theme: i.cfg.Theme,
1519 Provider: i.cfg.Provider,
1520 Model: i.cfg.Model,
1521 Reasoning: i.cfg.Reasoning,
1522 Busy: i.busy,
1523 BusyPrefix: statusBusyPrefix,
1524 CWD: i.cfg.CWD,
1525 Locked: i.cfg.Sandbox.Locked(),
1526 NoYolo: i.cfg.NoYolo,
1527 Usage: i.cumUsage,
1528 Subscription: i.cfg.AuthMethod == "oauth",
1529 ContextUsed: i.lastCtxInput,
1530 ContextMax: ctxMax,
1531 AutoCompacting: i.autoCompacting,
1532 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1533 Cols: cols,
1534 })
1535 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
1536 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
1537 i.ed.Prompt = ""
1538 } else {
1539 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1540 }
1541 edLines, curR, curC := i.ed.Render(cols)
1542 var workingLines []string
1543 if busyPrefix != "" && !workingWithStatus {
1544 workingLines = []string{" " + busyPrefix}
1545 }
1546 inputCursorOffset := 0
1547 inputCursorColOffset := 0
1548 switch inputStyle {
1549 case tui.InputStyleLines:
1550 edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1551 inputCursorOffset = 1
1552 case tui.InputStyleBlock:
1553 edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)
1554 inputCursorOffset = 1
1555 inputCursorColOffset = 2
1556 }
1557
1558 // "Sliding in" chips for messages the user typed while a turn is
1559 // in flight. Shown directly above the status bar so they're close
1560 // to the editor but don't push the chat around.
1561 var queue []string
1562 queued := append([]string(nil), i.queued...)
1563 if i.agent != nil {
1564 queued = append(queued, i.agent.PendingQueuedMessages()...)
1565 }
1566 if len(queued) > 0 {
1567 queue = append(queue, "")
1568 for _, q := range queued {
1569 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1570 text := truncateLine(q, cols-17)
1571 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1572 }
1573 // Hint row, rendered in the same muted tone as the model
1574 // info on the status bar so it reads as ambient metadata
1575 // rather than a chip. Tells the user how to recover the
1576 // most recent queued message back into the editor.
1577 hint := " Press " + slideBackChordHint() + " to slide back into input"
1578 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1579 }
1580
1581 // Bottom-sticky sections (always visible, never scroll). Each
1582 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1583 // is preceded by one blank row so it has air above the chat
1584 // content. The status block and editor get their own dedicated
1585 // blanks so spacing stays consistent whether or not a dialog or
1586 // popup is showing.
1587 bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1588 inputStartRow := -1
1589 if len(dialog) > 0 {
1590 bottom = append(bottom, "")
1591 }
1592 bottom = append(bottom, dialog...)
1593 if len(dialog) == 0 && len(suggest) == 0 {
1594 bottom = append(bottom, i.pinnedSkillsNotice(cols)...)
1595 }
1596 // The swarm dashboard owns the bottom of the screen while it's
1597 // active: it has its own inline editors for spawn (`n`) and
1598 // prompt (`p`), so the main input would be a confusing second
1599 // caret. The suggest popup, sliding-in queue, status block, and
1600 // main editor are all hidden underneath it. Keystrokes still
1601 // reach handleKey — it routes them to swarmDialog.HandleKey
1602 // before the editor ever sees them — so the only effect of this
1603 // branch is visual.
1604 if !i.swarmDialog.Active() {
1605 bottom = append(bottom, suggest...)
1606 bottom = append(bottom, queue...)
1607 lineInput := inputStyle == tui.InputStyleLines
1608 statusBelow := statusPosition == tui.StatusPositionBelowInput
1609 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1610
1611 var aboveInput []string
1612 if !statusBelow {
1613 aboveInput = append(aboveInput, statusLines...)
1614 }
1615 if !workingBelow {
1616 aboveInput = append(aboveInput, workingLines...)
1617 }
1618 var belowInput []string
1619 if workingBelow {
1620 belowInput = append(belowInput, workingLines...)
1621 }
1622 if statusBelow {
1623 belowInput = append(belowInput, statusLines...)
1624 }
1625
1626 bottom = append(bottom, "")
1627 bottom = append(bottom, aboveInput...)
1628 needsGapAboveInput := len(aboveInput) > 0 && !lineInput
1629 if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
1630 needsGapAboveInput = true
1631 }
1632 if needsGapAboveInput {
1633 bottom = append(bottom, "")
1634 }
1635 inputStartRow = len(bottom)
1636 bottom = append(bottom, edLines...)
1637 if len(belowInput) > 0 {
1638 if !lineInput {
1639 bottom = append(bottom, "")
1640 }
1641 bottom = append(bottom, belowInput...)
1642 }
1643 }
1644
1645 _, rows := i.cfg.Terminal.Size()
1646 chatRows := rows - len(bottom)
1647 if chatRows < 1 {
1648 chatRows = 1
1649 }
1650
1651 // Auto-follow guard: when the user has scrolled up (scrollOffset
1652 // > 0) and the agent appends new content below the viewport while
1653 // they're reading, compensate so the visible content stays
1654 // anchored. scrollOffset is measured from the bottom of `chat`,
1655 // so without compensation a growing buffer pushes the window
1656 // downward through the content and the lines the user was
1657 // reading scroll off the top.
1658 //
1659 // Skip compensation when the terminal width changed (a resize
1660 // reflows the whole buffer and the line-count delta no longer
1661 // corresponds to appended content) and when scrollOffset is 0
1662 // (the user is following the tail and wants new content to push
1663 // the view down as usual).
1664 //
1665 // The window the user sees starts at row
1666 // start = len(chat) - scrollOffset - chatRows
1667 // so to keep `start` fixed across a redraw we must offset by both
1668 // the buffer growth (len delta) AND the viewport-height change
1669 // (chatRows delta, e.g. the status band or sliding-in queue
1670 // appearing during a turn). Compensating only for the len delta
1671 // let a shrinking chatRows pull the window toward the tail, which
1672 // read as the viewport jumping to the bottom whenever the agent
1673 // streamed text or a tool call grew the bottom band.
1674 if i.jumpRow != nil {
1675 i.scrollOffset = max(0, len(chat)-*i.jumpRow-chatRows)
1676 i.jumpRow = nil
1677 i.prevScrollOffset = i.scrollOffset
1678 } else if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1679 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1680 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1681 }
1682 i.prevChatLen = len(chat)
1683 i.prevChatCols = cols
1684 i.prevChatRows = chatRows
1685
1686 // Apply scroll offset to the chat slice.
1687 maxOffset := len(chat) - chatRows
1688 if maxOffset < 0 {
1689 maxOffset = 0
1690 }
1691 // Tail-render expansion: if the user has scrolled to (or above)
1692 // the top of the currently rendered tail and there are still
1693 // truncated messages above, widen view.TailLimit and rebuild.
1694 // The chat cache is keyed on tailLimit so the next cachedChatLocked
1695 // will re-issue Build instead of returning the stale slice. We
1696 // rebuild immediately so the same redraw shows the freshly-revealed
1697 // rows; otherwise the user would have to scroll again to see them.
1698 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
1699 prevLen := len(chat)
1700 i.view.TailLimit += resumeTailExpandStep
1701 if i.view.TailLimit >= len(i.view.Messages) {
1702 i.view.TailLimit = 0 // unbounded
1703 }
1704 i.chatCacheValid = false
1705 chat = i.cachedChatLocked(cols)
1706 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1707 chat = chat[:len(chat)-1]
1708 }
1709 // Newly-revealed rows are older messages prepended at the top.
1710 // scrollOffset counts rows from the bottom, so to keep the
1711 // viewport visually anchored on the same content the user was
1712 // looking at we shift it up by the number of rows added. Keep
1713 // the auto-follow baseline (prevChatLen) in sync with the
1714 // post-expansion length too, so the next render's follow guard
1715 // doesn't see this growth as a synthetic delta and yank the
1716 // viewport again.
1717 if grew := len(chat) - prevLen; grew > 0 {
1718 i.scrollOffset += grew
1719 }
1720 i.prevChatLen = len(chat)
1721 i.prevChatCols = cols
1722 maxOffset = len(chat) - chatRows
1723 if maxOffset < 0 {
1724 maxOffset = 0
1725 }
1726 }
1727 if i.scrollOffset > maxOffset {
1728 i.scrollOffset = maxOffset
1729 }
1730 if i.scrollOffset < 0 {
1731 i.scrollOffset = 0
1732 }
1733
1734 var visibleChat []string
1735 if len(chat) <= chatRows {
1736 visibleChat = chat
1737 } else {
1738 end := len(chat) - i.scrollOffset
1739 rawStart := end - chatRows
1740 if rawStart < 0 {
1741 rawStart = 0
1742 }
1743 start := snapViewportStartToImageBlock(chat, rawStart)
1744 // If the snap pulled start upward (an image-block was atomic) while
1745 // the user is scrolling downward, the viewport would sit on the same
1746 // image until the user mashes down past every reserved row. Bump
1747 // scrollOffset past the image so one keypress always clears it.
1748 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
1749 jump := rawStart - start
1750 i.scrollOffset -= jump
1751 if i.scrollOffset < 0 {
1752 i.scrollOffset = 0
1753 }
1754 end = len(chat) - i.scrollOffset
1755 rawStart = end - chatRows
1756 if rawStart < 0 {
1757 rawStart = 0
1758 }
1759 start = snapViewportStartToImageBlock(chat, rawStart)
1760 }
1761 end = start + chatRows
1762 if end > len(chat) {
1763 end = len(chat)
1764 start = end - chatRows
1765 if start < 0 {
1766 start = 0
1767 }
1768 }
1769 visibleChat = chat[start:end]
1770 }
1771 i.prevScrollOffset = i.scrollOffset
1772 visibleChat = clipBottomClippedImages(visibleChat)
1773
1774 // A tiny "scrolled up" indicator in the top-right of the chat pane
1775 // so you know you're not at the bottom. When the viewport was
1776 // parked by /jump we include the turn number so the user remembers
1777 // they're reading history rather than the live conversation.
1778 if i.scrollOffset > 0 && len(visibleChat) > 0 {
1779 var text string
1780 if i.parkedTurn > 0 && i.parkedTotal > 0 {
1781 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
1782 i.parkedTurn, i.parkedTotal, i.scrollOffset)
1783 } else {
1784 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
1785 }
1786 note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)
1787 visibleChat = append([]string{note}, visibleChat...)
1788 if len(visibleChat) > chatRows {
1789 visibleChat = visibleChat[:chatRows]
1790 }
1791 }
1792
1793 // Default: the real terminal cursor sits on the main editor's
1794 // input position. In main-screen log mode cursor rows are relative
1795 // to the fixed bottom band, not the chat transcript.
1796 // dialogLead is 1 when the bottom region prepends a blank above
1797 // the dialog block (whenever a dialog is showing) so popup-side
1798 // cursor positions still land in the right cell.
1799 dialogLead := 0
1800 if len(dialog) > 0 {
1801 dialogLead = 1
1802 }
1803 cursorRow := inputStartRow + inputCursorOffset + curR
1804 cursorCol := curC + inputCursorColOffset
1805 if i.btwDialog.Active() {
1806 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1807 cursorRow = dialogLead + r
1808 cursorCol = c
1809 }
1810 }
1811 if i.dialog.Active() {
1812 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1813 cursorRow = dialogLead + r
1814 cursorCol = c
1815 }
1816 }
1817 if i.llamaDialog.Active() {
1818 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
1819 cursorRow = dialogLead + r
1820 cursorCol = c
1821 } else {
1822 cursorRow = -1
1823 cursorCol = 0
1824 }
1825 }
1826 if i.sessionDialog.Active() {
1827 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1828 cursorRow = dialogLead + r
1829 cursorCol = c
1830 }
1831 }
1832 if i.sessionTreeDialog.Active() {
1833 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1834 cursorRow = dialogLead + r
1835 cursorCol = c
1836 }
1837 }
1838 if i.swarmDialog.Active() {
1839 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1840 cursorRow = dialogLead + r
1841 cursorCol = c
1842 } else {
1843 // Dashboard list / transcript view has no caret. Without
1844 // this branch the default cursorRow points at the
1845 // (hidden) main editor row, so the terminal would draw
1846 // a stray block somewhere in the chat region.
1847 cursorRow = -1
1848 cursorCol = 0
1849 }
1850 }
1851 if i.extPanel.Active() {
1852 cursorRow = -1
1853 cursorCol = 0
1854 }
1855 if i.scrollOffset > 0 {
1856 i.rend.DrawHistory(visibleChat, bottom, cursorRow, cursorCol)
1857 } else {
1858 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1859 }
1860}
1861
1862func hasImageEscape(line string) bool {
1863 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1864}
1865
1866// snapViewportStartToImageBlock treats inline images as atomic blocks for
1867// scrolling. Terminal image protocols draw from a single escape row into a
1868// separate graphics layer; the following blank rows are only zot's reserved
1869// footprint. If the viewport starts on one of those blank rows, there is no
1870// correct partial-image state to render. Snap back to the escape row instead
1871// so the image is either shown from its beginning or skipped entirely.
1872func snapViewportStartToImageBlock(chat []string, start int) int {
1873 if start <= 0 || start >= len(chat) {
1874 return start
1875 }
1876 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1877 return start
1878 }
1879 for k := start - 1; k >= 0; k-- {
1880 line := chat[k]
1881 if hasImageEscape(line) {
1882 return k
1883 }
1884 if !isBoxBlankLine(line) {
1885 break
1886 }
1887 }
1888 return start
1889}
1890
1891const (
1892 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1893 shellEscapeMetaKey = "shell_escape"
1894 shellEscapeCommandMetaKey = "shell_escape_command"
1895)
1896
1897func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1898 if len(msgs) == 0 {
1899 return nil
1900 }
1901 out := make([]provider.Message, 0, len(msgs))
1902 for _, m := range msgs {
1903 if isHiddenTranscriptMessage(m) {
1904 continue
1905 }
1906 out = append(out, m)
1907 }
1908 return out
1909}
1910
1911func isHiddenTranscriptMessage(m provider.Message) bool {
1912 if m.Role != provider.RoleUser || len(m.Content) == 0 {
1913 return false
1914 }
1915 tb, ok := m.Content[0].(provider.TextBlock)
1916 if !ok {
1917 return false
1918 }
1919 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1920}
1921
1922func clipBottomClippedImages(lines []string) []string {
1923 if len(lines) == 0 {
1924 return lines
1925 }
1926 out := append([]string(nil), lines...)
1927 for i, line := range out {
1928 if !hasImageEscape(line) {
1929 continue
1930 }
1931 // Image blocks render as: image escape, zero or more blank
1932 // reservation rows, then the muted "image - ..." info line,
1933 // then one trailing blank. If the info line isn't visible in
1934 // the current chat slice, the image would paint down into the
1935 // fixed status bar area. Suppress that image for this frame.
1936 //
1937 // When the image lives inside a tool box, the reservation rows
1938 // are wrapped in vertical box edges ("│ ... │"); those rows
1939 // look non-blank under a naive whitespace check but are still
1940 // reservation rows for this scan, so treat them as blank.
1941 foundInfo := false
1942 for j := i + 1; j < len(out); j++ {
1943 if strings.Contains(out[j], "image - ") {
1944 foundInfo = true
1945 break
1946 }
1947 if !isBoxBlankLine(out[j]) {
1948 break
1949 }
1950 }
1951 if !foundInfo {
1952 out[i] = ""
1953 }
1954 }
1955 return out
1956}
1957
1958// isBoxBlankLine reports whether line is visually empty after
1959// stripping ANSI escape sequences, surrounding whitespace, and the
1960// vertical box edges drawn by the tool-box renderer. Used by
1961// clipBottomClippedImages so an image's reservation rows still count
1962// as blank when those rows are wrapped in "│ ... │" inside a tool box.
1963func isBoxBlankLine(line string) bool {
1964 stripped := stripANSIBytes(line)
1965 stripped = strings.TrimSpace(stripped)
1966 stripped = strings.Trim(stripped, "│")
1967 stripped = strings.TrimSpace(stripped)
1968 return stripped == ""
1969}
1970
1971// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1972// byte) from s without pulling in the regexp package. Mirrors the
1973// internal helper in package tui; the duplicated copy avoids exporting
1974// it just for one caller.
1975func stripANSIBytes(s string) string {
1976 if !strings.Contains(s, "\x1b") {
1977 return s
1978 }
1979 var b strings.Builder
1980 b.Grow(len(s))
1981 i := 0
1982 for i < len(s) {
1983 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1984 end := i + 2
1985 for end < len(s) {
1986 c := s[end]
1987 end++
1988 if c >= 0x40 && c <= 0x7e {
1989 break
1990 }
1991 }
1992 i = end
1993 continue
1994 }
1995 b.WriteByte(s[i])
1996 i++
1997 }
1998 return b.String()
1999}
2000
2001// truncateLine shortens s so it fits within n display cells, with an
2002// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
2003// novel doesn't blow past the status line.
2004func panelKeyName(k tui.Key) string {
2005 switch k.Kind {
2006 case tui.KeyUp:
2007 return "up"
2008 case tui.KeyDown:
2009 return "down"
2010 case tui.KeyLeft:
2011 return "left"
2012 case tui.KeyRight:
2013 return "right"
2014 case tui.KeyEnter:
2015 return "enter"
2016 case tui.KeyEsc:
2017 return "esc"
2018 case tui.KeyTab:
2019 return "tab"
2020 case tui.KeyBackspace:
2021 return "backspace"
2022 case tui.KeyDelete:
2023 return "delete"
2024 case tui.KeyHome:
2025 return "home"
2026 case tui.KeyEnd:
2027 return "end"
2028 case tui.KeyPageUp:
2029 return "pageup"
2030 case tui.KeyPageDown:
2031 return "pagedown"
2032 case tui.KeyRune:
2033 return "rune"
2034 default:
2035 return "unknown"
2036 }
2037}
2038
2039func panelKeyText(k tui.Key) string {
2040 if k.Kind == tui.KeyRune {
2041 return string(k.Rune)
2042 }
2043 return ""
2044}
2045
2046func truncateLine(s string, n int) string {
2047 if n <= 0 {
2048 return ""
2049 }
2050 // Collapse newlines — chips are single line.
2051 s = strings.ReplaceAll(s, "\n", " ↩ ")
2052 runes := []rune(s)
2053 if len(runes) <= n {
2054 return s
2055 }
2056 if n <= 3 {
2057 return strings.Repeat(".", n)
2058 }
2059 return string(runes[:n-3]) + "..."
2060}
2061
2062// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2063// will exit instead of just clearing input. Long enough to be
2064// deliberate (rules out accidental key chord), short enough that the
2065// hint stays meaningful.
2066const ctrlCExitWindow = 2 * time.Second
2067
2068// armCtrlCExit records the timestamp of the current ctrl+c so the next
2069// one within ctrlCExitWindow exits.
2070func (i *Interactive) armCtrlCExit() {
2071 i.mu.Lock()
2072 i.lastCtrlC = time.Now()
2073 i.mu.Unlock()
2074}
2075
2076// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2077// that another press should now exit.
2078func (i *Interactive) ctrlCExitArmed() bool {
2079 i.mu.Lock()
2080 t := i.lastCtrlC
2081 i.mu.Unlock()
2082 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2083}
2084
2085// clearFileSuggestQuery strips the filter the user typed after the
2086// last "@", leaving the bare "@" so the picker stays open. Called when
2087// navigating between directory levels (Right/Left): the filter applied
2088// to the level the user was on, not the one being entered, so carrying
2089// it forward would wrongly hide the new directory's contents.
2090func (i *Interactive) clearFileSuggestQuery() {
2091 val := i.ed.Value()
2092 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2093 i.ed.SetValue(val[:idx+1])
2094 }
2095}
2096
2097// setToolExpansion updates long tool result expansion and replays the
2098// transcript so already-emitted rows reflect the new state.
2099func (i *Interactive) setToolExpansion(expanded bool) {
2100 i.mu.Lock()
2101 i.view.ExpandAll = expanded
2102 if i.rend != nil {
2103 i.rend.Clear()
2104 }
2105 i.mu.Unlock()
2106 i.invalidate()
2107}
2108
2109func (i *Interactive) toggleToolExpansion() {
2110 i.mu.Lock()
2111 expanded := !i.view.ExpandAll
2112 i.mu.Unlock()
2113 i.setToolExpansion(expanded)
2114}
2115
2116func (i *Interactive) toggleBtwToolExpansion() {
2117 i.btwDialog.ToggleToolExpansion()
2118 i.mu.Lock()
2119 if i.rend != nil {
2120 i.rend.Clear()
2121 }
2122 i.mu.Unlock()
2123 i.invalidate()
2124}
2125
2126// confirmChildActive reports whether an interaction opened from slash input
2127// currently owns the keyboard while a tool confirmation remains pending.
2128func (i *Interactive) confirmChildActive() bool {
2129 return len(i.helpBlock) > 0 ||
2130 len(i.extNotes) > 0 ||
2131 i.dialog.Active() ||
2132 i.modelDialog.Active() ||
2133 i.llamaDialog.Active() ||
2134 i.rescueDialog.Active() ||
2135 i.sessionDialog.Active() ||
2136 i.swarmDialog.Active() ||
2137 i.jumpDialog.Active() ||
2138 i.btwDialog.Active() ||
2139 i.skillsDialog.Active() ||
2140 i.changelogDialog.Active() ||
2141 i.logoutDialog.Active() ||
2142 i.telegramDialog.Active() ||
2143 i.settingsDialog.Active() ||
2144 i.sessionOpsDialog.Active() ||
2145 i.sessionTreeDialog.Active() ||
2146 i.timeline.Active() ||
2147 i.extPanel.Active()
2148}
2149
2150// dialogOwnsInput reports whether a dialog, panel, or focused
2151// confirmation prompt currently consumes keys instead of the main editor.
2152func (i *Interactive) dialogOwnsInput() bool {
2153 return i.confirmDialog.Focused() ||
2154 i.dialog.Active() ||
2155 i.modelDialog.Active() ||
2156 i.llamaDialog.Active() ||
2157 i.rescueDialog.Active() ||
2158 i.sessionDialog.Active() ||
2159 i.swarmDialog.Active() ||
2160 i.jumpDialog.Active() ||
2161 i.btwDialog.Active() ||
2162 i.skillsDialog.Active() ||
2163 i.changelogDialog.Active() ||
2164 i.logoutDialog.Active() ||
2165 i.telegramDialog.Active() ||
2166 i.settingsDialog.Active() ||
2167 i.sessionOpsDialog.Active() ||
2168 i.sessionTreeDialog.Active() ||
2169 i.timeline.Active() ||
2170 i.extPanel.Active()
2171}
2172
2173// restoreConfirmFocus returns input to confirmation after slash input is
2174// cleared or a child interaction closes. A non-empty editor keeps command
2175// input focused, and an active child continues to own its keys.
2176func (i *Interactive) restoreConfirmFocus() {
2177 if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2178 return
2179 }
2180 if !i.ed.IsEmpty() || i.confirmChildActive() {
2181 return
2182 }
2183 i.confirmDialog.Focus()
2184 i.invalidate()
2185}
2186
2187func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2188 defer i.restoreConfirmFocus()
2189
2190 // Dialogs route keys before the main clipboard handler below. Resolve text
2191 // here when a child interaction owns input so every editor and filter sees
2192 // the same KeyPaste event as terminal-native bracketed paste. Main chat is
2193 // left image-first to preserve macOS clipboard image attachments.
2194 if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
2195 resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
2196 if err != nil {
2197 i.mu.Lock()
2198 i.statusErr = "clipboard paste failed: " + err.Error()
2199 i.statusOK = ""
2200 i.mu.Unlock()
2201 return false
2202 }
2203 if pastedText {
2204 k = resolved
2205 i.mu.Lock()
2206 i.statusErr = ""
2207 i.statusOK = ""
2208 i.mu.Unlock()
2209 }
2210 }
2211
2212 // Ctrl and Super chords reach us as modified runes so the keymap can
2213 // bind them on the main input. Dialogs key off bare letters ('r'
2214 // renames, 'q' closes, digits answer confirmations) and must not see
2215 // a chord as its letter, so neutralize it while one owns input.
2216 if isChordRune(k) && i.dialogOwnsInput() {
2217 k = tui.Key{Kind: tui.KeyUnknown}
2218 }
2219
2220 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2221 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
2222 // unexpectedly. The hint message also goes stale; clear it.
2223 if k.Kind != tui.KeyCtrlC {
2224 i.mu.Lock()
2225 if !i.lastCtrlC.IsZero() {
2226 i.lastCtrlC = time.Time{}
2227 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
2228 i.statusOK = ""
2229 }
2230 }
2231 i.mu.Unlock()
2232 }
2233
2234 // Dialogs consume keys while open (except ctrl+c, which always closes them).
2235
2236 // Confirmation owns input by default. Pressing / deliberately moves
2237 // focus to the main editor without answering the pending request, so
2238 // slash commands and the dialogs they open can run first.
2239 if i.confirmDialog.Focused() {
2240 if k.Kind == tui.KeyRune && k.Rune == '/' {
2241 i.confirmDialog.Blur()
2242 i.ed.Insert("/")
2243 i.suggest.Reset()
2244 i.invalidate()
2245 return false
2246 }
2247 if k.Kind == tui.KeyCtrlO {
2248 if i.btwDialog != nil && i.btwDialog.Active() {
2249 i.toggleBtwToolExpansion()
2250 } else {
2251 i.toggleToolExpansion()
2252 }
2253 return false
2254 }
2255 i.confirmDialog.HandleKey(k)
2256 i.invalidate()
2257 return false
2258 }
2259 if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2260 i.ed.Clear()
2261 i.suggest.Reset()
2262 i.fileSuggest.Reset()
2263 i.confirmDialog.Focus()
2264 i.invalidate()
2265 return false
2266 }
2267 if i.dialog.Active() {
2268 if k.Kind == tui.KeyCtrlC {
2269 i.dialog.Close()
2270 if i.cfg.AuthManager != nil {
2271 i.cfg.AuthManager.CancelOAuth()
2272 }
2273 return false
2274 }
2275 act := i.dialog.HandleKey(k)
2276 if act.StartAPIKey {
2277 i.startAPIKeyFlow(act.Provider)
2278 }
2279 if act.StartOAuth {
2280 i.startOAuthFlow(act.Provider)
2281 }
2282 if act.StartManual {
2283 i.startManualOAuthFlow(act.Provider)
2284 }
2285 if act.SubmitCode != "" {
2286 i.submitManualOAuthCode(act.SubmitCode)
2287 }
2288 if act.SaveLlama {
2289 if act.Provider == provider.LMStudioProviderID {
2290 i.saveLMStudioLogin(act.LlamaURL, act.LlamaAPIKey)
2291 } else {
2292 i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2293 }
2294 }
2295 return false
2296 }
2297 if i.modelDialog.Active() {
2298 if k.Kind == tui.KeyCtrlC {
2299 i.modelDialog.Close()
2300 i.quickModelAssign = 0
2301 return false
2302 }
2303 act := i.modelDialog.HandleKey(k)
2304 if act.Close {
2305 i.quickModelAssign = 0
2306 }
2307 if act.Select {
2308 if i.quickModelAssign > 0 {
2309 i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2310 i.quickModelAssign = 0
2311 } else {
2312 i.applyModelSelection(act.Provider, act.Model)
2313 }
2314 }
2315 return false
2316 }
2317 if i.llamaDialog.Active() {
2318 i.llamaDialog.HandleKey(k)
2319 i.invalidate()
2320 return false
2321 }
2322 if i.rescueDialog.Active() {
2323 if k.Kind == tui.KeyCtrlC {
2324 i.rescueDialog.Close()
2325 i.invalidate()
2326 return false
2327 }
2328 act := i.rescueDialog.HandleKey(k)
2329 if act.Select {
2330 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2331 }
2332 i.invalidate()
2333 return false
2334 }
2335 if i.sessionDialog.Active() {
2336 if k.Kind == tui.KeyCtrlC {
2337 i.sessionDialog.Close()
2338 return false
2339 }
2340 act := i.sessionDialog.HandleKey(k)
2341 if act.Select {
2342 i.applySessionSelection(act.Path)
2343 }
2344 if act.Delete {
2345 i.applySessionDeletion(act.Path)
2346 }
2347 // Always request a redraw after handling a key here: when esc
2348 // closes the picker, the overlay-close detection in the render
2349 // pass needs to run so the tall dialog rows get repainted from
2350 // the chat (otherwise VS Code's retained scrollback leaves a
2351 // duplicate frame on screen).
2352 i.invalidate()
2353 return false
2354 }
2355 if i.swarmDialog.Active() {
2356 if k.Kind == tui.KeyCtrlC {
2357 i.swarmDialog.Close()
2358 i.invalidate()
2359 return false
2360 }
2361 _, msg, errMsg := i.swarmDialog.HandleKey(k)
2362 if msg != "" || errMsg != "" {
2363 i.mu.Lock()
2364 i.statusOK = msg
2365 i.statusErr = errMsg
2366 i.mu.Unlock()
2367 }
2368 i.invalidate()
2369 return false
2370 }
2371 if i.logoutDialog.Active() {
2372 if k.Kind == tui.KeyCtrlC {
2373 i.logoutDialog.Close()
2374 i.invalidate()
2375 return false
2376 }
2377 act := i.logoutDialog.HandleKey(k)
2378 if act.Select {
2379 i.doLogout(act.Target)
2380 }
2381 i.invalidate()
2382 return false
2383 }
2384 if i.telegramDialog.Active() {
2385 if k.Kind == tui.KeyCtrlC {
2386 i.telegramDialog.Close()
2387 i.invalidate()
2388 return false
2389 }
2390 act := i.telegramDialog.HandleKey(k)
2391 if act.Select {
2392 i.doTelegram(act.Action)
2393 }
2394 i.invalidate()
2395 return false
2396 }
2397 if i.settingsDialog.Active() {
2398 if k.Kind == tui.KeyCtrlC {
2399 i.settingsDialog.Close()
2400 i.invalidate()
2401 return false
2402 }
2403 act := i.settingsDialog.HandleKey(k)
2404 if act.ModelShortcutSlot > 0 {
2405 i.openQuickModelPicker(act.ModelShortcutSlot)
2406 }
2407 if act.Toggle {
2408 i.applySettingChange(act)
2409 }
2410 i.invalidate()
2411 return false
2412 }
2413 if i.sessionOpsDialog.Active() {
2414 if k.Kind == tui.KeyCtrlC {
2415 i.sessionOpsDialog.Close()
2416 i.invalidate()
2417 return false
2418 }
2419 act := i.sessionOpsDialog.HandleKey(k)
2420 if act.Select {
2421 i.doSessionOp(act.Action, "")
2422 }
2423 i.invalidate()
2424 return false
2425 }
2426 if i.sessionTreeDialog.Active() {
2427 if k.Kind == tui.KeyCtrlC {
2428 i.sessionTreeDialog.Close()
2429 i.invalidate()
2430 return false
2431 }
2432 act := i.sessionTreeDialog.HandleKey(k)
2433 if act.Select {
2434 i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
2435 }
2436 i.invalidate()
2437 return false
2438 }
2439 if i.extPanel.Active() {
2440 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2441 if i.cfg.Extensions != nil {
2442 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
2443 }
2444 i.extPanel.Close()
2445 i.invalidate()
2446 return false
2447 }
2448 if i.cfg.Extensions != nil {
2449 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
2450 }
2451 return false
2452 }
2453 if i.timeline.Active() {
2454 data := i.timelineData()
2455 act := i.timeline.HandleKey(k, data)
2456 if act.Export {
2457 path, err := i.timeline.Export("", data)
2458 if err != nil {
2459 i.timeline.SetNotice("export failed: " + err.Error())
2460 } else {
2461 i.timeline.SetNotice("exported to " + friendlyPath(path))
2462 }
2463 }
2464 i.mu.Lock()
2465 i.chatCacheValid = false
2466 i.scrollOffset = 0
2467 i.mu.Unlock()
2468 i.invalidate()
2469 return false
2470 }
2471 if i.jumpDialog.Active() {
2472 if k.Kind == tui.KeyCtrlC {
2473 i.jumpDialog.Close()
2474 i.pendingFork = false
2475 return false
2476 }
2477 act := i.jumpDialog.HandleKey(k)
2478 if act.Select {
2479 if i.pendingFork {
2480 i.applyForkSelection(act.MessageIdx)
2481 } else {
2482 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
2483 }
2484 }
2485 // If the user dismissed the dialog without selecting, also
2486 // clear the pending-fork flag so a later plain /jump isn't
2487 // hijacked.
2488 if act.Close {
2489 i.pendingFork = false
2490 }
2491 return false
2492 }
2493 if i.btwDialog.Active() {
2494 if k.Kind == tui.KeyCtrlC {
2495 i.btwDialog.Close()
2496 i.invalidate()
2497 return false
2498 }
2499 if k.Kind == tui.KeyCtrlO {
2500 i.toggleBtwToolExpansion()
2501 return false
2502 }
2503 i.btwDialog.HandleKey(k, i.invalidate)
2504 return false
2505 }
2506 if i.skillsDialog.Active() {
2507 if k.Kind == tui.KeyCtrlC {
2508 i.skillsDialog.Close()
2509 i.invalidate()
2510 return false
2511 }
2512 if !i.handleSkillPinKey(k) {
2513 i.skillsDialog.HandleKey(k)
2514 }
2515 i.invalidate()
2516 return false
2517 }
2518 if i.changelogDialog.Active() {
2519 if closed := i.changelogDialog.HandleKey(k); closed {
2520 // User dismissed; let the parent persist the
2521 // LastChangelogShown marker via the close callback.
2522 if i.cfg.OnChangelogDismiss != nil {
2523 i.cfg.OnChangelogDismiss()
2524 }
2525 }
2526 i.invalidate()
2527 return false
2528 }
2529
2530 // Configured shortcuts only apply to the main input. Every dialog
2531 // above has already consumed its keys, and the confirmation prompt
2532 // keeps focus until the user explicitly moves it with '/'.
2533 if !keymapReservedKey(k) {
2534 if command := lookupKeymap(i.keymap, k); command != "" {
2535 parts := strings.Fields(command)
2536 if len(parts) > 0 && slashCancelsTurn(parts[0]) {
2537 i.cancelAndWaitForIdle()
2538 }
2539 i.ed.Clear()
2540 i.suggest.Reset()
2541 i.fileSuggest.Reset()
2542 return i.runSlash(ctx, command)
2543 }
2544 }
2545
2546 if slot := quickModelShortcutSlot(k); slot > 0 {
2547 i.applyQuickModelShortcut(slot)
2548 return false
2549 }
2550
2551 // Global keys.
2552 switch k.Kind {
2553 case tui.KeyCtrlC:
2554 i.mu.Lock()
2555 loadingSession := i.sessionLoading
2556 i.mu.Unlock()
2557 if loadingSession {
2558 return true
2559 }
2560 // While busy: do NOT cancel the turn. ctrl+c during a
2561 // running turn is almost always reflex muscle memory
2562 // ("be quiet" in a shell) rather than a deliberate
2563 // decision to kill a multi-minute model call that's
2564 // already cost tokens. Use esc to interrupt a turn; use
2565 // a deliberate double-ctrl+c to exit zot entirely. First
2566 // press arms the exit hint, second press within
2567 // ctrlCExitWindow quits.
2568 if i.busy {
2569 if i.ctrlCExitArmed() {
2570 return true
2571 }
2572 i.mu.Lock()
2573 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
2574 i.statusErr = ""
2575 i.mu.Unlock()
2576 i.armCtrlCExit()
2577 return false
2578 }
2579 // Idle: first press clears the editor (and any queued
2580 // follow-up messages); a second press within ctrlCExitWindow
2581 // exits. With both an empty editor and no queue the first
2582 // press still just arms — require a deliberate double-tap.
2583 ag := i.agent
2584 pending := 0
2585 if ag != nil {
2586 pending = ag.QueuedMessageCount()
2587 }
2588 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
2589 if hadInput {
2590 i.ed.Clear()
2591 i.clipboardImages = nil
2592 i.suggest.Reset()
2593 if ag != nil {
2594 ag.DrainQueuedMessages()
2595 }
2596 i.mu.Lock()
2597 i.queued = nil
2598 i.statusOK = "input cleared"
2599 i.statusErr = ""
2600 i.mu.Unlock()
2601 i.armCtrlCExit()
2602 return false
2603 }
2604 if i.ctrlCExitArmed() {
2605 return true
2606 }
2607 i.mu.Lock()
2608 i.statusOK = "press ctrl+c again to exit"
2609 i.statusErr = ""
2610 i.mu.Unlock()
2611 i.armCtrlCExit()
2612 return false
2613 case tui.KeyEsc:
2614 // Esc interrupts a running turn — but only when nothing
2615 // else on screen wants to consume the key first. The slash
2616 // popup has its own Esc behaviour (close + clear editor),
2617 // and transient overlays like the /help block and extension
2618 // notes should dismiss on Esc before we even consider the
2619 // turn. Without these guards, a casual Esc press after
2620 // running /help on a busy turn rips the turn away.
2621 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
2622 break
2623 }
2624 i.mu.Lock()
2625 hadHelp := len(i.helpBlock) > 0
2626 hadNotes := len(i.extNotes) > 0
2627 if hadHelp {
2628 i.helpBlock = nil
2629 }
2630 if hadNotes {
2631 i.extNotes = nil
2632 }
2633 i.mu.Unlock()
2634 if hadHelp || hadNotes {
2635 i.invalidate()
2636 return false
2637 }
2638 if i.busy && i.cancelTurn != nil {
2639 i.cancelTurn()
2640 // If a confirm dialog is pending, refuse it so the agent
2641 // goroutine unblocks and the context cancellation can
2642 // actually take effect.
2643 i.confirmDialog.CancelAll("turn cancelled")
2644 return false
2645 }
2646 case tui.KeyCtrlD:
2647 if i.ed.IsEmpty() && !i.busy {
2648 return true
2649 }
2650 case tui.KeyCtrlL:
2651 i.rend.Clear()
2652 i.invalidate()
2653 return false
2654 case tui.KeyPasteClipboard:
2655 i.pasteClipboard()
2656 return false
2657 case tui.KeyCtrlO:
2658 i.toggleToolExpansion()
2659 return false
2660 case tui.KeyPageUp:
2661 i.scrollBy(+i.chatPage())
2662 return false
2663 case tui.KeyPageDown:
2664 i.scrollBy(-i.chatPage())
2665 return false
2666 case tui.KeyUp:
2667 // Alt/Option+Up: pop the most recently queued ("sliding in")
2668 // message back into the editor so the user can edit and
2669 // resend it. Repeated presses keep peeling messages off the
2670 // tail of the queue; each press *replaces* the editor
2671 // contents (we don't append/push). When the queue is empty
2672 // the keypress falls through to the normal scroll behavior.
2673 if k.Alt {
2674 i.mu.Lock()
2675 var text string
2676 if i.agent != nil {
2677 text, _ = i.agent.PopQueuedMessage()
2678 }
2679 if text == "" {
2680 if n := len(i.queued); n > 0 {
2681 text = i.queued[n-1]
2682 i.queued = i.queued[:n-1]
2683 }
2684 }
2685 i.mu.Unlock()
2686 if text != "" {
2687 i.ed.SetValue(text)
2688 i.inputHistoryIndex = -1
2689 i.invalidate()
2690 return false
2691 }
2692 }
2693 // In multi-line / wrapped input, Up first moves inside the editor.
2694 // At the editor's top edge, plain Up can browse input history when
2695 // history browsing is safe/active; otherwise it falls back to chat
2696 // scrolling, preserving the old single-line scroll behavior.
2697 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2698 if i.ed.MoveVertical(-1) {
2699 i.invalidate()
2700 return false
2701 }
2702 if !k.Alt && i.handleInputHistoryKey(k) {
2703 return false
2704 }
2705 i.scrollBy(+3)
2706 return false
2707 }
2708 case tui.KeyDown:
2709 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2710 if i.ed.MoveVertical(+1) {
2711 i.invalidate()
2712 return false
2713 }
2714 if !k.Alt && i.handleInputHistoryKey(k) {
2715 return false
2716 }
2717 if i.scrollOffset > 0 {
2718 i.scrollBy(-3)
2719 }
2720 return false
2721 }
2722 }
2723
2724 // Note: we intentionally do NOT gate the editor on i.busy here.
2725 // Typing while the agent is working is supported — submitted
2726 // messages are queued and delivered as follow-up turns when the
2727 // current turn ends. See the submit handler below.
2728
2729 if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
2730 i.ed.HandleKey(k)
2731 return false
2732 }
2733
2734 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
2735 if i.suggest.Active(i.ed.Value()) {
2736 switch k.Kind {
2737 case tui.KeyUp:
2738 i.suggest.Up()
2739 return false
2740 case tui.KeyDown:
2741 i.suggest.Down()
2742 return false
2743 case tui.KeyPageUp:
2744 i.suggest.PageUp()
2745 return false
2746 case tui.KeyPageDown:
2747 i.suggest.PageDown()
2748 return false
2749 case tui.KeyTab:
2750 if name := i.suggest.Selection(i.ed.Value()); name != "" {
2751 i.ed.SetValue(name)
2752 i.suggest.Reset()
2753 }
2754 return false
2755 case tui.KeyEnter:
2756 // Enter on an ambiguous or partial slash prefix: complete to the
2757 // currently highlighted command and run it. That way typing
2758 // "/lo" + enter picks whichever of /login or /logout is selected
2759 // in the popup instead of submitting "/lo" as unknown. Also
2760 // clear the editor so the command doesn't linger after the
2761 // dialog opens/closes.
2762 if name := i.suggest.Selection(i.ed.Value()); name != "" {
2763 i.ed.Clear()
2764 i.suggest.Reset()
2765 return i.runSlash(ctx, name)
2766 }
2767 case tui.KeyEsc:
2768 i.ed.Clear()
2769 i.suggest.Reset()
2770 return false
2771 }
2772 }
2773
2774 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
2775 if i.fileSuggest.Active(i.ed.Value()) {
2776 switch k.Kind {
2777 case tui.KeyUp:
2778 i.fileSuggest.Up()
2779 return false
2780 case tui.KeyDown:
2781 i.fileSuggest.Down()
2782 return false
2783 case tui.KeyRight:
2784 // Open selected directory. The filter the user typed picked
2785 // that directory at the current level; once we descend it no
2786 // longer applies to the directory's contents, so clear it.
2787 // Otherwise typing "@eda" then right would re-filter inside
2788 // eda/ by "eda" and show nothing.
2789 if i.fileSuggest.Right() {
2790 i.clearFileSuggestQuery()
2791 }
2792 return false
2793 case tui.KeyLeft:
2794 // Go back to parent directory. Clear the filter for the same
2795 // reason as Right: it was scoped to the level we just left.
2796 if i.fileSuggest.Left() {
2797 i.clearFileSuggestQuery()
2798 }
2799 return false
2800 case tui.KeyEnter:
2801 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
2802 var chip string
2803 if entry.isDir {
2804 chip = "[dir:" + entry.rel + "/]"
2805 } else {
2806 chip = "[file:" + entry.rel + "]"
2807 }
2808 val := i.ed.Value()
2809 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2810 val = val[:idx]
2811 }
2812 i.ed.SetValue(val + chip + " ")
2813 i.fileSuggest.Reset()
2814 }
2815 return false
2816 case tui.KeyEsc:
2817 val := i.ed.Value()
2818 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2819 i.ed.SetValue(val[:idx])
2820 }
2821 i.fileSuggest.Reset()
2822 return false
2823 }
2824 }
2825
2826 // Tab-complete a path token in the editor when no popup is open.
2827 // Recognises tokens that look like paths (start with ~, /, ./, ../
2828 // or contain a slash); shell-style completion expands ~, lists the
2829 // parent dir, and completes the basename to the longest common
2830 // prefix. Single match: full replace and trailing / for dirs.
2831 // No match: no-op. Plain bare words (no slash, no tilde) fall
2832 // through so Tab keeps its current no-op behaviour outside paths.
2833 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
2834 if i.tryPathTabComplete() {
2835 return false
2836 }
2837 }
2838
2839 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2840 i.inputHistoryIndex = -1
2841 }
2842
2843 if k.Kind == tui.KeyEsc {
2844 i.clipboardImages = nil
2845 }
2846 if submit := i.ed.HandleKey(k); submit {
2847 // SubmitValue() expands any [pasted text #N +L lines]
2848 // placeholders back into their bodies; the raw Value()
2849 // is only what the user sees on screen.
2850 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
2851 // Expand [file:name] and [dir:name/] chips to full paths.
2852 text = expandFileChips(text, i.cfg.CWD)
2853 text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
2854 if text == "" && len(images) == 0 {
2855 return false
2856 }
2857 i.clipboardImages = nil
2858 i.ed.Clear()
2859 i.inputHistoryIndex = -1
2860 i.suggest.Reset()
2861 i.fileSuggest.Reset()
2862
2863 if cmd, ok := shellEscapeCommand(text); ok {
2864 i.startShellEscape(ctx, cmd)
2865 return false
2866 }
2867
2868 if looksLikeSlashCommand(text) {
2869 head := text
2870 rest := ""
2871 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
2872 head = text[:idx]
2873 rest = strings.TrimSpace(text[idx:])
2874 }
2875 if !isKnownSlashCommand(text) {
2876 // Try extensions before giving up. Extensions register
2877 // commands by bare name (no leading slash); strip it here.
2878 extName := strings.TrimPrefix(head, "/")
2879 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
2880 go i.invokeExtensionCommand(ctx, extName, rest)
2881 return false
2882 }
2883 i.mu.Lock()
2884 i.statusErr = "unknown command " + head + " — type /help to see the list"
2885 i.statusOK = ""
2886 i.mu.Unlock()
2887 return false
2888 }
2889 // Slash commands run regardless of busy state. Commands that
2890 // would mutate the transcript or replace the agent (/clear,
2891 // /compact, /logout, /login, /model) cancel the active turn
2892 // first and wait for the goroutine to wind down so they don't
2893 // race with a streaming response. Safe commands (/help,
2894 // /jump, /sessions, /jail, /unjail, /exit) run immediately
2895 // without disturbing the active turn.
2896 if slashCancelsTurn(head) {
2897 i.cancelAndWaitForIdle()
2898 }
2899 return i.runSlash(ctx, text)
2900 }
2901
2902 if i.agent == nil {
2903 i.mu.Lock()
2904 i.statusErr = "not logged in. type /login first."
2905 i.mu.Unlock()
2906 return false
2907 }
2908 // Mirror the user's typed prompt into the paired Telegram
2909 // chat (when the bridge is active) so the Telegram thread
2910 // stays a complete record of the session, not just the half
2911 // that originated on the phone. On a goroutine so the
2912 // network write doesn't delay the local turn.
2913 if i.telegramBridge != nil && i.telegramBridge.Active() {
2914 go i.telegramBridge.OnUserTyped(text)
2915 }
2916 // If a turn is already in flight, queue this prompt inside the
2917 // agent loop so it is delivered at the next safe model-call
2918 // boundary instead of waiting for the whole run to finish.
2919 i.mu.Lock()
2920 busy := i.busy
2921 compacting := i.compacting
2922 ag := i.agent
2923 i.mu.Unlock()
2924 if busy {
2925 if len(images) > 0 {
2926 i.mu.Lock()
2927 i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
2928 i.mu.Unlock()
2929 i.invalidate()
2930 return false
2931 }
2932 if ag != nil && !compacting {
2933 ag.QueueMessage(text)
2934 } else {
2935 i.mu.Lock()
2936 i.queued = append(i.queued, text)
2937 i.mu.Unlock()
2938 }
2939 i.invalidate()
2940 return false
2941 }
2942 i.startTurnWithImages(ctx, text, images)
2943 }
2944 return false
2945}
2946
2947func (i *Interactive) pasteClipboard() {
2948 data, ok, err := readClipboardImageAndCleanup(tui.ReadClipboardImagePNG)
2949 if err != nil {
2950 i.mu.Lock()
2951 i.statusErr = "clipboard paste failed: " + err.Error()
2952 i.statusOK = ""
2953 i.mu.Unlock()
2954 return
2955 }
2956 if ok {
2957 i.mu.Lock()
2958 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
2959 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
2960 Marker: marker,
2961 Image: provider.ImageBlock{MimeType: "image/png", Data: data},
2962 })
2963 i.ed.Insert(marker + " ")
2964 i.statusOK = ""
2965 i.statusErr = ""
2966 i.mu.Unlock()
2967 return
2968 }
2969 key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
2970 i.mu.Lock()
2971 defer i.mu.Unlock()
2972 if err != nil {
2973 i.statusErr = "clipboard paste failed: " + err.Error()
2974 i.statusOK = ""
2975 return
2976 }
2977 if pastedText {
2978 i.ed.HandleKey(key)
2979 i.statusErr = ""
2980 i.statusOK = ""
2981 return
2982 }
2983 i.statusErr = "clipboard does not contain text or an image"
2984 i.statusOK = ""
2985}
2986
2987func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
2988 if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
2989 return false
2990 }
2991 // Do not steal normal vertical cursor movement. History browsing can only
2992 // start from an empty editor; once active, Up/Down keep walking
2993 // the ring so repeated presses work even though the editor now
2994 // contains the selected historical prompt.
2995 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
2996 return false
2997 }
2998 hist := i.inputHistory()
2999 if len(hist) == 0 {
3000 return false
3001 }
3002
3003 if i.inputHistoryIndex < 0 {
3004 // Start just after the newest item so Up lands on the most
3005 // recent user prompt. A lone Down from an empty editor is not
3006 // history navigation; let the caller fall through to normal UI
3007 // behavior instead.
3008 if k.Kind != tui.KeyUp {
3009 return false
3010 }
3011 i.inputHistoryIndex = len(hist)
3012 }
3013
3014 switch k.Kind {
3015 case tui.KeyUp:
3016 if i.inputHistoryIndex > 0 {
3017 i.inputHistoryIndex--
3018 }
3019 case tui.KeyDown:
3020 if i.inputHistoryIndex < len(hist) {
3021 i.inputHistoryIndex++
3022 }
3023 }
3024
3025 if i.inputHistoryIndex >= len(hist) {
3026 i.ed.Clear()
3027 } else {
3028 i.ed.SetValue(hist[i.inputHistoryIndex])
3029 }
3030 return true
3031}
3032
3033func (i *Interactive) inputHistory() []string {
3034 if i.agent == nil {
3035 return nil
3036 }
3037 msgs := i.agent.Messages()
3038 hist := make([]string, 0, len(msgs))
3039 for _, m := range msgs {
3040 if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) {
3041 continue
3042 }
3043 text := userMessageText(m)
3044 if m.Meta[shellEscapeMetaKey] == "true" {
3045 // Older sessions lack command metadata. Never recall shell output
3046 // as input or try to recover a command from the terminal log.
3047 cmd := m.Meta[shellEscapeCommandMetaKey]
3048 if strings.TrimSpace(cmd) == "" {
3049 continue
3050 }
3051 text = "!" + cmd
3052 }
3053 if strings.TrimSpace(text) == "" {
3054 continue
3055 }
3056 hist = append(hist, text)
3057 }
3058 return hist
3059}
3060
3061func userMessageText(m provider.Message) string {
3062 var sb strings.Builder
3063 for _, c := range m.Content {
3064 if tb, ok := c.(provider.TextBlock); ok {
3065 if sb.Len() > 0 {
3066 sb.WriteByte('\n')
3067 }
3068 sb.WriteString(tb.Text)
3069 }
3070 }
3071 return sb.String()
3072}
3073
3074// invokeExtensionCommand fires an extension-registered slash command
3075// in a background goroutine, awaits the response, and applies the
3076// requested action (prompt / insert / display / noop). Errors and
3077// timeouts surface as a status_err line.
3078
3079func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
3080 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
3081 if err != nil {
3082 i.mu.Lock()
3083 i.statusErr = "extension /" + name + ": " + err.Error()
3084 i.statusOK = ""
3085 i.mu.Unlock()
3086 i.invalidate()
3087 return
3088 }
3089 if resp.Error != "" {
3090 i.mu.Lock()
3091 i.statusErr = "extension /" + name + ": " + resp.Error
3092 i.statusOK = ""
3093 i.mu.Unlock()
3094 i.invalidate()
3095 return
3096 }
3097 switch resp.Action {
3098 case "open_panel":
3099 if resp.OpenPanel != nil {
3100 extName := name
3101 if i.cfg.Extensions != nil {
3102 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3103 extName = owner
3104 }
3105 }
3106 i.OpenPanel(extName, *resp.OpenPanel)
3107 }
3108 case "prompt":
3109 if strings.TrimSpace(resp.Prompt) == "" {
3110 return
3111 }
3112 i.startTurn(i.runCtx, resp.Prompt)
3113 case "insert":
3114 i.ed.Insert(resp.Insert)
3115 i.invalidate()
3116 case "display":
3117 i.appendExtensionNote(name, resp.Display, "info")
3118 case "noop", "":
3119 // nothing
3120 default:
3121 i.mu.Lock()
3122 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3123 i.mu.Unlock()
3124 i.invalidate()
3125 }
3126}
3127
3128// appendExtensionNote renders an extension-originated note in the
3129// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3130// "success" (tool/ok green).
3131func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3132 if msg == "" {
3133 return
3134 }
3135 i.mu.Lock()
3136 defer i.mu.Unlock()
3137 color := i.cfg.Theme.Muted
3138 switch level {
3139 case "warn":
3140 color = i.cfg.Theme.Warning
3141 case "error":
3142 color = i.cfg.Theme.Error
3143 case "success":
3144 color = i.cfg.Theme.Tool
3145 }
3146 prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ")
3147 for _, line := range strings.Split(msg, "\n") {
3148 i.statusOK = "" // clear any stale ok
3149 i.statusErr = ""
3150 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
3151 }
3152 // Extension slash commands complete asynchronously. If their result is
3153 // displayed while confirmation is waiting, keep Esc assigned to the note
3154 // until it is dismissed rather than letting it answer the tool call.
3155 i.confirmDialog.Blur()
3156}
3157
3158// HostHooks implementation for the extension manager. The manager
3159// holds an interface, not a concrete *Interactive, so these methods
3160// are the only thing the manager sees.
3161
3162// Notify is the manager's NotifyFromExt entry point.
3163func (i *Interactive) Notify(extName, level, message string) {
3164 i.appendExtensionNote(extName, message, level)
3165 i.invalidate()
3166}
3167
3168// ClearNotes removes every note line owned by extName from the
3169// bottom-sticky ext-notes block. Extensions use this to retract a
3170// transient status line (e.g. an approval prompt) once it no longer
3171// applies, instead of leaving it stacked forever. Notes from other
3172// extensions and internal notes (auto-compact) are left untouched.
3173func (i *Interactive) ClearNotes(extName string) {
3174 marker := "[" + extName + "] "
3175 i.mu.Lock()
3176 if len(i.extNotes) == 0 {
3177 i.mu.Unlock()
3178 return
3179 }
3180 kept := i.extNotes[:0:0]
3181 changed := false
3182 for _, line := range i.extNotes {
3183 if strings.Contains(line, marker) {
3184 changed = true
3185 continue
3186 }
3187 kept = append(kept, line)
3188 }
3189 if changed {
3190 i.extNotes = kept
3191 }
3192 i.mu.Unlock()
3193 if changed {
3194 i.invalidate()
3195 }
3196}
3197
3198// Submit feeds text through the agent loop as if the user had typed it.
3199func (i *Interactive) Submit(text string) {
3200 if cmd, ok := shellEscapeCommand(text); ok {
3201 i.startShellEscape(i.runCtx, cmd)
3202 return
3203 }
3204 i.startTurn(i.runCtx, text)
3205}
3206
3207// completeStartupPre applies deferred InitialInput after entry.pre finishes.
3208// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
3209// it only pre-fills the editor (CLI-supplied prompts).
3210func (i *Interactive) completeStartupPre() {
3211 i.mu.Lock()
3212 if !i.awaitingStartupPre {
3213 i.mu.Unlock()
3214 return
3215 }
3216 i.awaitingStartupPre = false
3217 deferred := i.deferredInitialInput
3218 auto := i.autoSubmitDeferred
3219 i.deferredInitialInput = ""
3220 i.autoSubmitDeferred = false
3221 onDone := i.cfg.OnStartupPreDone
3222 i.mu.Unlock()
3223 if onDone != nil {
3224 onDone()
3225 }
3226 i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3227 i.invalidate()
3228}
3229
3230// applyStartupPreResult runs on the TUI event loop so the editor remains
3231// single-threaded. Input entered while resources were reloading wins over the
3232// deferred prefill rather than being overwritten.
3233func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3234 i.refreshSkillPins()
3235 if result.deferred == "" {
3236 return
3237 }
3238 if result.autoSubmit {
3239 i.Submit(result.deferred)
3240 return
3241 }
3242 if i.ed.IsEmpty() {
3243 i.ed.SetValue(result.deferred)
3244 }
3245}
3246
3247// ApplyChangedCWD is called by hosts after a successful /cd hook that do
3248// not provide startup context metadata.
3249func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
3250 i.applyChangedCWD(ag, provider, model, cwd, nil)
3251}
3252
3253// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
3254// startup context into the running TUI after a successful /cd hook.
3255func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3256 i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
3257}
3258
3259func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3260 i.mu.Lock()
3261 i.agent = ag
3262 i.cfg.CWD = cwd
3263 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3264 i.view.StartupContextPaths = nil
3265 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3266 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3267 }
3268 // Re-report the working directory to the terminal so "new tab here"
3269 // tracks the /cd change (OSC 7, see issue #38).
3270 if i.cfg.Terminal != nil {
3271 if seq := tui.ReportCWD(cwd); seq != "" {
3272 _, _ = i.cfg.Terminal.Write([]byte(seq))
3273 }
3274 }
3275 i.cfg.Provider = provider
3276 i.cfg.Model = model
3277 i.toolCalls = map[string]*tui.ToolCallView{}
3278 i.toolOrder = nil
3279 i.toolGate = map[string]int{}
3280 i.helpBlock = nil
3281 i.parkedTurn = 0
3282 i.statusErr = ""
3283 i.mu.Unlock()
3284 i.fileSuggest.Reset()
3285 i.fileSuggest.SetCWD(cwd)
3286 i.invalidate()
3287}
3288
3289// SubmitSlash runs text as a slash command in the TUI as if the user
3290// had typed it. text must start with '/' — callers that hand it
3291// plain prose silently get a no-op so a misbehaving extension can't
3292// run a stray prompt through this path. Read-only commands run in
3293// place; commands that would mutate the transcript or replace the
3294// agent cancel the active turn first via the same path the editor
3295// uses for typed slash commands.
3296func (i *Interactive) SubmitSlash(text string) {
3297 text = strings.TrimSpace(text)
3298 if !strings.HasPrefix(text, "/") {
3299 return
3300 }
3301 head := text
3302 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3303 head = text[:idx]
3304 }
3305 if slashCancelsTurn(head) {
3306 i.cancelAndWaitForIdle()
3307 }
3308 i.runSlash(i.runCtx, text)
3309 i.invalidate()
3310}
3311
3312// SubmitOrQueue runs text immediately if the agent is idle, or
3313// appends it to the pending queue if a turn is already in flight.
3314// Used by the telegram bridge (and by the editor submit path) so
3315// both input sources share the same "queue behind an active turn"
3316// semantics. Images are ignored for now — only the text prompt is
3317// forwarded — because the queued-prompt path is text-only; a
3318// follow-up can expand the queue entry to carry images.
3319func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3320 if cmd, ok := shellEscapeCommand(text); ok {
3321 i.startShellEscape(i.runCtx, cmd)
3322 return
3323 }
3324 i.mu.Lock()
3325 if i.agent == nil {
3326 i.statusErr = "not logged in. type /login first."
3327 i.mu.Unlock()
3328 i.invalidate()
3329 return
3330 }
3331 if i.busy {
3332 // Queue text only; images are dropped for queued prompts. Compaction
3333 // uses the host queue because it has no active agent loop to drain
3334 // Agent.QueueMessage entries.
3335 ag := i.agent
3336 compacting := i.compacting
3337 i.mu.Unlock()
3338 if ag != nil && !compacting {
3339 ag.QueueMessage(text)
3340 } else {
3341 i.mu.Lock()
3342 i.queued = append(i.queued, text)
3343 i.mu.Unlock()
3344 }
3345 i.invalidate()
3346 return
3347 }
3348 i.mu.Unlock()
3349 i.startTurnWithImages(i.runCtx, text, images)
3350}
3351
3352// CancelTurn aborts the active turn if one is running. Used by the
3353// telegram bridge when the paired user sends /stop.
3354// ChangelogVersion returns the version string of the changelog
3355// currently shown (or last shown). Used by the dismiss callback
3356// to store the correct version for dev builds.
3357func (i *Interactive) ChangelogVersion() string {
3358 if i.changelogDialog != nil {
3359 return i.changelogDialog.version
3360 }
3361 return ""
3362}
3363
3364func (i *Interactive) CancelTurn() {
3365 i.mu.Lock()
3366 cancel := i.cancelTurn
3367 i.mu.Unlock()
3368 if cancel != nil {
3369 cancel()
3370 i.confirmDialog.CancelAll("turn cancelled")
3371 }
3372}
3373
3374// Insert places text at the cursor in the editor.
3375func (i *Interactive) Insert(text string) {
3376 i.ed.Insert(text)
3377 i.invalidate()
3378}
3379
3380// Display appends a styled note from extName to the chat without a
3381// model call.
3382func (i *Interactive) Display(extName, text string) {
3383 i.appendExtensionNote(extName, text, "info")
3384 i.invalidate()
3385}
3386
3387func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3388 i.mu.Lock()
3389 defer i.mu.Unlock()
3390 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
3391 i.confirmDialog.Blur()
3392 if i.cfg.Extensions != nil {
3393 cols, rows := i.cfg.Terminal.Size()
3394 _ = cols
3395 _ = rows
3396 }
3397 i.invalidate()
3398}
3399
3400func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
3401 i.mu.Lock()
3402 defer i.mu.Unlock()
3403 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3404 i.extPanel.Update(title, lines, footer)
3405 i.invalidate()
3406 }
3407}
3408
3409func (i *Interactive) ClosePanel(extName, panelID string) {
3410 i.mu.Lock()
3411 defer i.mu.Unlock()
3412 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
3413 i.extPanel.Close()
3414 i.invalidate()
3415 }
3416}
3417
3418func effectiveImageProtocol(override *bool) tui.ImageProtocol {
3419 detected := tui.DetectImageProtocol()
3420 if override == nil {
3421 return detected
3422 }
3423 if !*override {
3424 return tui.ImageProtocolNone
3425 }
3426 return detected
3427}
3428
3429func imageProtocolName(p tui.ImageProtocol) string {
3430 switch p {
3431 case tui.ImageProtocolKitty:
3432 return "kitty/ghostty"
3433 case tui.ImageProtocolITerm2:
3434 return "iTerm2"
3435 default:
3436 return "none"
3437 }
3438}
3439
3440func onOff(v bool) string {
3441 if v {
3442 return "enabled"
3443 }
3444 return "disabled"
3445}
3446
3447func (i *Interactive) applyInputCursorColor() {
3448 if i == nil || i.cfg.Terminal == nil {
3449 return
3450 }
3451 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3452}
3453
3454func reasoningSettingOptions(levels []string) []settingsOption {
3455 descriptions := map[string]string{
3456 "": "no reasoning",
3457 "minimum": "very brief (~1k tokens)",
3458 "low": "light (~2k tokens)",
3459 "medium": "moderate (~8k tokens)",
3460 "high": "deep (~16k tokens)",
3461 "xhigh": "extra-high effort",
3462 "max": "unconstrained effort",
3463 }
3464 options := make([]settingsOption, 0, len(levels))
3465 for _, level := range levels {
3466 label := level
3467 if label == "" {
3468 label = "off"
3469 }
3470 options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
3471 }
3472 return options
3473}
3474
3475func (i *Interactive) reasoningSettingItem() settingsItem {
3476 levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
3477 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3478 desc := "reasoning depth for reasoning-capable models"
3479 hint := ""
3480 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
3481 levels = provider.AvailableReasoningLevels(m)
3482 reasoning = provider.ClampReasoningForModel(m, reasoning)
3483 if !m.Reasoning {
3484 hint = "current model does not support reasoning"
3485 desc += "; current model does not support reasoning"
3486 } else if len(levels) == 1 {
3487 hint = "current model has no adjustable reasoning levels"
3488 desc += "; current model has no adjustable reasoning levels"
3489 } else {
3490 desc += "; only levels supported by the current model are shown"
3491 }
3492 }
3493 options := reasoningSettingOptions(levels)
3494 choice := 0
3495 for idx, opt := range options {
3496 if opt.value == reasoning {
3497 choice = idx
3498 break
3499 }
3500 }
3501
3502 return settingsItem{
3503 key: "reasoning",
3504 label: "reasoning level",
3505 desc: desc,
3506 options: options,
3507 choice: choice,
3508 hint: hint,
3509 }
3510}
3511
3512func (i *Interactive) openReasoningDialog() {
3513 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
3514}
3515
3516func (i *Interactive) openSettingsDialog() {
3517 detected := tui.DetectImageProtocol()
3518 imgEnabled := detected != tui.ImageProtocolNone
3519 if i.cfg.InlineImagesEnabled != nil {
3520 imgEnabled = *i.cfg.InlineImagesEnabled
3521 }
3522 imgDisabled := detected == tui.ImageProtocolNone
3523 imgHint := ""
3524 if imgDisabled {
3525 imgEnabled = false
3526 imgHint = "this terminal does not support inline images"
3527 } else {
3528 imgHint = "terminal supports " + imageProtocolName(detected)
3529 }
3530
3531 autoSwarm := false
3532 if i.cfg.AutoSwarmEnabled != nil {
3533 autoSwarm = *i.cfg.AutoSwarmEnabled
3534 }
3535 autoSwarmDisabled := i.cfg.Swarm == nil
3536 autoSwarmHint := ""
3537 if autoSwarmDisabled {
3538 autoSwarm = false
3539 autoSwarmHint = "swarm supervisor not available in this mode"
3540 }
3541
3542 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3543 openRouterServerTools := i.openRouterServerToolsEnabled()
3544 openRouterServerToolsDisabled := true
3545 openRouterServerToolsHint := ""
3546 switch {
3547 case i.cfg.Provider != "openrouter":
3548 openRouterServerToolsHint = "only available when the current provider is OpenRouter"
3549 case i.cfg.NoTools:
3550 openRouterServerToolsHint = "disabled for this run by --no-tools"
3551 case strings.Contains(strings.ToLower(i.cfg.Model), "@preset/"):
3552 openRouterServerToolsHint = "this preset owns its server-tool configuration"
3553 default:
3554 openRouterServerToolsDisabled = false
3555 }
3556 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3557 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3558 compactMode := i.compactModeEnabled()
3559 collapseToolCall := i.cfg.CollapseToolCall != nil && *i.cfg.CollapseToolCall
3560 showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
3561 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
3562 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
3563 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
3564 quickItems := i.quickModelSettingItems()
3565
3566 autoCompactOptions := []settingsOption{
3567 {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
3568 {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
3569 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
3570 {value: "85", label: "85%", desc: "default balance between history and headroom"},
3571 {value: "90", label: "90%", desc: "retain more history before compacting"},
3572 }
3573 autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
3574 autoCompactChoice := 0
3575 for idx, opt := range autoCompactOptions {
3576 if opt.value == strconv.Itoa(autoCompactThreshold) {
3577 autoCompactChoice = idx
3578 break
3579 }
3580 }
3581
3582 reasoningItem := i.reasoningSettingItem()
3583
3584 themeName := i.cfg.ThemeName
3585 if themeName == "" {
3586 themeName = "auto"
3587 }
3588 if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
3589 themeName = "auto"
3590 i.cfg.ThemeName = ""
3591 if i.cfg.SettingsStore != nil {
3592 _ = i.cfg.SettingsStore.SetTheme("auto")
3593 }
3594 i.applyThemeNow("auto")
3595 }
3596 themeOptions := []settingsOption{}
3597 themeChoice := 0
3598 availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
3599 if i.cfg.ExtensionThemes != nil {
3600 availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
3601 }
3602 for idx, opt := range availableThemes {
3603 themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
3604 if opt.Value == themeName {
3605 themeChoice = idx
3606 }
3607 }
3608
3609 inputStyleOptions := []settingsOption{
3610 {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
3611 {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
3612 {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
3613 }
3614 inputStyleChoice := 0
3615 for idx, opt := range inputStyleOptions {
3616 if opt.value == inputStyle {
3617 inputStyleChoice = idx
3618 break
3619 }
3620 }
3621 statusPositionOptions := []settingsOption{
3622 {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
3623 {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
3624 }
3625 statusPositionChoice := 0
3626 for idx, opt := range statusPositionOptions {
3627 if opt.value == statusPosition {
3628 statusPositionChoice = idx
3629 break
3630 }
3631 }
3632
3633 workingPositionOptions := []settingsOption{
3634 {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
3635 {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
3636 }
3637 workingPositionChoice := 0
3638 for idx, opt := range workingPositionOptions {
3639 if opt.value == workingPosition {
3640 workingPositionChoice = idx
3641 break
3642 }
3643 }
3644
3645 items := []settingsItem{
3646 {
3647 key: "inline_images_enabled",
3648 label: "render images when supported",
3649 desc: "draw screenshots inline instead of showing a text placeholder",
3650 value: imgEnabled,
3651 disabled: imgDisabled,
3652 hint: imgHint,
3653 },
3654 {
3655 key: "auto_swarm_enabled",
3656 label: "auto-swarm",
3657 desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3658 value: autoSwarm,
3659 disabled: autoSwarmDisabled,
3660 hint: autoSwarmHint,
3661 },
3662 {
3663 key: "auto_compact_threshold",
3664 label: "auto-compact threshold",
3665 desc: "choose how full the model context can get before zot condenses conversation history",
3666 options: autoCompactOptions,
3667 choice: autoCompactChoice,
3668 },
3669 {
3670 key: "jail_by_default",
3671 label: "jail new sessions by default",
3672 desc: "confine tools to the session working directory unless /unjail is used",
3673 value: jailByDefault,
3674 },
3675 {
3676 key: "openrouter_server_tools_enabled",
3677 label: "OpenRouter Server Tools",
3678 desc: "advertise beta remote tools such as web search, datetime, and advisor; additional charges may apply",
3679 value: openRouterServerTools && !openRouterServerToolsDisabled,
3680 disabled: openRouterServerToolsDisabled,
3681 hint: openRouterServerToolsHint,
3682 },
3683 {
3684 key: "recursive_file_suggest",
3685 label: "recursive @-file search",
3686 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
3687 value: recursiveFiles,
3688 },
3689 {
3690 key: "fuzzy_skill_suggest",
3691 label: "fuzzy skill suggestions",
3692 desc: "rank /skill: suggestions by name subsequence matches and emphasize matching characters",
3693 value: i.cfg.FuzzySkillSuggest != nil && *i.cfg.FuzzySkillSuggest,
3694 },
3695 {
3696 key: "respect_gitignore",
3697 label: "hide gitignored files in @-picker",
3698 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
3699 value: respectGitignore,
3700 },
3701 {
3702 key: "compact_mode",
3703 label: "compact transcript rendering",
3704 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
3705 value: compactMode,
3706 },
3707 {
3708 key: "collapse_tool_call",
3709 label: "collapse tool call rendering",
3710 desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output",
3711 value: collapseToolCall,
3712 },
3713 {
3714 key: "timestamps",
3715 label: "timestamps",
3716 desc: "configure chat timestamps and their interval",
3717 children: []settingsItem{
3718 {
3719 key: "chat_timestamps",
3720 label: "enabled",
3721 desc: "show stored message times on existing separator rows after messages complete",
3722 value: i.cfg.ChatTimestamps,
3723 },
3724 i.chatTimestampIntervalSetting(),
3725 i.chatTimestampDateSetting(),
3726 },
3727 },
3728 {
3729 key: "show_instructions_at_startup",
3730 label: "show loaded resources at startup",
3731 desc: "list loaded context files, extensions, and user-installed skills above the transcript",
3732 value: showInstructions,
3733 },
3734 {
3735 key: "tui_settings",
3736 label: "tui settings",
3737 desc: "choose input chrome and where status information appears",
3738 children: []settingsItem{
3739 {
3740 key: "tui_input_style",
3741 label: "input style",
3742 desc: "choose between the plain prompt, lines, and a block input area",
3743 options: inputStyleOptions,
3744 choice: inputStyleChoice,
3745 },
3746 {
3747 key: "tui_status_position",
3748 label: "status position",
3749 desc: "place model, usage, and working directory above or below the input",
3750 options: statusPositionOptions,
3751 choice: statusPositionChoice,
3752 },
3753 {
3754 key: "tui_working_position",
3755 label: "working spinner position",
3756 desc: "place the working spinner above or below the input",
3757 options: workingPositionOptions,
3758 choice: workingPositionChoice,
3759 },
3760 },
3761 },
3762 reasoningItem,
3763 {
3764 key: "theme",
3765 label: "color theme",
3766 desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",
3767 options: themeOptions,
3768 choice: themeChoice,
3769 },
3770 }
3771 if len(quickItems) > 0 {
3772 items = append(items, settingsItem{
3773 key: "quick_models",
3774 label: "model shortcuts",
3775 desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
3776 children: quickItems,
3777 })
3778 }
3779 items = i.appendPowerShellSetting(items, runtime.GOOS)
3780 i.settingsDialog.Open(items)
3781}
3782
3783func (i *Interactive) applySettingChange(act settingsAction) {
3784 switch {
3785 case strings.HasPrefix(act.Key, "quick_model_"):
3786 i.applyQuickModelSetting(act.Key, act.StringValue)
3787 case act.Key == "reasoning":
3788 i.applyReasoningSetting(act.StringValue)
3789 case act.Key == "theme":
3790 i.applyThemeSetting(act.StringValue)
3791 case act.Key == "chat_timestamp_date":
3792 i.applyChatTimestampDate(act.StringValue)
3793 case act.Key == "chat_timestamp_interval_minutes":
3794 i.applyChatTimestampInterval(act.StringValue)
3795 case act.Key == "auto_compact_threshold":
3796 i.applyAutoCompactThresholdSetting(act.StringValue)
3797 case act.Key == "tui_input_style":
3798 i.applyTUIInputStyleSetting(act.StringValue)
3799 case act.Key == "tui_status_position":
3800 i.applyTUIStatusPositionSetting(act.StringValue)
3801 case act.Key == "tui_working_position":
3802 i.applyTUIWorkingPositionSetting(act.StringValue)
3803 default:
3804 i.applySettingToggle(act.Key, act.Value)
3805 }
3806}
3807
3808func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
3809 threshold, err := strconv.Atoi(value)
3810 if err != nil {
3811 return
3812 }
3813 threshold = normalizeAutoCompactThreshold(&threshold)
3814 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
3815 if err := store.SetAutoCompactThreshold(threshold); err != nil {
3816 i.mu.Lock()
3817 i.statusErr = "settings: " + err.Error()
3818 i.mu.Unlock()
3819 return
3820 }
3821 }
3822 i.mu.Lock()
3823 i.cfg.AutoCompactThreshold = &threshold
3824 if threshold == 0 {
3825 i.statusOK = "auto-compact threshold off"
3826 } else {
3827 i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
3828 }
3829 i.statusErr = ""
3830 i.mu.Unlock()
3831 i.invalidate()
3832}
3833
3834func (i *Interactive) quickModelSettingItems() []settingsItem {
3835 if len(i.cfg.QuickModelShortcuts) < 9 {
3836 next := make([]QuickModelShortcut, 9)
3837 copy(next, i.cfg.QuickModelShortcuts)
3838 i.cfg.QuickModelShortcuts = next
3839 }
3840 items := make([]settingsItem, 0, 9)
3841 for slot := 1; slot <= 9; slot++ {
3842 items = append(items, i.quickModelSettingItem(slot))
3843 }
3844 return items
3845}
3846
3847func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
3848 current := QuickModelShortcut{}
3849 if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
3850 current = i.cfg.QuickModelShortcuts[slot-1]
3851 }
3852 hint := "not assigned"
3853 if current.Provider != "" && current.Model != "" {
3854 hint = current.Provider + " / " + current.Model
3855 }
3856 return settingsItem{
3857 key: "quick_model_" + strconv.Itoa(slot),
3858 label: "model " + strconv.Itoa(slot),
3859 desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
3860 picker: true,
3861 hint: hint,
3862 }
3863}
3864
3865func (i *Interactive) compactModeEnabled() bool {
3866 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
3867}
3868
3869func quickModelShortcutSlot(k tui.Key) int {
3870 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
3871 return 0
3872 }
3873 if runtime.GOOS == "darwin" {
3874 if !k.Super && !k.Ctrl {
3875 return 0
3876 }
3877 } else if !k.Ctrl {
3878 return 0
3879 }
3880 return int(k.Rune - '0')
3881}
3882
3883func quickModelShortcutPrefix() string {
3884 return "Ctrl"
3885}
3886
3887func quickModelShortcutLabel(slot int) string {
3888 return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
3889}
3890
3891func (i *Interactive) openQuickModelPicker(slot int) {
3892 if slot < 1 || slot > 9 {
3893 return
3894 }
3895 i.quickModelAssign = slot
3896 current := i.cfg.Model
3897 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
3898 current = i.cfg.QuickModelShortcuts[slot-1].Model
3899 }
3900 var loggedIn []string
3901 if i.cfg.LoggedInProviders != nil {
3902 loggedIn = i.cfg.LoggedInProviders()
3903 }
3904 i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
3905}
3906
3907func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
3908 i.setQuickModelShortcut(slot, providerName, model)
3909}
3910
3911func (i *Interactive) applyQuickModelShortcut(slot int) {
3912 if slot < 1 || slot > 9 {
3913 return
3914 }
3915 if i.busy {
3916 i.mu.Lock()
3917 i.statusErr = "cannot switch model while a turn is running"
3918 i.statusOK = ""
3919 i.mu.Unlock()
3920 i.invalidate()
3921 return
3922 }
3923 if len(i.cfg.QuickModelShortcuts) < slot {
3924 i.mu.Lock()
3925 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3926 i.statusOK = ""
3927 i.mu.Unlock()
3928 i.invalidate()
3929 return
3930 }
3931 shortcut := i.cfg.QuickModelShortcuts[slot-1]
3932 if shortcut.Provider == "" || shortcut.Model == "" {
3933 i.mu.Lock()
3934 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
3935 i.statusOK = ""
3936 i.mu.Unlock()
3937 i.invalidate()
3938 return
3939 }
3940 i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
3941 i.invalidate()
3942}
3943
3944func (i *Interactive) applyQuickModelSetting(key, value string) {
3945 slotText := strings.TrimPrefix(key, "quick_model_")
3946 slot, err := strconv.Atoi(slotText)
3947 if err != nil || slot < 1 || slot > 9 {
3948 return
3949 }
3950 providerName, model := "", ""
3951 if value != "" {
3952 parts := strings.SplitN(value, "\t", 2)
3953 if len(parts) == 2 {
3954 providerName, model = parts[0], parts[1]
3955 }
3956 }
3957 i.setQuickModelShortcut(slot, providerName, model)
3958}
3959
3960func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
3961 if len(i.cfg.QuickModelShortcuts) < slot {
3962 next := make([]QuickModelShortcut, slot)
3963 copy(next, i.cfg.QuickModelShortcuts)
3964 i.cfg.QuickModelShortcuts = next
3965 }
3966 i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
3967 if i.cfg.SettingsStore != nil {
3968 if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
3969 i.mu.Lock()
3970 i.statusErr = "settings: " + err.Error()
3971 i.mu.Unlock()
3972 return
3973 }
3974 }
3975 i.mu.Lock()
3976 if model == "" {
3977 i.statusOK = quickModelShortcutLabel(slot) + " cleared"
3978 } else {
3979 i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
3980 }
3981 i.statusErr = ""
3982 i.mu.Unlock()
3983 i.refreshQuickModelSettingsItem(slot)
3984 i.invalidate()
3985}
3986
3987func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3988 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
3989 return
3990 }
3991 key := "quick_model_" + strconv.Itoa(slot)
3992 for idx, it := range i.settingsDialog.items {
3993 if it.key == key {
3994 i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
3995 return
3996 }
3997 }
3998}
3999
4000func (i *Interactive) applySettingToggle(key string, value bool) {
4001 if key == "chat_timestamps" {
4002 i.applyChatTimestamps(value)
4003 return
4004 }
4005 // Every setting toggle forces a full repaint at the end — same
4006 // effect as the user pressing Ctrl+L — so any per-setting visual
4007 // change (image rendering, status copy, future toggles) lands
4008 // immediately instead of waiting for the next diff frame.
4009 defer func() {
4010 if i.rend != nil {
4011 i.rend.Clear()
4012 }
4013 i.invalidate()
4014 }()
4015 switch key {
4016 case "inline_images_enabled":
4017 val := value
4018 i.cfg.InlineImagesEnabled = &val
4019 if i.cfg.SettingsStore != nil {
4020 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
4021 i.mu.Lock()
4022 i.statusErr = "settings: " + err.Error()
4023 i.mu.Unlock()
4024 return
4025 }
4026 }
4027 i.mu.Lock()
4028 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
4029 i.view.InvalidateRenderCache()
4030 i.statusOK = "inline image rendering " + onOff(value)
4031 i.statusErr = ""
4032 i.mu.Unlock()
4033 case "auto_swarm_enabled":
4034 val := value
4035 i.cfg.AutoSwarmEnabled = &val
4036 if i.cfg.SettingsStore != nil {
4037 if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
4038 i.mu.Lock()
4039 i.statusErr = "settings: " + err.Error()
4040 i.mu.Unlock()
4041 return
4042 }
4043 }
4044 // Add/remove the swarm_spawn tool on the live agent so the
4045 // model's tools[] list reflects the toggle on the next turn.
4046 // Without this the tool stays advertised after a disable and
4047 // the model keeps trying to call it.
4048 i.applyAutoSwarmTool(value)
4049 // Also swap the system-prompt addendum in/out so the model
4050 // knows to use the tool proactively (or stops referencing it
4051 // after a disable).
4052 i.applyAutoSwarmSystemPrompt(value)
4053 i.mu.Lock()
4054 i.statusOK = "auto-swarm " + onOff(value)
4055 i.statusErr = ""
4056 i.mu.Unlock()
4057 case "jail_by_default":
4058 val := value
4059 i.cfg.JailByDefault = &val
4060 if i.cfg.SettingsStore != nil {
4061 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
4062 i.mu.Lock()
4063 i.statusErr = "settings: " + err.Error()
4064 i.mu.Unlock()
4065 return
4066 }
4067 }
4068 if i.cfg.Sandbox != nil {
4069 if value {
4070 i.cfg.Sandbox.Lock()
4071 } else {
4072 i.cfg.Sandbox.Unlock()
4073 }
4074 }
4075 i.mu.Lock()
4076 i.statusOK = "jail by default " + onOff(value)
4077 i.statusErr = ""
4078 i.mu.Unlock()
4079 case "powershell_enabled":
4080 i.applyPowerShellSetting(value, runtime.GOOS)
4081 case "openrouter_server_tools_enabled":
4082 val := value
4083 i.cfg.OpenRouterServerToolsEnabled = &val
4084 if i.cfg.SettingsStore != nil {
4085 if err := i.cfg.SettingsStore.SetOpenRouterServerTools(value); err != nil {
4086 i.mu.Lock()
4087 i.statusErr = "settings: " + err.Error()
4088 i.mu.Unlock()
4089 return
4090 }
4091 }
4092 i.applyOpenRouterServerTools(value)
4093 i.mu.Lock()
4094 i.statusOK = "OpenRouter server tools " + onOff(value)
4095 i.statusErr = ""
4096 i.mu.Unlock()
4097 case "fuzzy_skill_suggest":
4098 if i.cfg.SettingsStore != nil {
4099 if err := i.cfg.SettingsStore.SetFuzzySkillSuggest(value); err != nil {
4100 i.mu.Lock()
4101 i.statusErr = "settings: " + err.Error()
4102 i.mu.Unlock()
4103 return
4104 }
4105 }
4106 i.cfg.FuzzySkillSuggest = &value
4107 i.suggest.SetFuzzySkills(value)
4108 i.mu.Lock()
4109 i.statusOK = "fuzzy skill suggestions " + onOff(value)
4110 i.statusErr = ""
4111 i.mu.Unlock()
4112 case "recursive_file_suggest":
4113 val := value
4114 i.cfg.RecursiveFileSuggest = &val
4115 if i.cfg.SettingsStore != nil {
4116 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
4117 i.mu.Lock()
4118 i.statusErr = "settings: " + err.Error()
4119 i.mu.Unlock()
4120 return
4121 }
4122 }
4123 // Flip the live picker so the next @ reflects the new mode
4124 // without restarting zot. SetRecursive drops its cache.
4125 i.fileSuggest.SetRecursive(value)
4126 i.mu.Lock()
4127 i.statusOK = "recursive @-file search " + onOff(value)
4128 i.statusErr = ""
4129 i.mu.Unlock()
4130 case "respect_gitignore":
4131 val := value
4132 i.cfg.RespectGitignore = &val
4133 if i.cfg.SettingsStore != nil {
4134 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
4135 i.mu.Lock()
4136 i.statusErr = "settings: " + err.Error()
4137 i.mu.Unlock()
4138 return
4139 }
4140 }
4141 i.fileSuggest.SetRespectGitignore(value)
4142 i.mu.Lock()
4143 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
4144 i.statusErr = ""
4145 i.mu.Unlock()
4146 case "compact_mode":
4147 val := value
4148 i.cfg.CompactMode = &val
4149 if i.cfg.SettingsStore != nil {
4150 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
4151 i.mu.Lock()
4152 i.statusErr = "settings: " + err.Error()
4153 i.mu.Unlock()
4154 return
4155 }
4156 }
4157 i.mu.Lock()
4158 i.view.CompactMode = value
4159 i.view.InvalidateRenderCache()
4160 i.statusOK = "compact transcript rendering " + onOff(value)
4161 i.statusErr = ""
4162 i.mu.Unlock()
4163 case "collapse_tool_call":
4164 val := value
4165 i.cfg.CollapseToolCall = &val
4166 if i.cfg.SettingsStore != nil {
4167 if err := i.cfg.SettingsStore.SetCollapseToolCall(value); err != nil {
4168 i.mu.Lock()
4169 i.statusErr = "settings: " + err.Error()
4170 i.mu.Unlock()
4171 return
4172 }
4173 }
4174 i.mu.Lock()
4175 i.view.CollapseToolCall = value
4176 i.view.InvalidateRenderCache()
4177 i.statusOK = "collapse tool call rendering " + onOff(value)
4178 i.statusErr = ""
4179 i.mu.Unlock()
4180 case "show_instructions_at_startup":
4181 val := value
4182 i.cfg.ShowInstructionsAtStartup = &val
4183 if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
4184 if err := store.SetShowInstructionsAtStartup(value); err != nil {
4185 i.mu.Lock()
4186 i.statusErr = "settings: " + err.Error()
4187 i.mu.Unlock()
4188 return
4189 }
4190 }
4191 i.mu.Lock()
4192 i.view.StartupAgentName = ""
4193 i.view.StartupContextPaths = nil
4194 i.view.StartupExtensionNames = nil
4195 i.view.StartupSkillNames = nil
4196 if value {
4197 i.view.StartupAgentName = i.cfg.StartupAgentName
4198 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
4199 i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
4200 i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
4201 }
4202 i.statusOK = "show loaded resources at startup " + onOff(value)
4203 i.statusErr = ""
4204 i.mu.Unlock()
4205 }
4206}
4207
4208func (i *Interactive) applyTUIInputStyleSetting(style string) {
4209 defer func() {
4210 if i.rend != nil {
4211 i.rend.Clear()
4212 }
4213 i.invalidate()
4214 }()
4215 style = tui.NormalizeInputStyle(style)
4216 i.cfg.TUIInputStyle = style
4217 i.applyInputCursorColor()
4218 if i.cfg.SettingsStore != nil {
4219 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
4220 i.mu.Lock()
4221 i.statusErr = "settings: " + err.Error()
4222 i.mu.Unlock()
4223 return
4224 }
4225 }
4226 i.mu.Lock()
4227 i.statusOK = "input style " + style
4228 i.statusErr = ""
4229 i.mu.Unlock()
4230}
4231
4232func (i *Interactive) applyTUIStatusPositionSetting(position string) {
4233 defer func() {
4234 if i.rend != nil {
4235 i.rend.Clear()
4236 }
4237 i.invalidate()
4238 }()
4239 position = tui.NormalizeStatusPosition(position)
4240 i.cfg.TUIStatusPosition = position
4241 if i.cfg.SettingsStore != nil {
4242 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
4243 i.mu.Lock()
4244 i.statusErr = "settings: " + err.Error()
4245 i.mu.Unlock()
4246 return
4247 }
4248 }
4249 i.mu.Lock()
4250 label := "above input"
4251 if position == tui.StatusPositionBelowInput {
4252 label = "below input"
4253 }
4254 i.statusOK = "status position " + label
4255 i.statusErr = ""
4256 i.mu.Unlock()
4257}
4258
4259func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
4260 defer func() {
4261 if i.rend != nil {
4262 i.rend.Clear()
4263 }
4264 i.invalidate()
4265 }()
4266 position = tui.NormalizeWorkingPosition(position)
4267 i.cfg.TUIWorkingPosition = position
4268 if i.cfg.SettingsStore != nil {
4269 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
4270 i.mu.Lock()
4271 i.statusErr = "settings: " + err.Error()
4272 i.mu.Unlock()
4273 return
4274 }
4275 }
4276 i.mu.Lock()
4277 label := "above input"
4278 if position == tui.WorkingPositionBelowInput {
4279 label = "below input"
4280 }
4281 i.statusOK = "working spinner position " + label
4282 i.statusErr = ""
4283 i.mu.Unlock()
4284}
4285
4286func (i *Interactive) applyThemeSetting(name string) {
4287 if i.cfg.SettingsStore != nil {
4288 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
4289 i.mu.Lock()
4290 i.statusErr = "settings: " + err.Error()
4291 i.mu.Unlock()
4292 return
4293 }
4294 }
4295 i.cfg.ThemeName = name
4296 if name == "auto" {
4297 i.cfg.ThemeName = ""
4298 }
4299 i.applyThemeNow(name)
4300}
4301
4302func (i *Interactive) applyThemeNow(name string) {
4303 if name == "" {
4304 name = "auto"
4305 }
4306 detected := tui.Dark
4307 if tui.IsLightTheme(i.cfg.Theme) {
4308 detected = tui.Light
4309 }
4310 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
4311 if err != nil {
4312 if i.cfg.SettingsStore != nil {
4313 _ = i.cfg.SettingsStore.SetTheme("auto")
4314 }
4315 i.cfg.ThemeName = ""
4316 th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
4317 i.mu.Lock()
4318 i.statusErr = "theme missing; reset to default"
4319 i.mu.Unlock()
4320 } else {
4321 i.mu.Lock()
4322 label := applied
4323 if label == "" {
4324 label = "auto"
4325 }
4326 i.statusOK = "theme " + label
4327 i.statusErr = ""
4328 i.mu.Unlock()
4329 }
4330 i.cfg.Theme = th
4331 i.view.Theme = th
4332 i.view.InvalidateRenderCache()
4333 i.ed.Prompt = th.AccentBar(th.Accent)
4334 i.applyInputCursorColor()
4335 i.spin.Configure(th)
4336 if i.rend != nil {
4337 i.rend.SetTheme(th)
4338 i.rend.Clear()
4339 }
4340 i.invalidate()
4341}
4342
4343func (i *Interactive) applyReasoningSetting(level string) {
4344 defer func() {
4345 if i.rend != nil {
4346 i.rend.Clear()
4347 }
4348 i.invalidate()
4349 }()
4350 level = provider.NormalizeReasoning(level)
4351 i.cfg.Reasoning = level
4352 if i.cfg.SettingsStore != nil {
4353 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
4354 i.mu.Lock()
4355 i.statusErr = "settings: " + err.Error()
4356 i.mu.Unlock()
4357 return
4358 }
4359 }
4360 i.mu.Lock()
4361 if i.agent != nil {
4362 i.agent.Reasoning = level
4363 }
4364 label := level
4365 if label == "" {
4366 label = "off"
4367 }
4368 i.statusOK = "reasoning level " + label
4369 i.statusErr = ""
4370 i.mu.Unlock()
4371 if i.cfg.OnReasoningChanged != nil {
4372 i.cfg.OnReasoningChanged(level)
4373 }
4374}
4375
4376// buildStudyPrompt returns the canned prompt the /study command
4377// submits to the agent.
4378//
4379// With no argument, /study targets the current directory — the
4380// historical behaviour. With an argument, /study targets that path
4381// instead; either a directory ("read every file in here") or a
4382// single file ("read this file"). The argument can be:
4383//
4384// - a relative path (resolved against cwd)
4385// - an absolute path
4386// - an @-picker chip, which has already been expanded to an
4387// absolute path by expandFileChips before runSlash sees it
4388//
4389// The path is stat'd to pick the right wording ("directory" vs
4390// "file"). If the path doesn't exist, we still build a sensible
4391// prompt rather than erroring — the agent will surface the
4392// missing-file failure itself when it tries to read it, which is
4393// more useful than a refusal here.
4394func buildStudyPrompt(arg, cwd string) string {
4395 arg = strings.TrimSpace(arg)
4396 if arg == "" {
4397 return "Read and understand everything in the current directory."
4398 }
4399 abs := arg
4400 if !filepath.IsAbs(abs) {
4401 abs = filepath.Join(cwd, abs)
4402 }
4403 display := arg
4404 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
4405 display = rel
4406 }
4407 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
4408 return "Read and understand the file " + display + "."
4409 }
4410 return "Read and understand everything in the directory " + display + "."
4411}
4412
4413// tryPathTabCompleteEditor looks at ed's current value, finds the
4414// path-like token immediately before the cursor (the cursor is always
4415// at the end of the buffer after a keystroke, so "before the cursor"
4416// is the trailing non-whitespace run), and rewrites it to its shell-
4417// style completion against the filesystem.
4418//
4419// Returns true when it consumed the Tab keystroke (token recognised,
4420// completion attempted — even if no candidates matched, the keystroke
4421// is still consumed so it doesn't insert a literal tab character).
4422// Returns false when the token doesn't look like a path; callers then
4423// let Tab fall through to its normal no-op.
4424//
4425// Recognised path shapes:
4426// - ~ or ~/foo expanded via os.UserHomeDir()
4427// - /abs/path or /abs/path/foo absolute
4428// - ./foo, ../foo, foo/bar relative to cwd
4429//
4430// A bare word like "hello" is not treated as a path so plain text
4431// keeps Tab as a literal no-op.
4432//
4433// Free function (not a method) so the same logic runs against the
4434// editor instances owned by btwDialog and swarmDialog without each
4435// dialog needing its own copy.
4436func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4437 if ed == nil {
4438 return false
4439 }
4440 val := ed.Value()
4441 // Find the trailing run of non-whitespace.
4442 start := len(val)
4443 for start > 0 {
4444 r := val[start-1]
4445 if r == ' ' || r == '\t' || r == '\n' {
4446 break
4447 }
4448 start--
4449 }
4450 token := val[start:]
4451 if token == "" {
4452 return false
4453 }
4454 if !looksLikePathToken(token) {
4455 return false
4456 }
4457
4458 // Resolve the absolute parent directory + base prefix to match.
4459 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
4460 if !ok {
4461 return true
4462 }
4463 entries, err := os.ReadDir(parentAbs)
4464 if err != nil {
4465 return true
4466 }
4467 var names []string
4468 var isDir []bool
4469 for _, e := range entries {
4470 name := e.Name()
4471 if !strings.HasPrefix(name, basePrefix) {
4472 continue
4473 }
4474 // Hide dotfiles unless the user explicitly typed a leading dot,
4475 // mirroring bash's default behaviour.
4476 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
4477 continue
4478 }
4479 names = append(names, name)
4480 isDir = append(isDir, e.IsDir())
4481 }
4482 if len(names) == 0 {
4483 return true
4484 }
4485
4486 var completed string
4487 var completedIsDir bool
4488 if len(names) == 1 {
4489 completed = names[0]
4490 completedIsDir = isDir[0]
4491 } else {
4492 completed = longestCommonPrefix(names)
4493 if completed == basePrefix {
4494 // Already at the deepest unambiguous prefix; nothing to add.
4495 return true
4496 }
4497 }
4498
4499 // Build the replacement token in the same display form the user
4500 // typed (preserve ~ vs absolute vs relative).
4501 newToken := displayParent + completed
4502 if len(names) == 1 && completedIsDir {
4503 newToken += "/"
4504 }
4505
4506 ed.SetValue(val[:start] + newToken)
4507 return true
4508}
4509
4510// tryPathTabComplete is the Interactive-bound convenience wrapper.
4511// It calls the free helper against the main editor and invalidates
4512// the frame on a successful rewrite.
4513func (i *Interactive) tryPathTabComplete() bool {
4514 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
4515 i.invalidate()
4516 return true
4517 }
4518 return false
4519}
4520
4521// looksLikePathToken reports whether tok is shaped like a filesystem
4522// path. Paths must either start with ~, /, ./, ../ or contain a /.
4523// Plain words are excluded so Tab on "hello" stays a no-op.
4524func looksLikePathToken(tok string) bool {
4525 if tok == "" {
4526 return false
4527 }
4528 if tok[0] == '~' || tok[0] == '/' {
4529 return true
4530 }
4531 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
4532 return true
4533 }
4534 return strings.Contains(tok, "/")
4535}
4536
4537// resolvePathTabToken splits tok into (absolute parent dir, basename
4538// prefix to match, display-form parent the user typed). ok is false
4539// when the parent dir can't be resolved (e.g. ~ with no $HOME).
4540func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
4541 // Detect ~ expansion.
4542 expanded := tok
4543 homePrefix := ""
4544 if tok == "~" {
4545 home, err := os.UserHomeDir()
4546 if err != nil || home == "" {
4547 return "", "", "", false
4548 }
4549 // "~" alone: complete in $HOME. parent = home, base = "".
4550 return home, "", "~/", true
4551 }
4552 if strings.HasPrefix(tok, "~/") {
4553 home, err := os.UserHomeDir()
4554 if err != nil || home == "" {
4555 return "", "", "", false
4556 }
4557 expanded = home + tok[1:]
4558 homePrefix = "~"
4559 }
4560
4561 dir, base := splitDirBase(expanded)
4562 if !filepath.IsAbs(dir) {
4563 dir = filepath.Join(cwd, dir)
4564 }
4565
4566 // Reconstruct the display form the user typed for the parent,
4567 // keeping ~ when they used it. The base is dropped — the caller
4568 // substitutes the completed name.
4569 display := tok[:len(tok)-len(base)]
4570 if homePrefix != "" && !strings.HasPrefix(display, "~") {
4571 display = homePrefix + display[len(homePrefix):]
4572 }
4573 return dir, base, display, true
4574}
4575
4576// splitDirBase is like filepath.Split but preserves the trailing
4577// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
4578// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
4579// the trailing separator when non-empty so callers can rebuild paths
4580// by concatenation.
4581func splitDirBase(p string) (dir, base string) {
4582 if p == "" {
4583 return ".", ""
4584 }
4585 i := strings.LastIndex(p, "/")
4586 if i < 0 {
4587 return ".", p
4588 }
4589 return p[:i+1], p[i+1:]
4590}
4591
4592func longestCommonPrefix(ss []string) string {
4593 if len(ss) == 0 {
4594 return ""
4595 }
4596 prefix := ss[0]
4597 for _, s := range ss[1:] {
4598 n := 0
4599 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
4600 n++
4601 }
4602 prefix = prefix[:n]
4603 if prefix == "" {
4604 return ""
4605 }
4606 }
4607 return prefix
4608}
4609
4610func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4611 parts := strings.Fields(cmd)
4612 if len(parts) == 0 {
4613 return false
4614 }
4615
4616 if isSkillCommand(cmd) {
4617 var available []*skills.Skill
4618 if i.cfg.SkillSnapshot != nil {
4619 available = i.cfg.SkillSnapshot()
4620 }
4621 prompt, _, err := expandSkillCommand(cmd, available)
4622 if err != nil {
4623 i.mu.Lock()
4624 i.statusErr = err.Error()
4625 i.statusOK = ""
4626 i.mu.Unlock()
4627 return false
4628 }
4629 i.submitOrQueuePrompt(ctx, prompt)
4630 return false
4631 }
4632
4633 switch strings.ToLower(parts[0]) {
4634 case "/exit":
4635 return true
4636 case "/clear":
4637 if i.agent != nil {
4638 i.agent.SetMessages(nil)
4639 }
4640 i.mu.Lock()
4641 i.toolCalls = map[string]*tui.ToolCallView{}
4642 i.toolOrder = nil
4643 i.toolGate = map[string]int{}
4644 i.statusErr = ""
4645 i.statusOK = ""
4646 i.helpBlock = nil
4647 i.parkedTurn = 0
4648 i.parkedTotal = 0
4649 i.scrollOffset = 0
4650 i.extNotes = nil
4651 i.reloadErrors = nil
4652 i.view.InvalidateRenderCache()
4653 i.mu.Unlock()
4654 i.SetPinnedSkillsPending(true)
4655 case "/help":
4656 i.mu.Lock()
4657 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
4658 i.statusErr = ""
4659 i.statusOK = ""
4660 // Pin the viewport to the newest content so the help block,
4661 // which we just appended to the end of the transcript, is
4662 // what the user actually sees.
4663 i.scrollOffset = 0
4664 i.mu.Unlock()
4665 case "/login":
4666 i.dialog.Open(i.cfg.ZotHome)
4667 case "/logout":
4668 if len(parts) >= 2 {
4669 // Explicit target: /logout anthropic | openai | all
4670 i.doLogout(parts[1])
4671 break
4672 }
4673 // No arg: open the picker over whichever providers are
4674 // currently logged in. If nothing's logged in, bail with a
4675 // status line.
4676 i.openLogoutDialog()
4677 case "/model":
4678 if len(parts) >= 2 {
4679 i.applyModelSelection("", parts[1])
4680 } else if i.modelRefreshNeeded() {
4681 i.mu.Lock()
4682 i.statusOK = "refreshing models"
4683 i.statusErr = ""
4684 i.mu.Unlock()
4685 go func() {
4686 refresh := func(fn func(context.Context) error) error {
4687 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
4688 defer cancel()
4689 return fn(refreshCtx)
4690 }
4691 var err error
4692 if i.cfg.RefreshLMStudioModels != nil {
4693 err = refresh(i.cfg.RefreshLMStudioModels)
4694 }
4695 if i.cfg.RefreshLlamaCPPModels != nil {
4696 if llamaErr := refresh(i.cfg.RefreshLlamaCPPModels); err == nil {
4697 err = llamaErr
4698 }
4699 }
4700 if i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() {
4701 if customErr := refresh(i.cfg.RefreshCustomProviderModels); err == nil {
4702 err = customErr
4703 }
4704 }
4705 select {
4706 case i.modelRefresh <- modelRefreshResult{err: err}:
4707 case <-ctx.Done():
4708 }
4709 }()
4710 } else {
4711 i.openModelPickerAfterRefresh(nil)
4712 }
4713 case "/llama":
4714 if i.cfg.LlamaCPPConfig == nil {
4715 i.mu.Lock()
4716 i.statusErr = "llama.cpp configuration is unavailable"
4717 i.statusOK = ""
4718 i.mu.Unlock()
4719 break
4720 }
4721 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
4722 if configErr == nil {
4723 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
4724 }
4725 if configErr != nil {
4726 i.mu.Lock()
4727 i.statusErr = configErr.Error()
4728 i.statusOK = ""
4729 i.mu.Unlock()
4730 }
4731 case "/reasoning":
4732 i.openReasoningDialog()
4733 case "/settings":
4734 i.openSettingsDialog()
4735 case "/sessions":
4736 i.sessionDialog.Open(i.sessionsRoot(), i.cfg.CWD)
4737 case "/jump":
4738 i.openJumpDialog(parts[1:])
4739 case "/btw":
4740 i.openBtwDialog(parts[1:])
4741 case "/skills":
4742 i.openSkillsDialog()
4743 case "/compact":
4744 i.runCompact(ctx, false)
4745 case "/study":
4746 // Canned prompt that tells the agent to read every file
4747 // in some target so its later turns have the whole thing
4748 // in context. With no argument, the target is the current
4749 // directory. With an argument, the target is whatever the
4750 // user passed — typed by hand, drag-dropped, or selected
4751 // via the @ file picker (which is why we accept both files
4752 // and directories; the @-picker chips for both have already
4753 // been expanded to absolute paths by expandFileChips above).
4754 // Dispatched through the normal queue-or-start path so it
4755 // behaves identically to typing the prompt by hand.
4756 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
4757 i.mu.Lock()
4758 busy := i.busy
4759 compacting := i.compacting
4760 ag := i.agent
4761 i.mu.Unlock()
4762 if busy {
4763 if ag != nil && !compacting {
4764 ag.QueueMessage(studyPrompt)
4765 } else {
4766 i.mu.Lock()
4767 i.queued = append(i.queued, studyPrompt)
4768 i.mu.Unlock()
4769 }
4770 i.invalidate()
4771 break
4772 }
4773 i.startTurn(ctx, studyPrompt)
4774 case "/cd":
4775 // Hidden command: switch the running session's cwd. Not in
4776 // slash_suggest, not in /help. Used by the workspaces
4777 // extension's panel-key Enter handler so picking a row
4778 // jumps zot into that directory without relaunching.
4779 //
4780 // Recovers the raw argument (path) from the original cmd
4781 // string rather than parts, so paths with spaces survive.
4782 // The host's ChangeCWD hook handles validation, session
4783 // close + reopen, agent rebuild, sandbox re-rooting, and
4784 // re-jail-if-jailed semantics.
4785 if i.cfg.ChangeCWD == nil {
4786 i.mu.Lock()
4787 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
4788 i.mu.Unlock()
4789 break
4790 }
4791 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
4792 if path == "" {
4793 i.mu.Lock()
4794 i.statusErr = "/cd: missing path"
4795 i.mu.Unlock()
4796 break
4797 }
4798 if err := i.cfg.ChangeCWD(path); err != nil {
4799 i.mu.Lock()
4800 i.statusErr = "/cd: " + err.Error()
4801 i.statusOK = ""
4802 i.mu.Unlock()
4803 break
4804 }
4805 // ChangeCWD has already updated i.cfg.CWD and swapped the
4806 // agent + session. Reset transient TUI state so the new
4807 // session opens clean.
4808 i.mu.Lock()
4809 i.toolCalls = map[string]*tui.ToolCallView{}
4810 i.toolOrder = nil
4811 i.toolGate = map[string]int{}
4812 i.helpBlock = nil
4813 i.parkedTurn = 0
4814 i.statusOK = "cwd " + i.cfg.CWD
4815 i.statusErr = ""
4816 i.mu.Unlock()
4817 i.fileSuggest.Reset()
4818 i.fileSuggest.SetCWD(i.cfg.CWD)
4819 i.invalidate()
4820 case "/jail":
4821 if i.cfg.Sandbox == nil {
4822 i.mu.Lock()
4823 i.statusErr = "sandbox not available in this build"
4824 i.mu.Unlock()
4825 break
4826 }
4827 i.cfg.Sandbox.Lock()
4828 i.mu.Lock()
4829 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
4830 i.statusErr = ""
4831 i.mu.Unlock()
4832 case "/unjail":
4833 if i.cfg.Sandbox == nil {
4834 i.mu.Lock()
4835 i.statusErr = "sandbox not available in this build"
4836 i.mu.Unlock()
4837 break
4838 }
4839 i.cfg.Sandbox.Unlock()
4840 i.mu.Lock()
4841 i.statusOK = "unjailed"
4842 i.statusErr = ""
4843 i.mu.Unlock()
4844 case "/reload-ext":
4845 i.runReloadExt(ctx)
4846 case "/telegram", "/tg":
4847 if len(parts) >= 2 {
4848 i.doTelegram(parts[1])
4849 break
4850 }
4851 i.openTelegramDialog()
4852 case "/session":
4853 if len(parts) >= 2 {
4854 action := parts[1]
4855 arg := ""
4856 if len(parts) >= 3 {
4857 arg = strings.Join(parts[2:], " ")
4858 }
4859 i.doSessionOp(action, arg)
4860 break
4861 }
4862 i.openSessionOpsDialog()
4863 case "/swarm":
4864 i.runSwarm(ctx, parts[1:])
4865 default:
4866 // Last-resort fallback: try the extension manager. Built-in
4867 // cases above always win; this branch only fires for slash
4868 // commands the extension manager registered. Same routing as
4869 // the editor's submit-handler dispatch path so the autocomplete
4870 // "enter on highlighted suggestion" flow also works.
4871 extName := strings.TrimPrefix(parts[0], "/")
4872 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
4873 rest := ""
4874 if len(parts) > 1 {
4875 rest = strings.Join(parts[1:], " ")
4876 }
4877 go i.invokeExtensionCommand(ctx, extName, rest)
4878 return false
4879 }
4880 i.mu.Lock()
4881 i.statusErr = "unknown command: " + parts[0]
4882 i.mu.Unlock()
4883 }
4884 return false
4885}
4886
4887// modelRefreshNeeded reports whether /model must synchronize transient
4888// local catalogs before the picker opens.
4889func (i *Interactive) modelRefreshNeeded() bool {
4890 if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
4891 return true
4892 }
4893 if i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil {
4894 return true
4895 }
4896 return i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured() && i.cfg.RefreshCustomProviderModels != nil
4897}
4898
4899func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4900 var loggedIn []string
4901 if i.cfg.LoggedInProviders != nil {
4902 loggedIn = i.cfg.LoggedInProviders()
4903 }
4904 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
4905 i.mu.Lock()
4906 if refreshErr != nil {
4907 i.statusErr = "model refresh: " + refreshErr.Error()
4908 i.statusOK = ""
4909 } else if i.statusOK == "refreshing models" {
4910 i.statusOK = ""
4911 }
4912 i.mu.Unlock()
4913}
4914
4915// openLogoutDialog shows the provider picker for `/logout` with no
4916// argument. Only providers the user is currently logged into are
4917// listed, plus an "all" entry when more than one is present. If
4918// nothing's logged in, writes a status line instead of opening an
4919// empty dialog.
4920func (i *Interactive) openLogoutDialog() {
4921 if i.cfg.AuthManager == nil {
4922 i.mu.Lock()
4923 i.statusErr = "no auth manager configured"
4924 i.mu.Unlock()
4925 i.invalidate()
4926 return
4927 }
4928 store := i.cfg.AuthManager.Store()
4929 if store == nil {
4930 i.mu.Lock()
4931 i.statusErr = "auth store is not available"
4932 i.mu.Unlock()
4933 i.invalidate()
4934 return
4935 }
4936 creds, err := store.Load()
4937 if err != nil {
4938 i.mu.Lock()
4939 i.statusErr = "read auth store: " + err.Error()
4940 i.mu.Unlock()
4941 i.invalidate()
4942 return
4943 }
4944
4945 var items []logoutItem
4946 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
4947 if creds.Has(p) {
4948 method := creds.Method(p)
4949 if method == "oauth" {
4950 method = "subscription"
4951 }
4952 items = append(items, logoutItem{
4953 label: providerLabel(p),
4954 target: p,
4955 method: method,
4956 })
4957 }
4958 }
4959 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
4960 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
4961 }
4962 if creds.OpenAI.OAuth != nil {
4963 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
4964 }
4965 for p, c := range creds.AdditionalAPIKeyCreds {
4966 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
4967 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
4968 }
4969 }
4970 if len(items) == 0 {
4971 i.mu.Lock()
4972 i.statusOK = "no credentials stored; already logged out"
4973 i.statusErr = ""
4974 i.mu.Unlock()
4975 i.invalidate()
4976 return
4977 }
4978 if len(items) > 1 {
4979 items = append(items, logoutItem{label: "all", target: "all"})
4980 }
4981
4982 i.logoutDialog.Open(items)
4983 i.invalidate()
4984}
4985
4986// doLogout clears credentials for the given provider (or all providers)
4987// from auth.json. If the active agent was using those credentials, it
4988// is torn down so the user is forced through /login before their next
4989// prompt.
4990//
4991// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
4992func (i *Interactive) doLogout(target string) {
4993 if i.cfg.AuthManager == nil {
4994 i.mu.Lock()
4995 i.statusErr = "no auth manager configured"
4996 i.mu.Unlock()
4997 return
4998 }
4999 store := i.cfg.AuthManager.Store()
5000 if store == nil {
5001 i.mu.Lock()
5002 i.statusErr = "auth store is not available"
5003 i.mu.Unlock()
5004 return
5005 }
5006
5007 var providers []string
5008 switch target {
5009 case "", "all":
5010 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
5011 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
5012 providers = []string{target}
5013 default:
5014 known := false
5015 for _, p := range auth.APIKeyProviders() {
5016 if target == p {
5017 known = true
5018 break
5019 }
5020 }
5021 if !known {
5022 i.mu.Lock()
5023 i.statusErr = "unknown provider: " + target
5024 i.mu.Unlock()
5025 return
5026 }
5027 providers = []string{target}
5028 }
5029
5030 var errs []string
5031 clearedCurrent := false
5032 for _, p := range providers {
5033 var err error
5034 switch p {
5035 case "openai":
5036 err = store.ClearAPIKey("openai")
5037 case "openai-codex":
5038 err = store.ClearOAuth("openai")
5039 default:
5040 err = store.Clear(p)
5041 }
5042 if err != nil {
5043 errs = append(errs, p+": "+err.Error())
5044 continue
5045 }
5046 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5047 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
5048 errs = append(errs, p+": "+err.Error())
5049 continue
5050 }
5051 }
5052 if p == provider.LMStudioProviderID {
5053 provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil)
5054 }
5055 if p == i.cfg.Provider {
5056 clearedCurrent = true
5057 }
5058 }
5059
5060 llamaConfigured := i.llamaConfigured
5061 if i.cfg.LlamaCPPConfig != nil {
5062 baseURL, _, err := i.cfg.LlamaCPPConfig()
5063 llamaConfigured = err == nil && baseURL != ""
5064 }
5065
5066 i.mu.Lock()
5067 defer i.mu.Unlock()
5068 i.llamaConfigured = llamaConfigured
5069 if len(errs) > 0 {
5070 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
5071 return
5072 }
5073 i.statusErr = ""
5074 if clearedCurrent {
5075 // The running agent was using a credential we just wiped. Drop
5076 // it so prompts can't go out with the stale client, and hint at
5077 // /login.
5078 i.agent = nil
5079 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
5080 } else {
5081 i.statusOK = "logged out of " + strings.Join(providers, ", ")
5082 }
5083}
5084
5085func providerSetupInfo(provider string) (string, []string, bool) {
5086 const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
5087 switch provider {
5088 case "amazon-bedrock":
5089 return "Amazon Bedrock setup", []string{
5090 "Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
5091 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
5092 "",
5093 "For Bedrock API keys, set:",
5094 " AWS_BEARER_TOKEN_BEDROCK=...",
5095 " AWS_REGION=us-east-1",
5096 "",
5097 "Docs:",
5098 " " + docsURL,
5099 }, true
5100 case "google-vertex":
5101 return "Google Vertex AI setup", []string{
5102 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
5103 "Set a Google API key, application-default credentials, or a service account.",
5104 "",
5105 "Common environment:",
5106 " GOOGLE_CLOUD_API_KEY=...",
5107 " GOOGLE_CLOUD_PROJECT=...",
5108 " GOOGLE_CLOUD_LOCATION=us-central1",
5109 "",
5110 "Docs:",
5111 " " + docsURL,
5112 }, true
5113 case "cloudflare-workers-ai":
5114 return "Cloudflare Workers AI setup", []string{
5115 "Cloudflare Workers AI needs both an API token and an account ID.",
5116 "",
5117 "Set:",
5118 " CLOUDFLARE_API_KEY=...",
5119 " CLOUDFLARE_ACCOUNT_ID=...",
5120 "",
5121 "Docs:",
5122 " " + docsURL,
5123 }, true
5124 case "cloudflare-ai-gateway":
5125 return "Cloudflare AI Gateway setup", []string{
5126 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
5127 "",
5128 "Set:",
5129 " CLOUDFLARE_API_KEY=...",
5130 " CLOUDFLARE_ACCOUNT_ID=...",
5131 " CLOUDFLARE_GATEWAY_ID=...",
5132 "",
5133 "Docs:",
5134 " " + docsURL,
5135 }, true
5136 case "azure-openai-responses":
5137 return "Azure OpenAI Responses setup", []string{
5138 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
5139 "",
5140 "Set:",
5141 " AZURE_OPENAI_API_KEY=...",
5142 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
5143 " AZURE_OPENAI_API_VERSION=2024-02-01",
5144 "",
5145 "Docs:",
5146 " " + docsURL,
5147 }, true
5148 default:
5149 return "", nil, false
5150 }
5151}
5152
5153func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
5154 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
5155 i.dialog.ShowResult(false, "auth store is unavailable")
5156 return
5157 }
5158 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
5159 i.dialog.ShowResult(false, err.Error())
5160 return
5161 }
5162 i.mu.Lock()
5163 i.llamaConfigured = true
5164 i.statusErr = ""
5165 i.statusOK = "configured llama.cpp router"
5166 i.mu.Unlock()
5167 i.dialog.ShowResult(true, "")
5168}
5169
5170func (i *Interactive) startAPIKeyFlow(provider string) {
5171 if title, lines, ok := providerSetupInfo(provider); ok {
5172 i.dialog.ShowInfo(title, lines)
5173 return
5174 }
5175 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5176 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5177 }
5178 url, err := i.cfg.AuthManager.StartAPIKey(provider)
5179 if err != nil {
5180 i.dialog.ShowResult(false, err.Error())
5181 return
5182 }
5183 i.dialog.ShowWaiting(url)
5184}
5185
5186func (i *Interactive) startOAuthFlow(provider string) {
5187 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5188 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5189 }
5190 // Device-code providers already support headless login and must only
5191 // start one polling flow.
5192 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5193 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5194 if err != nil {
5195 i.dialog.ShowResult(false, err.Error())
5196 return
5197 }
5198 i.dialog.ShowWaiting(loginURL)
5199 return
5200 }
5201 // Always run the manual/copy-code flow in parallel with the local
5202 // callback server so headless environments (docker, SSH) can paste
5203 // the authorization code directly without first pressing 'p'.
5204 _, err := i.cfg.AuthManager.StartOAuth(provider)
5205 if err != nil {
5206 i.dialog.ShowResult(false, err.Error())
5207 return
5208 }
5209 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
5210 if mErr == nil {
5211 i.dialog.ShowWaiting(manualURL)
5212 } else {
5213 i.dialog.ShowResult(false, mErr.Error())
5214 }
5215}
5216
5217func (i *Interactive) startManualOAuthFlow(provider string) {
5218 if i.cfg.AuthManager == nil {
5219 return
5220 }
5221 i.cfg.AuthManager.CancelOAuth()
5222 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
5223 if err != nil {
5224 i.dialog.ShowResult(false, err.Error())
5225 return
5226 }
5227 i.dialog.url = url
5228 i.invalidate()
5229}
5230
5231func (i *Interactive) submitManualOAuthCode(code string) {
5232 if i.cfg.AuthManager == nil {
5233 return
5234 }
5235 go func() {
5236 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
5237 i.dialog.ShowResult(false, err.Error())
5238 i.invalidate()
5239 }
5240 }()
5241}
5242
5243// applyModelSelection switches the active model (and provider, if the
5244// new model belongs to a different one). It rebuilds the underlying
5245// client when needed so the provider wire-protocol matches.
5246// cancelAndWaitForIdle cancels the active turn (if any) and blocks
5247// briefly until the turn goroutine has updated i.busy = false. Used
5248// before destructive slash commands so transcript-mutating work
5249// (/clear, /compact, /logout, /login completion, cross-provider
5250// /model swap) doesn't race with the still-running stream.
5251//
5252// The wait is bounded; if the turn doesn't release within the timeout
5253// we proceed anyway. Worst case is a brief overlap that the agent's
5254// own mutex protects against.
5255func (i *Interactive) cancelAndWaitForIdle() {
5256 i.mu.Lock()
5257 busy := i.busy
5258 cancel := i.cancelTurn
5259 i.mu.Unlock()
5260 if !busy {
5261 return
5262 }
5263 if cancel != nil {
5264 cancel()
5265 }
5266 // ConfirmToolCall waits on its response channel rather than the turn
5267 // context, so cancellation must also resolve every pending prompt.
5268 i.confirmDialog.CancelAll("turn cancelled")
5269 deadline := time.Now().Add(2 * time.Second)
5270 for time.Now().Before(deadline) {
5271 i.mu.Lock()
5272 done := !i.busy
5273 i.mu.Unlock()
5274 if done {
5275 return
5276 }
5277 time.Sleep(10 * time.Millisecond)
5278 }
5279}
5280
5281// openBtwDialog opens the side-chat overlay with a frozen snapshot
5282// of the current main session. The optional argument is auto-
5283// submitted as the first question, so '/btw does X work?' fires the
5284// model call immediately instead of just opening an empty dialog.
5285func (i *Interactive) openBtwDialog(args []string) {
5286 if i.agent == nil {
5287 i.mu.Lock()
5288 i.statusErr = "not logged in. type /login first."
5289 i.mu.Unlock()
5290 return
5291 }
5292 seed := strings.TrimSpace(strings.Join(args, " "))
5293 i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
5294 i.invalidate()
5295}
5296
5297// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
5298// or queues it behind the active turn using the normal text-only queue.
5299func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
5300 i.mu.Lock()
5301 if i.agent == nil {
5302 i.statusErr = "not logged in. type /login first."
5303 i.statusOK = ""
5304 i.mu.Unlock()
5305 i.invalidate()
5306 return
5307 }
5308 busy := i.busy
5309 compacting := i.compacting
5310 ag := i.agent
5311 i.mu.Unlock()
5312
5313 if busy {
5314 if !compacting {
5315 ag.QueueMessage(prompt)
5316 } else {
5317 i.mu.Lock()
5318 i.queued = append(i.queued, prompt)
5319 i.mu.Unlock()
5320 }
5321 i.invalidate()
5322 return
5323 }
5324 i.startTurn(ctx, prompt)
5325}
5326
5327// openSkillsDialog opens the skill inspector. The picker reflects
5328// whatever SkillSnapshot returns at call time, so edits to a
5329// SKILL.md made during a session show up on the next /skills.
5330func (i *Interactive) openSkillsDialog() {
5331 var list []*skills.Skill
5332 if i.cfg.SkillSnapshot != nil {
5333 list = i.cfg.SkillSnapshot()
5334 }
5335 i.skillsDialog.Open(list)
5336 i.refreshSkillPins()
5337 i.invalidate()
5338}
5339
5340// openJumpDialog builds a /jump picker from the current transcript.
5341// If the user typed "/jump foo" with a filter and it matches exactly
5342// one turn, jump there directly without showing the dialog.
5343func (i *Interactive) openJumpDialog(args []string) {
5344 if i.view == nil || len(i.view.Messages) == 0 {
5345 i.mu.Lock()
5346 i.statusErr = "nothing to jump to \u2014 the session is empty"
5347 i.mu.Unlock()
5348 return
5349 }
5350 filter := strings.TrimSpace(strings.Join(args, " "))
5351 i.jumpDialog.Open(i.view.Messages, filter)
5352 // Shortcut: with a filter argument that matches exactly one turn,
5353 // jump immediately and skip the picker.
5354 if filter != "" {
5355 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
5356 t := tgts[0]
5357 i.jumpDialog.Close()
5358 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
5359 }
5360 }
5361}
5362
5363// applyJumpSelection scrolls the chat viewport so the user message at
5364// msgIdx is visible at (or near) the top of the chat area. Uses the
5365// anchor slice returned by view.BuildWithAnchors so the mapping from
5366// message index to row is exact, regardless of variable-height tool
5367// blocks above the target.
5368func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
5369 i.mu.Lock()
5370 defer i.mu.Unlock()
5371 cols := i.lastCols()
5372 // Older resumed messages otherwise have zero-row placeholder anchors.
5373 i.view.TailLimit = 0
5374 i.chatCacheValid = false
5375 _, anchors := i.view.BuildWithAnchors(cols)
5376 var row int
5377 found := false
5378 for _, a := range anchors {
5379 if a.MessageIdx == msgIdx {
5380 row = a.Row
5381 found = true
5382 break
5383 }
5384 }
5385 if !found {
5386 i.statusErr = "could not resolve jump target"
5387 return
5388 }
5389 if i.updateInfo.Available {
5390 row += len(renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols))
5391 }
5392 // Resolve against the final chat and bottom-band heights on redraw,
5393 // not the previous picker layout or the approximate keyboard page size.
5394 i.jumpRow = &row
5395 i.parkedTurn = turnNo
5396 i.parkedTotal = totalTurnsLocked(i.view.Messages)
5397 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
5398 i.statusErr = ""
5399}
5400
5401// totalTurnsLocked counts user messages in the transcript. Caller is
5402// assumed to hold i.mu (the name is a mild reminder; this function
5403// itself doesn't touch shared state beyond the slice it's handed).
5404func totalTurnsLocked(msgs []provider.Message) int {
5405 n := 0
5406 for _, m := range msgs {
5407 if m.Role == provider.RoleUser {
5408 n++
5409 }
5410 }
5411 return n
5412}
5413
5414// applySessionDeletion permanently removes a session selected in /sessions.
5415// The active session is protected because unlinking its open file would lose
5416// subsequent transcript writes on Unix and behaves differently on Windows.
5417func (i *Interactive) applySessionDeletion(path string) {
5418 if i.cfg.CurrentSessionPath == nil {
5419 i.mu.Lock()
5420 i.statusErr = "session deletion is not wired in this build"
5421 i.statusOK = ""
5422 i.mu.Unlock()
5423 return
5424 }
5425 current := i.cfg.CurrentSessionPath()
5426 if current != "" && filepath.Clean(current) == filepath.Clean(path) {
5427 i.mu.Lock()
5428 i.statusErr = "cannot delete the active session; resume another session first"
5429 i.statusOK = ""
5430 i.mu.Unlock()
5431 return
5432 }
5433 if err := core.DeleteSession(path); err != nil {
5434 i.mu.Lock()
5435 i.statusErr = "delete session: " + err.Error()
5436 i.statusOK = ""
5437 i.mu.Unlock()
5438 return
5439 }
5440 i.sessionDialog.Remove(path)
5441 i.mu.Lock()
5442 i.statusOK = "deleted session: " + friendlyPath(path)
5443 i.statusErr = ""
5444 i.mu.Unlock()
5445}
5446
5447// applySessionSelection loads the given session via the cli-provided
5448// callback and snaps the viewport to the bottom (the latest message)
5449// so the user lands at the live tail of the resumed conversation.
5450func (i *Interactive) applySessionSelection(path string) {
5451 if i.cfg.LoadSession == nil {
5452 i.mu.Lock()
5453 i.statusErr = "session loading is not wired in this build"
5454 i.mu.Unlock()
5455 return
5456 }
5457 i.mu.Lock()
5458 if i.sessionLoading {
5459 i.statusErr = "already resuming a session"
5460 i.mu.Unlock()
5461 i.invalidate()
5462 return
5463 }
5464 i.sessionLoading = true
5465 i.statusOK = "resuming session: " + path
5466 i.statusErr = ""
5467 i.mu.Unlock()
5468 i.invalidate()
5469
5470 go func() {
5471 err := i.cfg.LoadSession(path)
5472 i.mu.Lock()
5473 defer i.mu.Unlock()
5474 i.sessionLoading = false
5475 if err != nil {
5476 i.statusErr = err.Error()
5477 i.statusOK = ""
5478 i.invalidate()
5479 return
5480 }
5481 i.statusOK = "resumed session: " + path
5482 i.statusErr = ""
5483 i.parkedTurn = 0
5484 i.parkedTotal = 0
5485 i.scrollOffset = 0
5486 // Fresh transcript swapped in: drop the auto-follow baseline so
5487 // the next render's follow guard doesn't see the wholesale
5488 // length change as a delta and jump the viewport.
5489 i.prevChatLen = 0
5490 i.prevChatCols = 0
5491 i.extNotes = nil
5492 i.view.InvalidateRenderCache()
5493 if i.agent != nil {
5494 i.view.Messages = i.agent.Messages()
5495 i.cumUsage = i.agent.Cost()
5496 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
5497 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
5498 } else {
5499 i.lastCtxInput = 0
5500 }
5501 // Snap to the tail again — the swap brought in a fresh
5502 // transcript whose markdown / chroma cost we don't want
5503 // blocking the redraw.
5504 if len(i.view.Messages) > initialResumeTailLimit {
5505 i.view.TailLimit = initialResumeTailLimit
5506 } else {
5507 i.view.TailLimit = 0
5508 }
5509 }
5510 i.invalidate()
5511 }()
5512}
5513
5514// scrollToLastTurn parks the viewport at the most recent user turn,
5515// or at the top if the transcript has no user messages. Used after
5516// resume so the user lands looking at where they left off.
5517func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
5518 if len(msgs) == 0 {
5519 i.mu.Lock()
5520 i.scrollOffset = 0
5521 i.mu.Unlock()
5522 return
5523 }
5524 // Find the last user message index.
5525 lastUser := -1
5526 turnNo, totalTurns := 0, 0
5527 for idx, m := range msgs {
5528 if m.Role == provider.RoleUser {
5529 totalTurns++
5530 lastUser = idx
5531 }
5532 }
5533 if lastUser < 0 {
5534 i.mu.Lock()
5535 i.scrollOffset = 0
5536 i.mu.Unlock()
5537 return
5538 }
5539 turnNo = totalTurns
5540
5541 cols := i.lastCols()
5542 chat, anchors := i.view.BuildWithAnchors(cols)
5543 var row int
5544 found := false
5545 for _, a := range anchors {
5546 if a.MessageIdx == lastUser {
5547 row = a.Row
5548 found = true
5549 break
5550 }
5551 }
5552 if !found {
5553 i.mu.Lock()
5554 i.scrollOffset = 0
5555 i.mu.Unlock()
5556 return
5557 }
5558
5559 chatLen := len(chat)
5560 page := i.chatPage()
5561 if page < 1 {
5562 page = 1
5563 }
5564 offset := chatLen - (row + page)
5565 if offset < 0 {
5566 offset = 0
5567 }
5568 maxOffset := chatLen - page
5569 if maxOffset < 0 {
5570 maxOffset = 0
5571 }
5572 if offset > maxOffset {
5573 offset = maxOffset
5574 }
5575
5576 i.mu.Lock()
5577 i.scrollOffset = offset
5578 // Mark the parked-turn footer so the user sees "viewing turn N of
5579 // M - pgdn to catch up" — same affordance as /jump. Tells them at
5580 // a glance that they're looking at history, not the live tail.
5581 if offset > 0 {
5582 i.parkedTurn = turnNo
5583 i.parkedTotal = totalTurns
5584 }
5585 i.mu.Unlock()
5586 i.invalidate()
5587}
5588
5589func (i *Interactive) applyModelSelection(prov, model string) {
5590 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
5591}
5592
5593// applyRescueModelSelection is like applyModelSelection but routes
5594// through BuildAgentForRescue so launch-time --api-key / --base-url
5595// overrides are dropped before the new agent is built. Falls back to
5596// the regular builder when the host doesn't wire a rescue builder.
5597func (i *Interactive) applyRescueModelSelection(prov, model string) {
5598 builder := i.cfg.BuildAgentForRescue
5599 if builder == nil {
5600 builder = i.cfg.BuildAgentFor
5601 }
5602 i.swapModel(prov, model, builder, true)
5603}
5604
5605// swapModel applies a /model selection (or a rescue selection) using
5606// the supplied builder. rescue=true tags the success message so the
5607// user can see that launch-time overrides were ignored.
5608func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
5609 if model == "" {
5610 return
5611 }
5612 m, err := provider.FindModel(prov, model)
5613 if err != nil {
5614 i.mu.Lock()
5615 i.statusErr = err.Error()
5616 i.mu.Unlock()
5617 return
5618 }
5619 // Same provider AND not a rescue retry: just swap the model on
5620 // the existing agent. Mixed-API providers dispatch from model metadata,
5621 // so the client remains reusable. Rescue retries always rebuild so a stale
5622 // auth header / base URL can't carry over.
5623 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
5624 i.mu.Lock()
5625 i.cfg.Model = m.ID
5626 i.agent.Model = m.ID
5627 i.statusOK = "model: " + m.ID
5628 i.statusErr = ""
5629 i.mu.Unlock()
5630 if i.cfg.PersistModel != nil {
5631 i.cfg.PersistModel(i.cfg.Provider, m.ID)
5632 }
5633 return
5634 }
5635 if builder == nil {
5636 i.mu.Lock()
5637 i.statusErr = "cannot switch provider: no builder configured"
5638 i.mu.Unlock()
5639 return
5640 }
5641 // Snapshot the current transcript and cumulative usage BEFORE we
5642 // build the replacement agent so we can hand them off. Without
5643 // this the user perceives the entire session as wiped on a
5644 // cross-provider /model swap.
5645 var carryMsgs []provider.Message
5646 var carryCost provider.Usage
5647 if i.agent != nil {
5648 carryMsgs = i.agent.Messages()
5649 carryCost = i.agent.Cost()
5650 }
5651
5652 ag, p, md, err := builder(m.Provider, m.ID)
5653 if err != nil {
5654 i.mu.Lock()
5655 i.statusErr = err.Error()
5656 i.mu.Unlock()
5657 return
5658 }
5659
5660 // Replay the transcript and seed the cost on the freshly-built
5661 // agent. Messages travel cleanly between providers because they
5662 // use the same provider.Message shape; tool-call ids are local
5663 // to a turn so cross-provider continuation never confuses the
5664 // new model (it just sees the assistant's reply, no orphan
5665 // tool_use blocks because /model swaps are gated to idle state).
5666 if len(carryMsgs) > 0 {
5667 ag.SetMessages(carryMsgs)
5668 }
5669 ag.SeedCost(carryCost)
5670
5671 i.mu.Lock()
5672 i.agent = ag
5673 i.cfg.Provider = p
5674 i.cfg.Model = md
5675 if rescue {
5676 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
5677 } else {
5678 i.statusOK = "switched to " + p + " / " + md
5679 }
5680 i.statusErr = ""
5681 // Render cache keys are width+content based, so the new agent's
5682 // identical messages will reuse the existing entries. Nothing
5683 // to invalidate.
5684 i.mu.Unlock()
5685 // The new agent was built off the base tool registry, so any
5686 // dynamically-registered tools need to be reattached. The apply
5687 // helpers are no-ops when their feature is inactive, so the
5688 // cross-provider path still works on a vanilla setup.
5689 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5690 i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5691 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5692 if i.cfg.PersistModel != nil {
5693 i.cfg.PersistModel(p, md)
5694 }
5695}
5696
5697func (i *Interactive) handleAuthEvent(ev auth.Event) {
5698 switch ev.Kind {
5699 case "started":
5700 i.dialog.ShowWaiting(ev.URL)
5701 case "browser_open":
5702 // no-op
5703 case "error":
5704 i.dialog.ShowResult(false, ev.Message)
5705 case "success":
5706 // Rebuild the agent with the fresh credential.
5707 ag, prov, model, err := i.cfg.BuildAgent()
5708 if err != nil {
5709 i.dialog.ShowResult(false, err.Error())
5710 return
5711 }
5712 i.mu.Lock()
5713 i.agent = ag
5714 i.cfg.Provider = prov
5715 i.cfg.Model = model
5716 i.statusErr = ""
5717 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5718 i.mu.Unlock()
5719 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5720 i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5721 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5722 i.dialog.ShowResult(true, "")
5723 }
5724}
5725
5726// clearPendingCompactTurnLocked drops work that only makes sense after the
5727// current compaction. The caller must hold i.mu.
5728func (i *Interactive) clearPendingCompactTurnLocked() {
5729 i.pendingCompactPrompt = ""
5730 i.pendingCompactImages = nil
5731 i.hasPendingCompactPrompt = false
5732 i.continueAfterCompact = false
5733 i.pendingPostCompactNote = ""
5734}
5735
5736// runCompact invokes core.Agent.Compact and reflects the progress in
5737// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5738// cancel via the same cancelTurn channel used for normal turns.
5739//
5740// When auto is true the spinner message is pinned to "condensing
5741// history" and the status bar surfaces "(auto)" next to the context
5742// percentage so it's obvious the system triggered this, not the user.
5743func (i *Interactive) runCompact(parent context.Context, auto bool) {
5744 if i.agent == nil {
5745 i.mu.Lock()
5746 i.statusErr = "not logged in. type /login first."
5747 i.mu.Unlock()
5748 return
5749 }
5750 ctx, cancel := context.WithCancel(parent)
5751 i.mu.Lock()
5752 i.busy = true
5753 i.compacting = true
5754 if auto {
5755 i.spin.StartFixed("condensing history")
5756 i.autoCompacting = true
5757 } else {
5758 i.spin.StartFixed("compacting")
5759 }
5760 i.cancelTurn = cancel
5761 i.statusErr = ""
5762 i.statusOK = ""
5763 // Do NOT set streamOn: the summary text should not be visible
5764 // in the chat while compacting. The user just sees the spinner
5765 // and can keep typing / queue prompts.
5766 i.scrollOffset = 0
5767 i.helpBlock = nil
5768 i.mu.Unlock()
5769 i.invalidate()
5770
5771 go func() {
5772 // Sink discards deltas — we don't stream the summary to the UI.
5773 sink := func(delta string) {}
5774 summary, err := i.agent.Compact(ctx, 4, sink)
5775 _ = summary
5776 i.mu.Lock()
5777 i.busy = false
5778 i.compacting = false
5779 i.resetStreamingStateLocked()
5780 i.cancelTurn = nil
5781 i.autoCompacting = false
5782
5783 // Drain pending work after the transcript is clean. Overflow recovery
5784 // continues the user message already in the transcript. Pre-turn
5785 // compaction preserves its complete text-and-images request. Regular
5786 // prompts typed during compaction remain in the host queue.
5787 var next string
5788 var nextImages []provider.ImageBlock
5789 var hasNext bool
5790 var continueExisting bool
5791
5792 switch {
5793 case err != nil && ctx.Err() != nil:
5794 i.statusErr = ""
5795 if auto {
5796 i.statusOK = "auto-condense cancelled"
5797 } else {
5798 i.statusOK = "compaction cancelled"
5799 }
5800 i.queued = nil // drop queue on cancel
5801 i.clearPendingCompactTurnLocked()
5802 if i.agent != nil {
5803 i.agent.DrainQueuedMessages()
5804 }
5805 case err != nil:
5806 i.statusErr = "compaction failed: " + err.Error()
5807 i.statusOK = ""
5808 i.queued = nil // drop queue on error
5809 i.clearPendingCompactTurnLocked()
5810 if i.agent != nil {
5811 i.agent.DrainQueuedMessages()
5812 }
5813 default:
5814 i.statusErr = ""
5815 // Read token count from the compaction message meta.
5816 tokens := ""
5817 msgs := i.agent.Messages()
5818 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
5819 tokens = msgs[0].Meta["tokens_before"]
5820 }
5821 switch {
5822 case i.pendingPostCompactNote != "":
5823 i.statusOK = i.pendingPostCompactNote
5824 case tokens != "":
5825 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
5826 default:
5827 i.statusOK = "compacted (ctrl+o to expand)"
5828 }
5829 i.pendingPostCompactNote = ""
5830 i.extNotes = stripAutoCompactNotes(i.extNotes)
5831 i.lastCtxInput = estimateTimelineMessageTokens(msgs)
5832 i.toolCalls = map[string]*tui.ToolCallView{}
5833 i.toolOrder = nil
5834 i.toolGate = map[string]int{}
5835 i.view.InvalidateRenderCache()
5836 switch {
5837 case i.continueAfterCompact:
5838 continueExisting = true
5839 i.continueAfterCompact = false
5840 case i.hasPendingCompactPrompt:
5841 next = i.pendingCompactPrompt
5842 nextImages = i.pendingCompactImages
5843 i.pendingCompactPrompt = ""
5844 i.pendingCompactImages = nil
5845 i.hasPendingCompactPrompt = false
5846 hasNext = true
5847 case len(i.queued) > 0:
5848 next, i.queued = i.queued[0], i.queued[1:]
5849 hasNext = true
5850 }
5851 // A prompt resumed immediately after compaction must not trigger
5852 // the pre-turn threshold guard again before fresh provider usage
5853 // replaces this estimate.
5854 if hasNext {
5855 i.lastCtxInput = 0
5856 }
5857 }
5858 i.mu.Unlock()
5859 i.invalidate()
5860
5861 if hasNext || continueExisting {
5862 p := i.runCtx
5863 if p == nil {
5864 p = context.Background()
5865 }
5866 if continueExisting {
5867 i.startTurnRequest(p, "", nil, true)
5868 } else {
5869 i.startTurnWithImages(p, next, nextImages)
5870 }
5871 }
5872 }()
5873}
5874
5875// shellEscapeCommand reports whether text is a "!command" shell
5876// escape and, if so, returns the command with the leading '!' (and
5877// surrounding whitespace) stripped. A bare "!" with no command is
5878// treated as not an escape so it falls through to the normal prompt
5879// path rather than running an empty shell.
5880func shellEscapeCommand(text string) (string, bool) {
5881 return ShellEscapeCommand(text)
5882}
5883
5884// ShellEscapeCommand reports whether text is a "!command" shell
5885// escape and, if so, returns the command with the leading '!' (and
5886// surrounding whitespace) stripped. A bare "!" with no command is
5887// treated as not an escape so it falls through to the normal prompt
5888// path rather than running an empty shell.
5889func ShellEscapeCommand(text string) (string, bool) {
5890 trimmed := strings.TrimLeft(text, " \t")
5891 if !strings.HasPrefix(trimmed, "!") {
5892 return "", false
5893 }
5894 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
5895 if cmd == "" {
5896 return "", false
5897 }
5898 return cmd, true
5899}
5900
5901// startShellEscape runs a "!command" in the same shell the bash tool
5902// uses, in the session working directory, honoring the /jail sandbox.
5903// It shares the busy/cancel state with the agent: esc cancels it, and
5904// it refuses to start while a turn or another shell escape is already
5905// in flight. Its terminal-log output is appended to the transcript as
5906// user context without automatically starting a model turn.
5907func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5908 i.mu.Lock()
5909 if i.busy || i.shellRunning {
5910 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
5911 i.statusOK = ""
5912 i.mu.Unlock()
5913 i.invalidate()
5914 return
5915 }
5916 if parent == nil {
5917 parent = i.runCtx
5918 }
5919 if parent == nil {
5920 parent = context.Background()
5921 }
5922 ctx, cancel := context.WithCancel(parent)
5923 i.busy = true
5924 i.shellRunning = true
5925 i.shellLive = "$ " + cmd + "\n\n"
5926 i.cancelTurn = cancel
5927 i.statusErr = ""
5928 i.statusOK = ""
5929 i.spin.StartFixed("running shell command")
5930 // Clear stale extension notes the same way a new turn would so the
5931 // screen doesn't accumulate transient state.
5932 i.extNotes = nil
5933 i.scrollOffset = 0
5934 i.parkedTurn = 0
5935 i.parkedTotal = 0
5936 i.helpBlock = nil
5937 sandbox := i.cfg.Sandbox
5938 cwd := i.cfg.CWD
5939 i.mu.Unlock()
5940 i.invalidate()
5941
5942 go func() {
5943 defer cancel()
5944 raw, _ := json.Marshal(map[string]any{"command": cmd})
5945 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5946 progress := func(chunk string) {
5947 i.mu.Lock()
5948 i.shellLive += chunk
5949 i.mu.Unlock()
5950 i.invalidate()
5951 }
5952 res, err := bash.Execute(ctx, raw, progress)
5953
5954 var out string
5955 if err != nil {
5956 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
5957 } else {
5958 for _, c := range res.Content {
5959 if tb, ok := c.(provider.TextBlock); ok {
5960 out += tb.Text
5961 }
5962 }
5963 }
5964 cancelled := ctx.Err() != nil
5965 failed := err != nil || res.IsError || cancelled
5966 if cancelled {
5967 out += "\n\n[cancelled]"
5968 }
5969
5970 if i.agent != nil {
5971 i.agent.AppendUserContext(out, map[string]string{
5972 shellEscapeMetaKey: "true",
5973 shellEscapeCommandMetaKey: cmd,
5974 })
5975 }
5976
5977 i.mu.Lock()
5978 i.shellRunning = false
5979 i.shellLive = ""
5980 i.busy = false
5981 i.cancelTurn = nil
5982 awaitingPre := i.awaitingStartupPre
5983 if failed {
5984 if cancelled {
5985 i.statusErr = "shell command cancelled"
5986 } else {
5987 i.statusErr = "shell command failed"
5988 }
5989 i.statusOK = ""
5990 } else {
5991 i.statusOK = "shell command finished"
5992 i.statusErr = ""
5993 }
5994 i.mu.Unlock()
5995 i.invalidate()
5996 if awaitingPre {
5997 i.completeStartupPre()
5998 }
5999 }()
6000}
6001
6002func (i *Interactive) startTurn(parent context.Context, prompt string) {
6003 i.startTurnWithImages(parent, prompt, nil)
6004}
6005
6006func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
6007 i.startTurnRequest(parent, prompt, images, false)
6008}
6009
6010func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
6011 if i.agent == nil {
6012 // Text startup pre cannot run without credentials; continue so
6013 // deferred InitialInput (pre-fill or auto-submit) still applies.
6014 i.mu.Lock()
6015 awaitingPre := i.awaitingStartupPre
6016 i.mu.Unlock()
6017 if awaitingPre {
6018 i.completeStartupPre()
6019 }
6020 return
6021 }
6022 // Pre-turn safety: if the most recent context measurement is
6023 // already past the auto-compact threshold, condense before
6024 // sending so the next outbound request stays under the limit.
6025 // The condense flow re-fires the user's queued prompt for us, so
6026 // we just hand it off and exit.
6027 i.mu.Lock()
6028 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
6029 if needsPreCompact {
6030 i.pendingCompactPrompt = prompt
6031 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6032 i.hasPendingCompactPrompt = true
6033 i.statusErr = ""
6034 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
6035 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
6036 i.mu.Unlock()
6037 i.invalidate()
6038 i.runCompact(parent, true)
6039 return
6040 }
6041 i.mu.Unlock()
6042
6043 ctx, cancel := context.WithCancel(parent)
6044 i.mu.Lock()
6045 if !overflowRecoveryAttempted && !i.awaitingStartupPre {
6046 prompt = i.preloadPinnedPromptLocked(prompt)
6047 }
6048 i.busy = true
6049 i.spin.Start()
6050 i.cancelTurn = cancel
6051 i.statusErr = ""
6052 i.statusOK = ""
6053 i.streaming.Reset()
6054 i.streamOn = true
6055 i.toolCalls = map[string]*tui.ToolCallView{}
6056 i.toolOrder = nil
6057 i.toolGate = map[string]int{}
6058 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
6059 i.scrollOffset = 0 // jump back to the bottom on new turn
6060 // Lift the resume tail cap once the user starts interacting. The
6061 // cap is purely a first-paint optimization (don't markdown the
6062 // whole history before showing anything). Keeping it active during
6063 // a turn makes the rendered chat a sliding window: appended
6064 // messages push older ones off the TOP of the buffer, which the
6065 // renderer must treat as a change above the viewport and repaint
6066 // fully, snapping the terminal's native scrollback to the bottom on
6067 // every streamed chunk. A fresh session has no cap (append-only),
6068 // which is why the jump only shows up in resumed sessions. Dropping
6069 // the cap here makes resumed turns append-only too.
6070 i.view.TailLimit = 0
6071 // Reset the auto-follow baseline so the very next render at
6072 // interactive.go:1053 doesn't see a synthetic shrink between
6073 // "last frame had the previous turn's tool overlay" and
6074 // "this frame had it cleared above". Without this, the guard
6075 // reads delta = -(rows in cleared overlay) and decrements
6076 // scrollOffset, which on terminals that mirror zot's pane
6077 // scroll into the host scrollbar visibly yanks the viewport.
6078 // See autofollow_shrink_test.go for the exact arithmetic.
6079 i.prevChatLen = 0
6080 i.prevChatCols = 0
6081 i.parkedTurn = 0 // starting a turn clears the /jump parked state
6082 i.parkedTotal = 0
6083 i.helpBlock = nil // hide the help block once the user asks something
6084 i.mu.Unlock()
6085 i.invalidate()
6086
6087 sink := func(ev core.AgentEvent) {
6088 i.handleEvent(ev)
6089 i.invalidate()
6090 }
6091
6092 go func() {
6093 var err error
6094 if overflowRecoveryAttempted {
6095 err = i.agent.Continue(ctx, sink)
6096 } else {
6097 err = i.agent.Prompt(ctx, prompt, images, sink)
6098 }
6099 i.mu.Lock()
6100 i.busy = false
6101 // Don't touch streamPending / streamFlushPending here — the
6102 // pacer may still be draining the final deltas and needs to
6103 // paint them even though Prompt has returned. It will reset
6104 // streamOn on its own once the buffer empties.
6105 if len(i.streamPending) == 0 {
6106 i.streamOn = false
6107 }
6108 i.cancelTurn = nil
6109 if err != nil && ctx.Err() == nil {
6110 i.statusErr = err.Error()
6111 }
6112 // Decide whether to offer a model rescue picker for recoverable
6113 // provider failures (auth/rate/temporary). The picker opens after
6114 // the mutex is released so it can take its own locks freely.
6115 var (
6116 offer bool
6117 rescueWhy string
6118 rescueImgs []provider.ImageBlock
6119 rescueModel string
6120 rescueProv string
6121 rescueFprov string
6122 )
6123 if err != nil && ctx.Err() == nil {
6124 if ok, reason := classifyRescueError(err); ok {
6125 offer = true
6126 rescueWhy = reason
6127 rescueImgs = images
6128 rescueModel = i.cfg.Model
6129 rescueProv = i.cfg.Provider
6130 rescueFprov = extractFailedProvider(err)
6131 if rescueFprov == "" {
6132 rescueFprov = i.cfg.Provider
6133 }
6134 // Suppress the red banner — the rescue dialog already
6135 // surfaces the failure.
6136 i.statusErr = ""
6137 }
6138 }
6139 // Detect responses that reject the current context, either as an
6140 // HTTP 413 payload limit or a model context-window error. Token-
6141 // based auto-compact can miss both when metadata is stale or the
6142 // limit is measured in raw bytes. Compact once, then continue the
6143 // user message already present in the transcript.
6144 contextOverflow := err != nil && ctx.Err() == nil && isContextOverflowError(err)
6145 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6146 if recoverContextOverflow {
6147 i.statusErr = ""
6148 i.continueAfterCompact = true
6149 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
6150 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
6151 }
6152 // Persist the assistant's reply (and every tool row before
6153 // it) to the session file while the turn memory is hot.
6154 // Without this, WriteNewTranscript only fires at zot exit,
6155 // meaning a crash or ungraceful kill drops the whole
6156 // conversation. FlushSession is idempotent (it advances the
6157 // baseline so subsequent flushes only write new rows).
6158 flush := i.cfg.FlushSession
6159 i.mu.Unlock()
6160 if flush != nil {
6161 flush()
6162 }
6163 i.mu.Lock()
6164 awaitingPre := i.awaitingStartupPre
6165 // Pop the next queued message, if any, and relaunch.
6166 var next string
6167 var hasNext bool
6168 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
6169 next, i.queued = i.queued[0], i.queued[1:]
6170 hasNext = true
6171 }
6172 // If the turn was cancelled or errored, drop the queue so the
6173 // user isn't bombarded with stale messages after an interrupt.
6174 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
6175 i.queued = nil
6176 if i.agent != nil {
6177 i.agent.DrainQueuedMessages()
6178 }
6179 }
6180 // Decide whether the next thing to do is an auto-compaction.
6181 // Only fires when the turn completed cleanly AND no host-side
6182 // or agent-side queued messages are waiting (otherwise a queued
6183 // message would race the condense).
6184 agentQueued := 0
6185 if i.agent != nil {
6186 agentQueued = i.agent.QueuedMessageCount()
6187 }
6188 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
6189 i.mu.Unlock()
6190 i.invalidate()
6191 parent := i.runCtx
6192 if parent == nil {
6193 parent = context.Background()
6194 }
6195 if awaitingPre {
6196 i.completeStartupPre()
6197 return
6198 }
6199 switch {
6200 case hasNext:
6201 i.startTurn(parent, next)
6202 case offer:
6203 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
6204 case recoverContextOverflow:
6205 i.runCompact(parent, true)
6206 case shouldAutoCompact:
6207 i.runCompact(parent, true)
6208 }
6209 }()
6210}
6211
6212// openRescueDialog surfaces the rescue model picker after a recoverable
6213// provider failure. The pending prompt + images are stashed on the
6214// Interactive so a later applyRescueSelection can re-run the same turn
6215// against the freshly-picked model. activeProvider/failedProvider are
6216// usually the same, but some clients embed different prefixes in their
6217// errors than the configured provider id, so we accept both.
6218func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
6219 if i.rescueDialog == nil {
6220 return
6221 }
6222 loggedIn := []string{}
6223 if i.cfg.LoggedInProviders != nil {
6224 loggedIn = i.cfg.LoggedInProviders()
6225 }
6226 fprov := failedProvider
6227 if fprov == "" {
6228 fprov = activeProvider
6229 }
6230 i.mu.Lock()
6231 i.pendingRescuePrompt = prompt
6232 i.pendingRescueImages = images
6233 i.mu.Unlock()
6234 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
6235 i.invalidate()
6236}
6237
6238// applyRescueSelection switches model (cross-provider if needed) and
6239// re-runs the same prompt+images that just failed. Mirrors
6240// applyModelSelection's transcript-carry logic so the user keeps full
6241// session continuity across the swap.
6242func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
6243 if model == "" {
6244 return
6245 }
6246 i.applyRescueModelSelection(prov, model)
6247 i.mu.Lock()
6248 images := i.pendingRescueImages
6249 if prompt == "" {
6250 prompt = i.pendingRescuePrompt
6251 }
6252 i.pendingRescuePrompt = ""
6253 i.pendingRescueImages = nil
6254 i.mu.Unlock()
6255 parent := i.runCtx
6256 if parent == nil {
6257 parent = context.Background()
6258 }
6259 i.startTurnWithImages(parent, prompt, images)
6260}
6261
6262func stripAutoCompactNotes(notes []string) []string {
6263 if len(notes) == 0 {
6264 return notes
6265 }
6266 out := notes[:0]
6267 for _, n := range notes {
6268 if strings.Contains(n, "condensing history") {
6269 continue
6270 }
6271 out = append(out, n)
6272 }
6273 return out
6274}
6275
6276// autoCompactNoteLine returns a styled chat-area note for the
6277// inline auto-compact heads-up. Lives in extNotes so it survives
6278// the busy-spinner overwrite of the status row.
6279func autoCompactNoteLine(th tui.Theme, msg string) string {
6280 return " " + th.FG256(th.Warning, "⚠ "+msg)
6281}
6282
6283// isContextOverflowError matches provider responses that reject the
6284// current request because its raw payload or tokenized input is too
6285// large. Providers surface this as either HTTP 413 or semantic context
6286// errors with differing codes and messages.
6287func isContextOverflowError(err error) bool {
6288 if err == nil {
6289 return false
6290 }
6291 msg := strings.ToLower(err.Error())
6292 return strings.Contains(msg, "http 413") ||
6293 strings.Contains(msg, " 413") ||
6294 strings.HasPrefix(msg, "413 ") ||
6295 strings.Contains(msg, "payload too large") ||
6296 strings.Contains(msg, "request entity too large") ||
6297 strings.Contains(msg, "input exceeds the context window") ||
6298 strings.Contains(msg, "context window exceeded") ||
6299 strings.Contains(msg, "maximum context length") ||
6300 strings.Contains(msg, "context_length_exceeded") ||
6301 (strings.Contains(msg, "input") && strings.Contains(msg, "exceeds the maximum number of tokens"))
6302}
6303
6304const defaultAutoCompactThreshold = 85
6305
6306func normalizeAutoCompactThreshold(threshold *int) int {
6307 if threshold == nil {
6308 return defaultAutoCompactThreshold
6309 }
6310 switch *threshold {
6311 case 0, 70, 80, 85, 90:
6312 return *threshold
6313 default:
6314 return defaultAutoCompactThreshold
6315 }
6316}
6317
6318func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
6319 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
6320 return false
6321 }
6322 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
6323}
6324
6325// shouldAutoCompactLocked reports whether the last turn pushed context
6326// usage past the auto-compact threshold. Must be called with i.mu
6327// held; it reads lastCtxInput and the current model's context window.
6328func (i *Interactive) shouldAutoCompactLocked() bool {
6329 if i.agent == nil {
6330 return false
6331 }
6332 if i.autoCompacting {
6333 return false
6334 }
6335 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
6336 if err != nil || m.ContextWindow <= 0 {
6337 return false
6338 }
6339 threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
6340 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
6341}
6342
6343func (i *Interactive) handleEvent(ev core.AgentEvent) {
6344 i.mu.Lock()
6345 defer i.mu.Unlock()
6346 switch e := ev.(type) {
6347 case core.EvAssistantStart:
6348 // Fires at the top of every oneTurn, including follow-up
6349 // turns after tool use. Without this, the streaming buffer
6350 // is still marked off from the previous assistant message
6351 // and the final summary text pops in all at once instead
6352 // of typewriter-streaming delta by delta.
6353 i.streaming.Reset()
6354 i.streamPending = i.streamPending[:0]
6355 i.streamFlushPending = false
6356 i.streamOn = true
6357 // Clear the live tool-call overlay. Any tools from the
6358 // previous round are now fully folded into the transcript
6359 // (assistant tool_use block + tool role message with the
6360 // result), so keeping them in the overlay would duplicate
6361 // them in the view — once inside the finalised transcript
6362 // and once below the streaming block, with the streaming
6363 // summary sandwiched in between. The next EvToolUseStart
6364 // will populate fresh entries for this turn's tools.
6365 i.toolCalls = map[string]*tui.ToolCallView{}
6366 i.toolOrder = nil
6367 i.toolGate = map[string]int{}
6368 case core.EvTextDelta:
6369 // Buffer into streamPending; the paintPace ticker drains
6370 // it into i.streaming a few runes at a time for a smooth
6371 // typewriter effect independent of upstream chunk size.
6372 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
6373 i.streamOn = true
6374 case core.EvAssistantMessage:
6375 // OnAssistant + telegram mirroring always fire on message
6376 // arrival — they read the FINAL message content, which is
6377 // complete regardless of what's still in the pacer.
6378 i.assistantMessageSideEffects(e.Message)
6379 // If the pacer still has characters to drain, keep streamOn
6380 // true and mark flush pending; the paintPace ticker will
6381 // drain the remainder and reset streaming state when done.
6382 // Otherwise (rare: full-replay sessions, abort paths) clear
6383 // synchronously so a later render doesn't show stale text.
6384 if len(i.streamPending) > 0 {
6385 i.streamFlushPending = true
6386 return
6387 }
6388 i.resetStreamingStateLocked()
6389 case core.EvToolUseStart:
6390 // Live streaming: pre-create the view so the user sees the
6391 // tool call being composed in real time. Any subsequent
6392 // EvToolCall for the same ID updates the same struct (the
6393 // final parsed args + name are already known here).
6394 if _, exists := i.toolCalls[e.ID]; !exists {
6395 i.toolCalls[e.ID] = &tui.ToolCallView{
6396 ID: e.ID,
6397 Name: e.Name,
6398 Streaming: true,
6399 }
6400 i.toolOrder = append(i.toolOrder, e.ID)
6401 i.gateToolLocked(e.ID)
6402 }
6403 case core.EvToolUseArgs:
6404 if tc, ok := i.toolCalls[e.ID]; ok {
6405 tc.RawJSONBuf += e.Delta
6406 // Refresh the live path as soon as it parses; used in
6407 // the header (write /Users/pat/Desktop/demo.ts)
6408 // while the content is still streaming.
6409 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
6410 tc.LivePath = p
6411 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
6412 tc.LivePath = p
6413 }
6414 }
6415 case core.EvToolUseEnd:
6416 if tc, ok := i.toolCalls[e.ID]; ok {
6417 tc.Streaming = false
6418 }
6419 case core.EvToolCall:
6420 // If we already pre-created the view during streaming, just
6421 // refresh the final Args summary. Otherwise create a new one
6422 // (non-streaming providers or legacy paths).
6423 if tc, ok := i.toolCalls[e.ID]; ok {
6424 tc.Args = tui.ShortArgs(e.Name, e.Args)
6425 if tc.RawJSONBuf == "" {
6426 tc.RawJSONBuf = string(e.Args)
6427 }
6428 tc.Streaming = false
6429 } else {
6430 i.toolCalls[e.ID] = &tui.ToolCallView{
6431 ID: e.ID,
6432 Name: e.Name,
6433 Args: tui.ShortArgs(e.Name, e.Args),
6434 RawJSONBuf: string(e.Args),
6435 }
6436 i.toolOrder = append(i.toolOrder, e.ID)
6437 i.gateToolLocked(e.ID)
6438 }
6439 case core.EvToolResult:
6440 if tc, ok := i.toolCalls[e.ID]; ok {
6441 tc.Done = true
6442 tc.Error = e.Result.IsError
6443 tc.Preview = ""
6444 var text strings.Builder
6445 for _, c := range e.Result.Content {
6446 if tb, ok := c.(provider.TextBlock); ok {
6447 if text.Len() > 0 {
6448 text.WriteString("\n")
6449 }
6450 text.WriteString(tb.Text)
6451 }
6452 }
6453 tc.Result = text.String()
6454 }
6455 if i.cfg.OnToolResult != nil {
6456 i.cfg.OnToolResult(e.ID, e.Result)
6457 }
6458 case core.EvUsage:
6459 i.cumUsage = e.Cumulative
6460 if e.Usage.InputTokens > 0 {
6461 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
6462 }
6463 case core.EvTurnEnd:
6464 if e.Stop == provider.StopAborted {
6465 i.resetStreamingStateLocked()
6466 i.statusErr = ""
6467 i.statusOK = "cancelled"
6468 return
6469 }
6470 if e.Stop == provider.StopLength {
6471 // The model hit its output-token cap mid-response, so the
6472 // reply (often a long write/edit) is truncated. Surface it
6473 // explicitly, otherwise the turn just ends and reads like
6474 // the UI gave up. The agent already requests the model's
6475 // full MaxOutput budget, so this means the response genuinely
6476 // exceeded that ceiling; ask the user to continue.
6477 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
6478 i.statusOK = ""
6479 return
6480 }
6481 // Don't surface mid-loop stream errors as a red banner here.
6482 // EvTurnEnd fires after every step in a multi-step tool loop,
6483 // so a transient 503 / network blip would briefly paint a red
6484 // banner over the still-streaming chat before the agent loop
6485 // either retries or exits. The final error (if any) is set by
6486 // startTurnWithImages once Prompt() returns, and recoverable
6487 // failures are routed to the rescue picker instead — which
6488 // keeps the chat clean while the agent is working.
6489 _ = e.Err
6490 }
6491}
6492
6493// Agent returns the current agent, if any. Used by cli.go to flush the
6494// final transcript to the session file.
6495func (i *Interactive) Agent() *core.Agent {
6496 i.mu.Lock()
6497 defer i.mu.Unlock()
6498 return i.agent
6499}
6500
6501// silence unused import in some build configs
6502var _ = fmt.Sprintf
6503
6504// runReloadExt triggers a live reload of every extension (discovered
6505// + explicit). Runs on a goroutine so the TUI stays responsive; the
6506// Manager.Reload takes a couple of hundred ms to shut down subprocs
6507// and respawn them. Shows a temporary status line throughout.
6508func (i *Interactive) runReloadExt(ctx context.Context) {
6509 if i.cfg.Extensions == nil {
6510 i.mu.Lock()
6511 i.statusErr = "no extension manager in this build"
6512 i.mu.Unlock()
6513 i.invalidate()
6514 return
6515 }
6516 i.mu.Lock()
6517 i.statusOK = "reloading extensions..."
6518 i.statusErr = ""
6519 i.mu.Unlock()
6520 i.invalidate()
6521
6522 go func() {
6523 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
6524 msg, failed := formatReloadStatus(stats)
6525 seq := i.setReloadStatus(msg, failed)
6526 i.invalidate()
6527 go i.dismissReloadStatus(ctx, seq, msg, failed)
6528 }()
6529}
6530
6531// Confirm implements core.Confirmer. The agent goroutine calls
6532// this synchronously before every tool invocation when --no-yolo is
6533// active. We push the request onto the confirmDialog queue, trigger
6534// a redraw, and block the caller until the user answers.
6535//
6536// If the session is cancelled or the TUI exits mid-prompt, any
6537// pending request is refused via CancelAll so the agent doesn't
6538// deadlock.
6539func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
6540 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
6541}
6542
6543// ConfirmToolCall attaches a side-effect-free preview to the matching live
6544// tool panel, then blocks until the user approves or refuses the call.
6545func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
6546 resp := make(chan core.ConfirmDecision, 1)
6547 returnToChild := false
6548 if call.ID != "" {
6549 i.mu.Lock()
6550 if tc, ok := i.toolCalls[call.ID]; ok {
6551 tc.Args = call.Summary
6552 tc.Preview = call.Content
6553 }
6554 i.mu.Unlock()
6555 if i.btwDialog != nil && i.btwDialog.Active() {
6556 returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
6557 }
6558 }
6559 i.confirmDialog.Enqueue(&confirmRequest{
6560 toolName: call.Name,
6561 preview: call.Summary,
6562 resp: resp,
6563 returnToChild: returnToChild,
6564 })
6565 i.invalidate()
6566 return <-resp
6567}
6568
6569// openTelegramDialog shows the picker for `/telegram` with no arg.
6570// Items depend on current state: disconnect + status when running,
6571// connect + status when stopped.
6572func (i *Interactive) openTelegramDialog() {
6573 items := i.telegramMenuItems()
6574 if len(items) == 0 {
6575 i.mu.Lock()
6576 i.statusErr = "telegram not configured. run `zot telegram-bot setup` first."
6577 i.mu.Unlock()
6578 i.invalidate()
6579 return
6580 }
6581 i.telegramDialog.Open(items)
6582 i.invalidate()
6583}
6584
6585// telegramMenuItems builds the dialog entries for the current
6586// bridge state. Returns empty when no bot.json exists so the
6587// caller can show a helpful status line instead of an empty menu.
6588func (i *Interactive) telegramMenuItems() []telegramItem {
6589 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6590 if err != nil || cfg.BotToken == "" {
6591 return nil
6592 }
6593 var items []telegramItem
6594 if i.telegramBridge != nil && i.telegramBridge.Active() {
6595 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
6596 st := i.telegramBridge.State()
6597 hint := "active"
6598 if st.Username != "" {
6599 hint += " as @" + st.Username
6600 }
6601 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
6602 } else {
6603 label := "connect"
6604 hint := "start mirroring dms into this session"
6605 if cfg.AllowedUserID == 0 {
6606 hint = "awaiting pairing (send /start to the bot once connected)"
6607 }
6608 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
6609 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
6610 }
6611 return items
6612}
6613
6614// doTelegram dispatches one of the three explicit actions. Called
6615// from /telegram <action> or after the picker selects a row.
6616func (i *Interactive) doTelegram(action string) {
6617 switch action {
6618 case "connect":
6619 i.telegramConnect()
6620 case "disconnect":
6621 i.telegramDisconnect()
6622 case "status":
6623 i.telegramStatus()
6624 default:
6625 i.mu.Lock()
6626 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
6627 i.mu.Unlock()
6628 i.invalidate()
6629 }
6630}
6631
6632// telegramConnect starts the bridge. Refuses if it's already
6633// running or if the on-disk bot.json is missing a token.
6634func (i *Interactive) telegramConnect() {
6635 if i.telegramBridge != nil && i.telegramBridge.Active() {
6636 i.mu.Lock()
6637 i.statusOK = "telegram already connected"
6638 i.statusErr = ""
6639 i.mu.Unlock()
6640 i.invalidate()
6641 return
6642 }
6643 cfg, err := telegram.LoadConfig(i.cfg.ZotHome)
6644 if err != nil {
6645 i.mu.Lock()
6646 i.statusErr = "telegram: " + err.Error()
6647 i.mu.Unlock()
6648 i.invalidate()
6649 return
6650 }
6651 if cfg.BotToken == "" {
6652 i.mu.Lock()
6653 i.statusErr = "telegram: no bot token configured. run `zot telegram-bot setup` first."
6654 i.mu.Unlock()
6655 i.invalidate()
6656 return
6657 }
6658 // Refuse to start when a background daemon is already polling
6659 // the same bot. Two concurrent long-poll consumers race each
6660 // update and one always loses, so DMs get half-delivered. The
6661 // user can `zot telegram-bot stop` first, then /telegram connect.
6662 if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
6663 i.mu.Lock()
6664 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zot telegram-bot stop` first.", pid)
6665 i.mu.Unlock()
6666 i.invalidate()
6667 return
6668 }
6669 i.telegramBridge = &telegram.Bridge{
6670 Client: telegram.NewClient(cfg.BotToken),
6671 Config: cfg,
6672 Save: func(next telegram.Config) error {
6673 return telegram.SaveConfig(i.cfg.ZotHome, next)
6674 },
6675 Host: &telegramHost{iv: i},
6676 }
6677 if err := i.telegramBridge.Start(i.runCtx); err != nil {
6678 i.telegramBridge = nil
6679 i.mu.Lock()
6680 i.statusErr = "telegram connect failed: " + err.Error()
6681 i.mu.Unlock()
6682 i.invalidate()
6683 return
6684 }
6685 i.applyTelegramTools(true)
6686 state := i.telegramBridge.State()
6687 label := "telegram connected"
6688 if state.Username != "" {
6689 label += " as @" + state.Username
6690 }
6691 if state.PairedID == 0 {
6692 label += " — send /start to the bot from your phone to claim it"
6693 }
6694 i.mu.Lock()
6695 i.statusOK = label
6696 i.statusErr = ""
6697 i.mu.Unlock()
6698 i.invalidate()
6699}
6700
6701// telegramDisconnect stops the bridge. No-op when already stopped.
6702func (i *Interactive) telegramDisconnect() {
6703 if i.telegramBridge == nil || !i.telegramBridge.Active() {
6704 i.mu.Lock()
6705 i.statusOK = "telegram already disconnected"
6706 i.statusErr = ""
6707 i.mu.Unlock()
6708 i.invalidate()
6709 return
6710 }
6711 i.telegramBridge.Stop()
6712 i.applyTelegramTools(false)
6713 i.mu.Lock()
6714 i.statusOK = "telegram disconnected"
6715 i.statusErr = ""
6716 i.mu.Unlock()
6717 i.invalidate()
6718}
6719
6720// telegramSenderAdapter wraps the bridge so the tools package can
6721// drive it without importing telegram directly. The Active() check
6722// is forwarded to the bridge so the tool can fail clearly with a
6723// model-readable error when the user disconnected mid-turn.
6724type telegramSenderAdapter struct {
6725 bridge *telegram.Bridge
6726}
6727
6728func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
6729 if a.bridge == nil {
6730 return fmt.Errorf("telegram bridge is not connected")
6731 }
6732 return a.bridge.SendImage(ctx, path, caption)
6733}
6734
6735func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
6736 if a.bridge == nil {
6737 return fmt.Errorf("telegram bridge is not connected")
6738 }
6739 return a.bridge.SendDocument(ctx, path, caption)
6740}
6741
6742func (a telegramSenderAdapter) Active() bool {
6743 return a.bridge != nil && a.bridge.Active()
6744}
6745
6746// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the
6747// delegated turn's outcome separately from the long-lived daemon status.
6748type swarmWatchEntry struct {
6749 agent *swarm.Agent
6750 task string
6751 done bool
6752 outcome string
6753 err string
6754}
6755
6756// TrackSwarmAgent is the exported entry point used by the cli to
6757// hand a freshly-spawned auto-swarm agent off to the watcher.
6758func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
6759 i.trackSwarmAgent(a, task)
6760}
6761
6762// trackSwarmAgent records a freshly-spawned auto-swarm agent and
6763// subscribes to both ways its delegated task can finish. Successful
6764// long-lived agents report a prompt-level turn_end and keep listening;
6765// startup failures and unexpected daemon exits only unblock Agent.Wait.
6766// Whichever signal arrives first completes the entry exactly once.
6767//
6768// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
6769// swarm is enabled, so this is a no-op when the feature is off.
6770func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
6771 if i == nil || a == nil {
6772 return
6773 }
6774 entry := &swarmWatchEntry{agent: a, task: task}
6775 i.swarmWatchMu.Lock()
6776 i.swarmWatch = append(i.swarmWatch, entry)
6777 i.swarmWatchMu.Unlock()
6778
6779 a.SetOnTurnEnd(func(step int, errMsg string) {
6780 outcome := "completed"
6781 if errMsg != "" {
6782 outcome = "failed"
6783 }
6784 i.completeSwarmWatchEntry(entry, outcome, errMsg)
6785 })
6786 go func() {
6787 a.Wait()
6788 snap := a.Snapshot()
6789 outcome := string(snap.Status)
6790 if snap.Status == swarm.StatusDone {
6791 outcome = "completed"
6792 }
6793 i.completeSwarmWatchEntry(entry, outcome, snap.Err)
6794 }()
6795}
6796
6797func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {
6798 i.swarmWatchMu.Lock()
6799 if entry.done {
6800 i.swarmWatchMu.Unlock()
6801 return
6802 }
6803 entry.done = true
6804 entry.outcome = outcome
6805 entry.err = errMsg
6806 allDone := true
6807 for _, e := range i.swarmWatch {
6808 if !e.done {
6809 allDone = false
6810 break
6811 }
6812 }
6813 var batch []*swarmWatchEntry
6814 if allDone {
6815 batch = i.swarmWatch
6816 i.swarmWatch = nil
6817 }
6818 i.swarmWatchMu.Unlock()
6819 if len(batch) != 0 {
6820 i.flushSwarmSummary(batch)
6821 }
6822}
6823
6824// flushSwarmSummary composes a synthetic user turn describing every
6825// sub-agent's outcome and injects it via SubmitOrQueue so the main
6826// agent picks it up at the next safe boundary. The summary is
6827// phrased as a system update ("Auto-swarm finished: ...") so the
6828// model treats it as observed state, not as a fresh user request.
6829func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
6830 if len(batch) == 0 {
6831 return
6832 }
6833 var sb strings.Builder
6834 fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
6835 for idx, e := range batch {
6836 snap := e.agent.Snapshot()
6837 status := e.outcome
6838 if status == "" {
6839 status = string(snap.Status)
6840 }
6841 task := snap.Task
6842 if task == "" {
6843 task = e.task
6844 }
6845 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
6846 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
6847 if snap.Err != "" {
6848 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
6849 } else if e.err != "" {
6850 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
6851 }
6852 if snap.LastAssistant != "" {
6853 sb.WriteString(" final response:\n")
6854 sb.WriteString(snap.LastAssistant)
6855 sb.WriteString("\n")
6856 } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
6857 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
6858 }
6859 sb.WriteString("\n")
6860 }
6861 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.")
6862 i.SubmitOrQueue(sb.String(), nil)
6863}
6864
6865func truncateForSummary(s string, n int) string {
6866 s = strings.TrimSpace(s)
6867 if len(s) <= n {
6868 return s
6869 }
6870 return s[:n-3] + "..."
6871}
6872
6873// applyAutoSwarmSystemPrompt appends (active=true) or strips
6874// (active=false) the auto-swarm system-prompt block on the running
6875// agent so the model proactively considers swarm_spawn when the user
6876// flips the toggle. Edit the base prompt so extension replacements are not
6877// applied twice when the next turn prepares the rebuilt prompt.
6878func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6879 if i.agent == nil {
6880 return
6881 }
6882 addendum := i.cfg.AutoSwarmSystemAddendum
6883 if addendum == "" {
6884 return
6885 }
6886 sys := i.agent.BaseSystem()
6887 has := strings.Contains(sys, addendum)
6888 switch {
6889 case active && !has:
6890 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6891 sys += "\n\n"
6892 }
6893 i.agent.SetSystem(sys + addendum)
6894 case !active && has:
6895 i.agent.SetSystem(strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n")
6896 }
6897}
6898
6899// applyAutoSwarmTool registers (active=true) or removes (active=false)
6900// the swarm_spawn tool on the running agent so the model only sees it
6901// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
6902// snapshot+mutate pattern so extension tools and /reload-ext additions
6903// survive a toggle.
6904func (i *Interactive) autoSwarmEnabled() bool {
6905 return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6906}
6907
6908func (i *Interactive) openRouterServerToolsEnabled() bool {
6909 return i.cfg.OpenRouterServerToolsEnabled != nil && *i.cfg.OpenRouterServerToolsEnabled
6910}
6911
6912func (i *Interactive) applyOpenRouterServerTools(active bool) {
6913 if i.agent == nil {
6914 return
6915 }
6916 current := i.agent.Tools
6917 next := core.Registry{}
6918 for name, t := range current {
6919 if tools.IsOpenRouterServerTool(name) {
6920 continue
6921 }
6922 next[name] = t
6923 }
6924 i.agent.MaxToolCalls = 0
6925 if active && !i.cfg.NoTools && i.cfg.Provider == "openrouter" && !strings.Contains(strings.ToLower(i.cfg.Model), "@preset/") {
6926 for _, t := range tools.OpenRouterServerTools() {
6927 next[t.Name()] = t
6928 }
6929 i.agent.MaxToolCalls = tools.ResolveToolCallLimit(i.cfg.OpenRouterServerToolCallLimit)
6930 }
6931 i.agent.SetTools(next)
6932}
6933
6934func (i *Interactive) applyAutoSwarmTool(active bool) {
6935 if i.agent == nil {
6936 return
6937 }
6938 current := i.agent.Tools
6939 next := core.Registry{}
6940 for name, t := range current {
6941 if name == "swarm_spawn" {
6942 continue
6943 }
6944 next[name] = t
6945 }
6946 if active && i.cfg.Swarm != nil {
6947 next["swarm_spawn"] = &tools.SwarmSpawnTool{
6948 Swarm: i.cfg.Swarm,
6949 Enabled: func() bool { return true },
6950 DefaultModel: func() string { return i.cfg.Model },
6951 DefaultProvider: func() string { return i.cfg.Provider },
6952 OnSpawned: i.trackSwarmAgent,
6953 }
6954 }
6955 i.agent.SetTools(next)
6956}
6957
6958// applyTelegramTools registers (active=true) or removes (active=false)
6959// the telegram_send_image and telegram_send_file tools on the running
6960// agent so the model only sees them while the bridge is connected.
6961// Snapshots and mutates the live tool registry so any extension or
6962// /reload-ext additions made while Telegram is connected survive a
6963// later /telegram disconnect (we only add or strip the two telegram
6964// entries, never the rest).
6965func (i *Interactive) applyTelegramTools(active bool) {
6966 if i.agent == nil {
6967 return
6968 }
6969 current := i.agent.Tools
6970 next := core.Registry{}
6971 for name, t := range current {
6972 if name == "telegram_send_image" || name == "telegram_send_file" {
6973 continue
6974 }
6975 next[name] = t
6976 }
6977 if active {
6978 sender := telegramSenderAdapter{bridge: i.telegramBridge}
6979 next["telegram_send_image"] = &tools.TelegramSendImageTool{
6980 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6981 }
6982 next["telegram_send_file"] = &tools.TelegramSendFileTool{
6983 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
6984 }
6985 }
6986 i.agent.SetTools(next)
6987}
6988
6989// telegramStatus writes a one-liner describing the bridge state.
6990// Reports on both the in-tui bridge and the background daemon so
6991// the user isn't confused when the daemon owns the poll loop.
6992func (i *Interactive) telegramStatus() {
6993 var msg string
6994 if i.telegramBridge != nil && i.telegramBridge.Active() {
6995 s := i.telegramBridge.State()
6996 msg = "telegram: connected (tui bridge)"
6997 if s.Username != "" {
6998 msg += " as @" + s.Username
6999 }
7000 if s.PairedID != 0 {
7001 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
7002 } else {
7003 msg += " - awaiting pairing"
7004 }
7005 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZotHome); alive && pid > 0 {
7006 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
7007 } else {
7008 cfg, _ := telegram.LoadConfig(i.cfg.ZotHome)
7009 if cfg.BotToken == "" {
7010 msg = "telegram: not configured. run `zot telegram-bot setup` first."
7011 } else {
7012 msg = "telegram: disconnected"
7013 if cfg.BotUsername != "" {
7014 msg += " (@" + cfg.BotUsername + " ready to connect)"
7015 }
7016 }
7017 }
7018 i.mu.Lock()
7019 i.statusOK = msg
7020 i.statusErr = ""
7021 i.mu.Unlock()
7022 i.invalidate()
7023}
7024
7025// telegramHost adapts *Interactive to telegram.Host so the bridge
7026// can call back into the TUI without importing modes directly.
7027type telegramHost struct{ iv *Interactive }
7028
7029func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
7030 h.iv.SubmitOrQueue(prompt, images)
7031}
7032
7033func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
7034
7035func (h *telegramHost) Status() string {
7036 h.iv.mu.Lock()
7037 providerName := h.iv.cfg.Provider
7038 model := h.iv.cfg.Model
7039 cwd := h.iv.cfg.CWD
7040 usage := h.iv.cumUsage
7041 subscription := h.iv.cfg.AuthMethod == "oauth"
7042 ctxUsed := h.iv.lastCtxInput
7043 busy := h.iv.busy
7044 queued := len(h.iv.queued)
7045 h.iv.mu.Unlock()
7046
7047 ctxMax := 0
7048 if m, err := provider.FindModel(providerName, model); err == nil {
7049 ctxMax = m.ContextWindow
7050 }
7051 return telegram.FormatStatus(telegram.StatusSnapshot{
7052 Provider: providerName,
7053 Model: model,
7054 CWD: cwd,
7055 Usage: usage,
7056 Subscription: subscription,
7057 ContextUsed: ctxUsed,
7058 ContextMax: ctxMax,
7059 Busy: busy,
7060 Queued: queued,
7061 })
7062}
7063
7064func (h *telegramHost) Notify(level, message string) {
7065 h.iv.mu.Lock()
7066 switch level {
7067 case "error", "warn":
7068 h.iv.statusErr = message
7069 h.iv.statusOK = ""
7070 default:
7071 h.iv.statusOK = message
7072 h.iv.statusErr = ""
7073 }
7074 h.iv.mu.Unlock()
7075 h.iv.invalidate()
7076}
7077
7078// openSessionOpsDialog shows the picker for `/session` with no arg.
7079// Always offers timeline, export, import, fork, and tree. The handlers bail
7080// with a clear status message when a precondition is not met (empty transcript
7081// on fork; no parent/siblings on tree).
7082func (i *Interactive) openSessionOpsDialog() {
7083 items := []sessionOpsItem{
7084 {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
7085 {label: "export", action: "export", hint: "write the current session to a .zotsession file"},
7086 {label: "import", action: "import", hint: "load a .zotsession file into this directory"},
7087 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
7088 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
7089 }
7090 i.sessionOpsDialog.Open(items)
7091 i.invalidate()
7092}
7093
7094// doSessionOp dispatches timeline, export, import, fork, or tree. arg is the
7095// optional positional argument from e.g. /session export <path>
7096// or /session import <path>; timeline, fork, and tree ignore it.
7097func (i *Interactive) doSessionOp(action, arg string) {
7098 switch action {
7099 case "timeline":
7100 i.openTimeline()
7101 case "export":
7102 i.doSessionExport(arg)
7103 case "import":
7104 i.doSessionImport(arg)
7105 case "fork":
7106 i.doSessionFork()
7107 case "tree":
7108 i.doSessionTree()
7109 default:
7110 i.mu.Lock()
7111 i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
7112 i.mu.Unlock()
7113 i.invalidate()
7114 }
7115}
7116
7117func (i *Interactive) openTimeline() {
7118 i.mu.Lock()
7119 if i.timeline == nil {
7120 i.timeline = newTimelineView()
7121 }
7122 i.timeline.Open(i.timelineDataLocked())
7123 i.chatCacheValid = false
7124 i.scrollOffset = 0
7125 i.statusErr = ""
7126 i.statusOK = ""
7127 i.mu.Unlock()
7128 i.invalidate()
7129}
7130
7131// doSessionExport writes the live session file to destination path
7132// dst. When dst is empty we default to ~/Downloads (falling back to
7133// the user's home directory if it doesn't exist). The helper
7134// expands a leading `~` and creates any missing parent directories.
7135func (i *Interactive) doSessionExport(dst string) {
7136 if i.cfg.CurrentSessionPath == nil {
7137 i.mu.Lock()
7138 i.statusErr = "export: no session is active (running with --no-session?)"
7139 i.mu.Unlock()
7140 i.invalidate()
7141 return
7142 }
7143 src := i.cfg.CurrentSessionPath()
7144 if src == "" {
7145 i.mu.Lock()
7146 i.statusErr = "export: no session is active (running with --no-session?)"
7147 i.mu.Unlock()
7148 i.invalidate()
7149 return
7150 }
7151 // Persist any in-memory agent messages to the session file so
7152 // the export carries the full conversation. Without this, the
7153 // default lazy-flush-at-exit strategy leaves most of a running
7154 // session unwritten and the export ends up with only the meta.
7155 if i.cfg.FlushSession != nil {
7156 i.cfg.FlushSession()
7157 }
7158 dst = unquotePath(dst)
7159 if dst == "" {
7160 dst = defaultExportDir()
7161 } else {
7162 dst = expandTilde(dst)
7163 }
7164 out, err := core.ExportSession(src, dst)
7165 if err != nil {
7166 i.mu.Lock()
7167 i.statusErr = "export: " + err.Error()
7168 i.mu.Unlock()
7169 i.invalidate()
7170 return
7171 }
7172 i.mu.Lock()
7173 i.statusOK = "exported session to " + friendlyPath(out)
7174 i.statusErr = ""
7175 i.mu.Unlock()
7176 i.invalidate()
7177}
7178
7179// doSessionImport copies the .zotsession file at src into the
7180// running cwd's sessions directory and loads it as the active
7181// session, same as `/sessions` -> pick. When src is empty we ask
7182// the user to pass a path (no usable default here).
7183func (i *Interactive) doSessionImport(src string) {
7184 src = unquotePath(src)
7185 if src == "" {
7186 i.mu.Lock()
7187 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession"
7188 i.mu.Unlock()
7189 i.invalidate()
7190 return
7191 }
7192 src = expandTilde(src)
7193 if _, err := os.Stat(src); err != nil {
7194 i.mu.Lock()
7195 i.statusErr = "import: " + err.Error()
7196 i.mu.Unlock()
7197 i.invalidate()
7198 return
7199 }
7200 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
7201 if err != nil {
7202 i.mu.Lock()
7203 i.statusErr = "import: " + err.Error()
7204 i.mu.Unlock()
7205 i.invalidate()
7206 return
7207 }
7208 if i.cfg.LoadSession == nil {
7209 i.mu.Lock()
7210 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
7211 i.statusErr = ""
7212 i.mu.Unlock()
7213 i.invalidate()
7214 return
7215 }
7216 if err := i.cfg.LoadSession(newPath); err != nil {
7217 i.mu.Lock()
7218 i.statusErr = "import: load failed: " + err.Error()
7219 i.mu.Unlock()
7220 i.invalidate()
7221 return
7222 }
7223 i.mu.Lock()
7224 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
7225 i.statusErr = ""
7226 i.mu.Unlock()
7227 i.invalidate()
7228}
7229
7230// defaultExportDir returns ~/Downloads when it exists, or ~ as a
7231// fallback, or /tmp on exotic machines with no home dir.
7232func defaultExportDir() string {
7233 home, err := os.UserHomeDir()
7234 if err != nil || home == "" {
7235 return os.TempDir()
7236 }
7237 downloads := filepath.Join(home, "Downloads")
7238 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
7239 return downloads
7240 }
7241 return home
7242}
7243
7244// expandTilde turns a leading ~ into the user's home directory.
7245// Returns the input unchanged when there's no tilde or no home.
7246func expandTilde(p string) string {
7247 if p == "" || p[0] != '~' {
7248 return p
7249 }
7250 home, err := os.UserHomeDir()
7251 if err != nil || home == "" {
7252 return p
7253 }
7254 if len(p) == 1 {
7255 return home
7256 }
7257 if p[1] == '/' || p[1] == filepath.Separator {
7258 return filepath.Join(home, p[2:])
7259 }
7260 return p
7261}
7262
7263// unquotePath strips a matching pair of surrounding single or
7264// double quotes. Drag-drop paste in the tui auto-quotes dropped
7265// file paths so the shell-like `/session import 'foo bar.zs'`
7266// stays well-formed; when the TUI's own slash handler consumes
7267// the arg, we want the raw path back.
7268func unquotePath(p string) string {
7269 p = strings.TrimSpace(p)
7270 if len(p) >= 2 {
7271 first := p[0]
7272 last := p[len(p)-1]
7273 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
7274 p = p[1 : len(p)-1]
7275 }
7276 }
7277 return p
7278}
7279
7280// friendlyPath collapses the user's home directory to a leading ~
7281// so status messages read cleanly. Falls back to the raw path when
7282// the home dir is unknown.
7283func friendlyPath(p string) string {
7284 home, err := os.UserHomeDir()
7285 if err != nil || home == "" {
7286 return p
7287 }
7288 if strings.HasPrefix(p, home+string(filepath.Separator)) {
7289 return "~" + p[len(home):]
7290 }
7291 return p
7292}
7293
7294// doSessionFork opens the /jump turn picker in "fork mode". The
7295// next selection branches the current session at that user turn
7296// instead of scrolling the viewport to it.
7297func (i *Interactive) doSessionFork() {
7298 if i.cfg.CurrentSessionPath == nil || i.cfg.CurrentSessionPath() == "" {
7299 i.mu.Lock()
7300 i.statusErr = "fork: no session is active (running with --no-session?)"
7301 i.mu.Unlock()
7302 i.invalidate()
7303 return
7304 }
7305 msgs := []provider.Message{}
7306 if i.agent != nil {
7307 msgs = i.agent.Messages()
7308 }
7309 if len(msgs) == 0 {
7310 i.mu.Lock()
7311 i.statusErr = "fork: transcript is empty; nothing to fork from"
7312 i.mu.Unlock()
7313 i.invalidate()
7314 return
7315 }
7316 i.pendingFork = true
7317 i.jumpDialog.Open(msgs, "")
7318 i.invalidate()
7319}
7320
7321// doSessionTree shows the current session family as an inline branch tree.
7322// Pick an entry to check out that point into a new branch. Subsequent
7323// prompts use only context up to the selected message.
7324func (i *Interactive) doSessionTree() {
7325 current := ""
7326 if i.cfg.CurrentSessionPath != nil {
7327 current = i.cfg.CurrentSessionPath()
7328 }
7329 if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
7330 i.invalidate()
7331 return
7332 }
7333 msgs := []provider.Message{}
7334 if i.agent != nil {
7335 msgs = i.agent.Messages()
7336 }
7337 if len(msgs) == 0 {
7338 i.mu.Lock()
7339 i.statusErr = "tree: no messages in this session yet"
7340 i.mu.Unlock()
7341 i.invalidate()
7342 return
7343 }
7344 if !i.sessionTreeDialog.OpenMessages(msgs) {
7345 i.mu.Lock()
7346 i.statusErr = "tree: no messages to show"
7347 i.mu.Unlock()
7348 }
7349 i.invalidate()
7350}
7351
7352// applySessionTreeMessageSelection checks out the selected message into a
7353// new branch, then switches the running agent to that branch. Unlike /jump,
7354// this changes future model context: only messages up to the selected row are
7355// sent on subsequent turns.
7356func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
7357 if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
7358 i.mu.Lock()
7359 i.statusErr = "tree: session branching is not available in this build"
7360 i.mu.Unlock()
7361 i.invalidate()
7362 return
7363 }
7364 if src == "" {
7365 src = i.cfg.CurrentSessionPath()
7366 }
7367 if src == "" {
7368 i.mu.Lock()
7369 i.statusErr = "tree: no session is active"
7370 i.mu.Unlock()
7371 i.invalidate()
7372 return
7373 }
7374 if i.cfg.FlushSession != nil {
7375 i.cfg.FlushSession()
7376 }
7377 upTo := msgIdx + 1
7378 if role == provider.RoleUser {
7379 upTo = msgIdx
7380 }
7381 newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7382 if err != nil {
7383 i.mu.Lock()
7384 i.statusErr = "tree: " + err.Error()
7385 i.mu.Unlock()
7386 i.invalidate()
7387 return
7388 }
7389 if err := i.cfg.LoadSession(newPath); err != nil {
7390 i.mu.Lock()
7391 i.statusErr = "tree: checkout failed: " + err.Error()
7392 i.mu.Unlock()
7393 i.invalidate()
7394 return
7395 }
7396 i.scrollToBottom()
7397 i.mu.Lock()
7398 if role == provider.RoleUser {
7399 i.ed.SetValue(prompt)
7400 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
7401 } else {
7402 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
7403 }
7404 i.statusErr = ""
7405 i.mu.Unlock()
7406 i.invalidate()
7407}
7408
7409// applyForkSelection branches the current session at msgIdx+1 (so
7410// the selected user message and everything before it is included
7411// in the new branch), then switches the running agent to the new
7412// file. Called from the jump-dialog handler when pendingFork=true.
7413func (i *Interactive) applyForkSelection(msgIdx int) {
7414 i.pendingFork = false
7415 if i.cfg.CurrentSessionPath == nil {
7416 i.mu.Lock()
7417 i.statusErr = "fork: no session is active"
7418 i.mu.Unlock()
7419 i.invalidate()
7420 return
7421 }
7422 src := i.cfg.CurrentSessionPath()
7423 if src == "" {
7424 i.mu.Lock()
7425 i.statusErr = "fork: no session is active"
7426 i.mu.Unlock()
7427 i.invalidate()
7428 return
7429 }
7430 if i.cfg.FlushSession != nil {
7431 i.cfg.FlushSession()
7432 }
7433 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
7434 // the selected user message is included.
7435 upTo := msgIdx + 1
7436 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
7437 if err != nil {
7438 i.mu.Lock()
7439 i.statusErr = "fork: " + err.Error()
7440 i.mu.Unlock()
7441 i.invalidate()
7442 return
7443 }
7444 if i.cfg.LoadSession == nil {
7445 i.mu.Lock()
7446 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
7447 i.statusErr = ""
7448 i.mu.Unlock()
7449 i.invalidate()
7450 return
7451 }
7452 if err := i.cfg.LoadSession(newPath); err != nil {
7453 i.mu.Lock()
7454 i.statusErr = "fork: switch failed: " + err.Error()
7455 i.mu.Unlock()
7456 i.invalidate()
7457 return
7458 }
7459 i.mu.Lock()
7460 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
7461 i.statusErr = ""
7462 i.mu.Unlock()
7463 i.invalidate()
7464}
7465
7466// formatInt is a tiny strconv.Itoa shim; keeps the handler above
7467// from needing a strconv import just for one call.
7468func formatInt(n int) string {
7469 return fmt.Sprintf("%d", n)
7470}
7471
7472// assistantText returns the concatenated text of every TextBlock in
7473// m. Used by the streaming-view dedupe guard to tell when a live
7474// streamed reply has already been promoted into the transcript.
7475func assistantText(m provider.Message) string {
7476 var sb strings.Builder
7477 for _, c := range m.Content {
7478 if tb, ok := c.(provider.TextBlock); ok {
7479 if sb.Len() > 0 {
7480 sb.WriteByte('\n')
7481 }
7482 sb.WriteString(tb.Text)
7483 }
7484 }
7485 return sb.String()
7486}
7487
7488// resetStreamingStateLocked clears every piece of streaming state
7489// in one shot. Used by abort paths (turn cancel, compact finish,
7490// queue drain) so the pacer doesn't keep draining stale runes from
7491// a prior turn. Must be called with i.mu held.
7492func (i *Interactive) resetStreamingStateLocked() {
7493 i.streaming.Reset()
7494 i.streamPending = i.streamPending[:0]
7495 i.streamFlushPending = false
7496 i.streamOn = false
7497 i.openAllToolGatesLocked()
7498}
7499
7500// openAllToolGatesLocked drops every pending tool gate so that any
7501// tool registered during this turn renders unconditionally from now
7502// on. Called when streaming finalizes (the paced text has fully
7503// drained and `streaming` is about to reset to length 0): without
7504// this, the gate comparison against a freshly-reset streaming buffer
7505// would wrongly re-hide tools that had already cleared their gate.
7506// Must be called with i.mu held.
7507func (i *Interactive) openAllToolGatesLocked() {
7508 for id := range i.toolGate {
7509 i.toolGate[id] = 0
7510 }
7511}
7512
7513// gateToolLocked records the stream position at which a tool call may
7514// become visible. The gate is the total length the streaming buffer
7515// will reach once the pacer has drained everything currently queued
7516// (already painted + still pending). Holding the tool block back
7517// until the pacer crosses that mark guarantees the prose emitted
7518// before the tool call finishes typing out above it, instead of the
7519// tool block snapping in while the paragraph is still filling in.
7520//
7521// We only gate while text is actively streaming. If no stream is in
7522// flight (gate 0), the tool shows immediately, which is the correct
7523// behaviour for tool-only turns and replayed sessions. First
7524// registration wins so a later EvToolCall can't move an existing
7525// gate. Must be called with i.mu held.
7526func (i *Interactive) gateToolLocked(id string) {
7527 if _, ok := i.toolGate[id]; ok {
7528 return
7529 }
7530 if !i.streamOn {
7531 i.toolGate[id] = 0
7532 return
7533 }
7534 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
7535}
7536
7537// toolGateOpenLocked reports whether the gated tool block may render
7538// yet, i.e. the pacer has drained enough text to reach the position
7539// recorded when the tool call arrived. Must be called with i.mu held.
7540func (i *Interactive) toolGateOpenLocked(id string) bool {
7541 gate, ok := i.toolGate[id]
7542 if !ok || gate == 0 {
7543 return true
7544 }
7545 return i.streaming.Len() >= gate
7546}
7547
7548// assistantMessageSideEffects runs the non-visual hooks attached to
7549// EvAssistantMessage: the host-provided OnAssistant callback and the
7550// telegram-bridge mirror. Called with i.mu held.
7551//
7552// Factored out of handleEvent because the streaming pacer may defer
7553// visual reset until after the last buffered rune has painted, but
7554// the callbacks themselves must fire on message arrival so
7555// downstream observers (session persistence, telegram, cost panels)
7556// don't wait on a UI animation to catch up.
7557func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
7558 if i.cfg.OnAssistant != nil {
7559 i.cfg.OnAssistant(m)
7560 }
7561 if i.telegramBridge != nil && i.telegramBridge.Active() {
7562 var sb strings.Builder
7563 for _, c := range m.Content {
7564 if tb, ok := c.(provider.TextBlock); ok {
7565 if sb.Len() > 0 {
7566 sb.WriteString("\n")
7567 }
7568 sb.WriteString(tb.Text)
7569 }
7570 }
7571 if text := sb.String(); strings.TrimSpace(text) != "" {
7572 go i.telegramBridge.OnAssistantText(text)
7573 }
7574 }
7575}
7576
7577// paintPaceRate is how many runes the streaming pacer releases per
7578// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
7579// that a 500-rune summary finishes in ~1.3s, slow enough to look
7580// like a human typing. Empirically matches the feel of provider
7581// paths that already drip-stream natively.
7582const paintPaceRate = 6
7583
7584// paintPaceInterval is the tick interval for the streaming pacer.
7585// 16ms lines up with the redraw throttle so we never paint faster
7586// than the terminal can keep up.
7587const paintPaceInterval = 16 * time.Millisecond
7588
7589// runStreamPacer drains buffered deltas from streamPending into
7590// streaming a small batch per tick, invalidating after each move.
7591// It stops when the context cancels (tui shutdown).
7592//
7593// Why a pacer: providers differ wildly in how they chunk their
7594// text_delta events. The API-key path on Anthropic emits ~30 drips
7595// for a 400-token summary; the OAuth path can coalesce the same
7596// summary into 3 fat chunks, visually indistinguishable from "the
7597// whole reply just appeared". The pacer normalizes that so every
7598// path looks the same on screen.
7599func (i *Interactive) runStreamPacer(ctx context.Context) {
7600 t := time.NewTicker(paintPaceInterval)
7601 defer t.Stop()
7602 for {
7603 select {
7604 case <-ctx.Done():
7605 return
7606 case <-t.C:
7607 i.mu.Lock()
7608 if len(i.streamPending) == 0 {
7609 // EvAssistantMessage already fired but the pacer
7610 // was still draining a tick ago. Everything is now
7611 // painted; clear the streaming flags so the next
7612 // redraw shows the finalised transcript message
7613 // and hides the streaming overlay.
7614 if i.streamFlushPending {
7615 i.streamFlushPending = false
7616 i.streaming.Reset()
7617 i.streamOn = false
7618 i.openAllToolGatesLocked()
7619 i.mu.Unlock()
7620 i.invalidate()
7621 continue
7622 }
7623 i.mu.Unlock()
7624 continue
7625 }
7626 n := paintPaceRate
7627 if n > len(i.streamPending) {
7628 n = len(i.streamPending)
7629 }
7630 i.streaming.WriteString(string(i.streamPending[:n]))
7631 i.streamPending = i.streamPending[n:]
7632 i.mu.Unlock()
7633 i.invalidate()
7634 }
7635 }
7636}