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