v0.3.95 beceda1

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

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