v0.4.10 5ac0009

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

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