v0.3.86 81f384a

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

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