v0.3.99 352bb42

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

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 477

↑↓ moveenter openesc close