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