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