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