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