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