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