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