v0.1.1 7141aff

interactive.go RawHistoryFolder

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

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