v0.4.1 d107bf9

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

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 476

↑↓ moveenter openesc close