v0.3.96 c5883a4

interactive.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close