v0.5.0 c35564a

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 the provider's fast tier where supported; 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 "/fast":
5548		enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
5549		i.applySettingToggle("fast_mode", enabled)
5550	case "/settings":
5551		i.openSettingsDialog()
5552	case "/sessions":
5553		i.mu.Lock()
5554		i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5555		i.mu.Unlock()
5556	case "/fork":
5557		i.doSessionFork()
5558	case "/jump":
5559		i.openJumpDialog(parts[1:])
5560	case "/btw":
5561		i.openBtwDialog(parts[1:])
5562	case "/skills":
5563		i.openSkillsDialog()
5564	case "/compact":
5565		i.runCompact(ctx, false)
5566	case "/study":
5567		// Canned prompt that tells the agent to read every file
5568		// in some target so its later turns have the whole thing
5569		// in context. With no argument, the target is the current
5570		// directory. With an argument, the target is whatever the
5571		// user passed — typed by hand, drag-dropped, or selected
5572		// via the @ file picker (which is why we accept both files
5573		// and directories; the @-picker chips for both have already
5574		// been expanded to absolute paths by expandFileChips above).
5575		// Dispatched through the normal queue-or-start path so it
5576		// behaves identically to typing the prompt by hand.
5577		studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
5578		i.mu.Lock()
5579		busy := i.busy
5580		compacting := i.compacting
5581		ag := i.agent
5582		i.mu.Unlock()
5583		if busy {
5584			if ag != nil && !compacting {
5585				ag.QueueMessage(studyPrompt)
5586			} else {
5587				i.mu.Lock()
5588				i.queued = append(i.queued, studyPrompt)
5589				i.mu.Unlock()
5590			}
5591			i.invalidate()
5592			break
5593		}
5594		i.startTurn(ctx, studyPrompt)
5595	case "/cd":
5596		// Hidden command: switch the running session's cwd. Not in
5597		// slash_suggest, not in /help. Used by the workspaces
5598		// extension's panel-key Enter handler so picking a row
5599		// jumps zut into that directory without relaunching.
5600		//
5601		// Recovers the raw argument (path) from the original cmd
5602		// string rather than parts, so paths with spaces survive.
5603		// The host's ChangeCWD hook handles validation, session
5604		// close + reopen, agent rebuild, sandbox re-rooting, and
5605		// re-jail-if-jailed semantics.
5606		if i.cfg.ChangeCWD == nil {
5607			i.mu.Lock()
5608			i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
5609			i.mu.Unlock()
5610			break
5611		}
5612		path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
5613		if path == "" {
5614			i.mu.Lock()
5615			i.statusErr = "/cd: missing path"
5616			i.mu.Unlock()
5617			break
5618		}
5619		if err := i.cfg.ChangeCWD(path); err != nil {
5620			i.mu.Lock()
5621			i.statusErr = "/cd: " + err.Error()
5622			i.statusOK = ""
5623			i.mu.Unlock()
5624			break
5625		}
5626		// ChangeCWD has already updated i.cfg.CWD and swapped the
5627		// agent + session. Reset transient TUI state so the new
5628		// session opens clean.
5629		i.mu.Lock()
5630		i.toolCalls = map[string]*tui.ToolCallView{}
5631		i.toolOrder = nil
5632		i.toolGate = map[string]int{}
5633		i.helpBlock = nil
5634		i.parkedTurn = 0
5635		i.statusOK = "cwd " + i.cfg.CWD
5636		i.statusErr = ""
5637		i.mu.Unlock()
5638		i.fileSuggest.Reset()
5639		i.fileSuggest.SetCWD(i.cfg.CWD)
5640		i.invalidate()
5641	case "/jail":
5642		if i.cfg.Sandbox == nil {
5643			i.mu.Lock()
5644			i.statusErr = "sandbox not available in this build"
5645			i.mu.Unlock()
5646			break
5647		}
5648		i.cfg.Sandbox.Lock()
5649		i.mu.Lock()
5650		i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
5651		i.statusErr = ""
5652		i.mu.Unlock()
5653	case "/unjail":
5654		if i.cfg.Sandbox == nil {
5655			i.mu.Lock()
5656			i.statusErr = "sandbox not available in this build"
5657			i.mu.Unlock()
5658			break
5659		}
5660		i.cfg.Sandbox.Unlock()
5661		i.mu.Lock()
5662		i.statusOK = "unjailed"
5663		i.statusErr = ""
5664		i.mu.Unlock()
5665	case "/reload-ext":
5666		i.runReloadExt(ctx)
5667	case "/telegram", "/tg":
5668		if len(parts) >= 2 {
5669			i.doTelegram(parts[1])
5670			break
5671		}
5672		i.openTelegramDialog()
5673	case "/session":
5674		if len(parts) >= 2 {
5675			action := parts[1]
5676			arg := ""
5677			if len(parts) >= 3 {
5678				arg = strings.Join(parts[2:], " ")
5679			}
5680			i.doSessionOp(action, arg)
5681			break
5682		}
5683		i.openSessionOpsDialog()
5684	case "/subagents":
5685		i.runSubagents(ctx, parts[1:])
5686	default:
5687		// Last-resort fallback: try the extension manager. Built-in
5688		// cases above always win; this branch only fires for slash
5689		// commands the extension manager registered. Same routing as
5690		// the editor's submit-handler dispatch path so the autocomplete
5691		// "enter on highlighted suggestion" flow also works.
5692		extName := strings.TrimPrefix(parts[0], "/")
5693		if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
5694			rest := ""
5695			if len(parts) > 1 {
5696				rest = strings.Join(parts[1:], " ")
5697			}
5698			go i.invokeExtensionCommand(ctx, extName, rest)
5699			return false
5700		}
5701		i.mu.Lock()
5702		i.statusErr = "unknown command: " + parts[0]
5703		i.mu.Unlock()
5704	}
5705	return false
5706}
5707
5708func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
5709	var loggedIn []string
5710	if i.cfg.LoggedInProviders != nil {
5711		loggedIn = i.cfg.LoggedInProviders()
5712	}
5713	i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
5714	i.mu.Lock()
5715	if refreshErr != nil {
5716		i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
5717		i.statusOK = ""
5718	} else if i.statusOK == "Refreshing models" {
5719		i.statusOK = ""
5720	}
5721	i.mu.Unlock()
5722}
5723
5724// openLogoutDialog shows the provider picker for `/logout` with no
5725// argument. Only providers the user is currently logged into are
5726// listed, plus an "all" entry when more than one is present. If
5727// nothing's logged in, writes a status line instead of opening an
5728// empty dialog.
5729func (i *Interactive) openLogoutDialog() {
5730	if i.cfg.AuthManager == nil {
5731		i.mu.Lock()
5732		i.statusErr = "no auth manager configured"
5733		i.mu.Unlock()
5734		i.invalidate()
5735		return
5736	}
5737	store := i.cfg.AuthManager.Store()
5738	if store == nil {
5739		i.mu.Lock()
5740		i.statusErr = "auth store is not available"
5741		i.mu.Unlock()
5742		i.invalidate()
5743		return
5744	}
5745	creds, err := store.Load()
5746	if err != nil {
5747		i.mu.Lock()
5748		i.statusErr = "read auth store: " + err.Error()
5749		i.mu.Unlock()
5750		i.invalidate()
5751		return
5752	}
5753
5754	var items []logoutItem
5755	for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
5756		if creds.Has(p) {
5757			method := creds.Method(p)
5758			if method == "oauth" {
5759				method = "subscription"
5760			}
5761			items = append(items, logoutItem{
5762				label:  providerLabel(p),
5763				target: p,
5764				method: method,
5765			})
5766		}
5767	}
5768	if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
5769		items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
5770	}
5771	if creds.OpenAI.OAuth != nil {
5772		items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
5773	}
5774	for p, c := range creds.AdditionalAPIKeyCreds {
5775		if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
5776			items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
5777		}
5778	}
5779	if len(items) == 0 {
5780		i.mu.Lock()
5781		i.statusOK = "no credentials stored; already logged out"
5782		i.statusErr = ""
5783		i.mu.Unlock()
5784		i.invalidate()
5785		return
5786	}
5787	if len(items) > 1 {
5788		items = append(items, logoutItem{label: "all", target: "all"})
5789	}
5790
5791	i.logoutDialog.Open(items)
5792	i.invalidate()
5793}
5794
5795// doLogout clears credentials for the given provider (or all providers)
5796// from auth.json. If the active agent was using those credentials, it
5797// is torn down so the user is forced through /login before their next
5798// prompt.
5799//
5800// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
5801func (i *Interactive) doLogout(target string) {
5802	if i.cfg.AuthManager == nil {
5803		i.mu.Lock()
5804		i.statusErr = "no auth manager configured"
5805		i.mu.Unlock()
5806		return
5807	}
5808	store := i.cfg.AuthManager.Store()
5809	if store == nil {
5810		i.mu.Lock()
5811		i.statusErr = "auth store is not available"
5812		i.mu.Unlock()
5813		return
5814	}
5815
5816	var providers []string
5817	switch target {
5818	case "", "all":
5819		providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
5820	case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
5821		providers = []string{target}
5822	default:
5823		known := false
5824		for _, p := range auth.APIKeyProviders() {
5825			if target == p {
5826				known = true
5827				break
5828			}
5829		}
5830		if !known {
5831			i.mu.Lock()
5832			i.statusErr = "unknown provider: " + target
5833			i.mu.Unlock()
5834			return
5835		}
5836		providers = []string{target}
5837	}
5838
5839	var errs []string
5840	clearedCurrent := false
5841	for _, p := range providers {
5842		var err error
5843		switch p {
5844		case "openai":
5845			err = store.ClearAPIKey("openai")
5846		case "openai-codex":
5847			err = store.ClearOAuth("openai")
5848		default:
5849			err = store.Clear(p)
5850		}
5851		if err != nil {
5852			errs = append(errs, p+": "+err.Error())
5853			continue
5854		}
5855		if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5856			if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
5857				errs = append(errs, p+": "+err.Error())
5858				continue
5859			}
5860		}
5861		if p == i.cfg.Provider {
5862			clearedCurrent = true
5863		}
5864	}
5865
5866	llamaConfigured := i.llamaConfigured
5867	if i.cfg.LlamaCPPConfig != nil {
5868		baseURL, _, err := i.cfg.LlamaCPPConfig()
5869		llamaConfigured = err == nil && baseURL != ""
5870	}
5871
5872	i.mu.Lock()
5873	defer i.mu.Unlock()
5874	i.llamaConfigured = llamaConfigured
5875	if len(errs) > 0 {
5876		i.statusErr = "logout errors: " + strings.Join(errs, "; ")
5877		return
5878	}
5879	i.statusErr = ""
5880	if clearedCurrent {
5881		// The running agent was using a credential we just wiped. Drop
5882		// it so prompts can't go out with the stale client, and hint at
5883		// /login. Close any LSP processes owned by its tools first.
5884		if i.agent != nil {
5885			_ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
5886		}
5887		i.agent = nil
5888		i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
5889	} else {
5890		i.statusOK = "logged out of " + strings.Join(providers, ", ")
5891	}
5892}
5893
5894func providerSetupInfo(provider string) (string, []string, bool) {
5895	const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
5896	switch provider {
5897	case "amazon-bedrock":
5898		return "Amazon Bedrock setup", []string{
5899			"Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
5900			"Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
5901			"",
5902			"For Bedrock API keys, set:",
5903			"  AWS_BEARER_TOKEN_BEDROCK=...",
5904			"  AWS_REGION=us-east-1",
5905			"",
5906			"Docs:",
5907			"  " + docsURL,
5908		}, true
5909	case "google-vertex":
5910		return "Google Vertex AI setup", []string{
5911			"Google Vertex AI usually uses Google Cloud credentials and project settings.",
5912			"Set a Google API key, application-default credentials, or a service account.",
5913			"",
5914			"Common environment:",
5915			"  GOOGLE_CLOUD_API_KEY=...",
5916			"  GOOGLE_CLOUD_PROJECT=...",
5917			"  GOOGLE_CLOUD_LOCATION=us-central1",
5918			"",
5919			"Docs:",
5920			"  " + docsURL,
5921		}, true
5922	case "cloudflare-workers-ai":
5923		return "Cloudflare Workers AI setup", []string{
5924			"Cloudflare Workers AI needs both an API token and an account ID.",
5925			"",
5926			"Set:",
5927			"  CLOUDFLARE_API_KEY=...",
5928			"  CLOUDFLARE_ACCOUNT_ID=...",
5929			"",
5930			"Docs:",
5931			"  " + docsURL,
5932		}, true
5933	case "cloudflare-ai-gateway":
5934		return "Cloudflare AI Gateway setup", []string{
5935			"Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
5936			"",
5937			"Set:",
5938			"  CLOUDFLARE_API_KEY=...",
5939			"  CLOUDFLARE_ACCOUNT_ID=...",
5940			"  CLOUDFLARE_GATEWAY_ID=...",
5941			"",
5942			"Docs:",
5943			"  " + docsURL,
5944		}, true
5945	case "azure-openai-responses":
5946		return "Azure OpenAI Responses setup", []string{
5947			"Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
5948			"",
5949			"Set:",
5950			"  AZURE_OPENAI_API_KEY=...",
5951			"  AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
5952			"  AZURE_OPENAI_API_VERSION=2024-02-01",
5953			"",
5954			"Docs:",
5955			"  " + docsURL,
5956		}, true
5957	default:
5958		return "", nil, false
5959	}
5960}
5961
5962func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
5963	if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
5964		i.dialog.ShowResult(false, "auth store is unavailable")
5965		return
5966	}
5967	if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
5968		i.dialog.ShowResult(false, err.Error())
5969		return
5970	}
5971	i.mu.Lock()
5972	i.llamaConfigured = true
5973	i.statusErr = ""
5974	i.statusOK = "configured llama.cpp router"
5975	i.mu.Unlock()
5976	i.dialog.ShowResult(true, "")
5977}
5978
5979func (i *Interactive) startAPIKeyFlow(provider string) {
5980	if title, lines, ok := providerSetupInfo(provider); ok {
5981		i.dialog.ShowInfo(title, lines)
5982		return
5983	}
5984	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5985		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
5986	}
5987	url, err := i.cfg.AuthManager.StartAPIKey(provider)
5988	if err != nil {
5989		i.dialog.ShowResult(false, err.Error())
5990		return
5991	}
5992	i.dialog.ShowWaiting(url)
5993}
5994
5995func (i *Interactive) startOAuthFlow(provider string) {
5996	if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5997		_ = i.cfg.SetKimiCLIFallbackDisabled(false)
5998	}
5999	// Device-code providers already support headless login and must only
6000	// start one polling flow.
6001	if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
6002		loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
6003		if err != nil {
6004			i.dialog.ShowResult(false, err.Error())
6005			return
6006		}
6007		i.dialog.ShowWaiting(loginURL)
6008		return
6009	}
6010	// Always run the manual/copy-code flow in parallel with the local
6011	// callback server so headless environments (docker, SSH) can paste
6012	// the authorization code directly without first pressing 'p'.
6013	_, err := i.cfg.AuthManager.StartOAuth(provider)
6014	if err != nil {
6015		i.dialog.ShowResult(false, err.Error())
6016		return
6017	}
6018	manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
6019	if mErr == nil {
6020		i.dialog.ShowWaiting(manualURL)
6021	} else {
6022		i.dialog.ShowResult(false, mErr.Error())
6023	}
6024}
6025
6026func (i *Interactive) startManualOAuthFlow(provider string) {
6027	if i.cfg.AuthManager == nil {
6028		return
6029	}
6030	i.cfg.AuthManager.CancelOAuth()
6031	url, err := i.cfg.AuthManager.StartManualOAuth(provider)
6032	if err != nil {
6033		i.dialog.ShowResult(false, err.Error())
6034		return
6035	}
6036	i.dialog.url = url
6037	i.invalidate()
6038}
6039
6040func (i *Interactive) submitManualOAuthCode(code string) {
6041	if i.cfg.AuthManager == nil {
6042		return
6043	}
6044	go func() {
6045		if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
6046			i.dialog.ShowResult(false, err.Error())
6047			i.invalidate()
6048		}
6049	}()
6050}
6051
6052// applyModelSelection switches the active model (and provider, if the
6053// new model belongs to a different one). It rebuilds the underlying
6054// client when needed so the provider wire-protocol matches.
6055// cancelAndWaitForIdle cancels the active turn (if any) and blocks
6056// briefly until the turn goroutine has updated i.busy = false. Used
6057// before destructive slash commands so transcript-mutating work
6058// (/clear, /compact, /logout, /login completion, cross-provider
6059// /model swap) doesn't race with the still-running stream.
6060//
6061// The wait is bounded; if the turn doesn't release within the timeout
6062// we proceed anyway. Worst case is a brief overlap that the agent's
6063// own mutex protects against.
6064func (i *Interactive) cancelAndWaitForIdle() {
6065	i.mu.Lock()
6066	busy := i.busy
6067	cancel := i.cancelTurn
6068	i.mu.Unlock()
6069	if !busy {
6070		return
6071	}
6072	if cancel != nil {
6073		cancel()
6074	}
6075	// ConfirmToolCall waits on its response channel rather than the turn
6076	// context, so cancellation must also resolve every pending prompt.
6077	i.confirmDialog.CancelAll("turn cancelled")
6078	deadline := time.Now().Add(2 * time.Second)
6079	for time.Now().Before(deadline) {
6080		i.mu.Lock()
6081		done := !i.busy
6082		i.mu.Unlock()
6083		if done {
6084			return
6085		}
6086		time.Sleep(10 * time.Millisecond)
6087	}
6088}
6089
6090// openBtwDialog opens the side-chat overlay with a frozen snapshot
6091// of the current main session. The optional argument is auto-
6092// submitted as the first question, so '/btw does X work?' fires the
6093// model call immediately instead of just opening an empty dialog.
6094func (i *Interactive) openBtwDialog(args []string) {
6095	i.agentMu.Lock()
6096	defer i.agentMu.Unlock()
6097	i.mu.Lock()
6098	ag := i.agent
6099	system := ""
6100	if ag != nil {
6101		system, _ = ag.PromptConfig()
6102	}
6103	theme := i.cfg.Theme
6104	model := i.cfg.Model
6105	cwd := i.cfg.CWD
6106	compact := i.compactModeEnabled()
6107	flatTools := i.cfg.FlatTools
6108	lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
6109	i.mu.Unlock()
6110	if ag == nil {
6111		i.mu.Lock()
6112		i.statusErr = "not logged in. type /login first."
6113		i.mu.Unlock()
6114		return
6115	}
6116	seed := strings.TrimSpace(strings.Join(args, " "))
6117	i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6118	i.invalidate()
6119}
6120
6121// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
6122// or queues it behind the active turn using the normal text-only queue.
6123func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6124	i.mu.Lock()
6125	if i.agent == nil {
6126		i.statusErr = "not logged in. type /login first."
6127		i.statusOK = ""
6128		i.mu.Unlock()
6129		i.invalidate()
6130		return
6131	}
6132	if i.busy {
6133		// Keep the mutex held while enqueueing so the turn-completion
6134		// goroutine cannot publish idle and inspect an empty queue between
6135		// this check and the append.
6136		if i.compacting {
6137			i.queued = append(i.queued, prompt)
6138		} else {
6139			i.agent.QueueMessage(prompt)
6140		}
6141		i.mu.Unlock()
6142		i.invalidate()
6143		return
6144	}
6145	i.mu.Unlock()
6146	i.startTurn(ctx, prompt)
6147}
6148
6149// openSkillsDialog opens the skill inspector. The picker reflects
6150// whatever SkillSnapshot returns at call time, so edits to a
6151// SKILL.md made during a session show up on the next /skills.
6152func (i *Interactive) openSkillsDialog() {
6153	var list []*skills.Skill
6154	if i.cfg.SkillSnapshot != nil {
6155		list = i.cfg.SkillSnapshot()
6156	}
6157	i.skillsDialog.Open(list)
6158	i.invalidate()
6159}
6160
6161// openJumpDialog builds a /jump picker from the current transcript.
6162// If the user typed "/jump foo" with a filter and it matches exactly
6163// one turn, jump there directly without showing the dialog.
6164func (i *Interactive) openJumpDialog(args []string) {
6165	if i.view == nil || len(i.view.Messages) == 0 {
6166		i.mu.Lock()
6167		i.statusErr = "nothing to jump to \u2014 the session is empty"
6168		i.mu.Unlock()
6169		return
6170	}
6171	filter := strings.TrimSpace(strings.Join(args, " "))
6172	i.jumpDialog.Open(i.view.Messages, filter)
6173	// Shortcut: with a filter argument that matches exactly one turn,
6174	// jump immediately and skip the picker.
6175	if filter != "" {
6176		if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6177			t := tgts[0]
6178			i.jumpDialog.Close()
6179			i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6180		}
6181	}
6182}
6183
6184// applyJumpSelection scrolls the chat viewport so the user message at
6185// msgIdx is visible at (or near) the top of the chat area. Uses the
6186// anchor slice returned by view.BuildWithAnchors so the mapping from
6187// message index to row is exact, regardless of variable-height tool
6188// blocks above the target.
6189func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6190	cols := i.lastCols()
6191	chat, anchors := i.view.BuildWithAnchors(cols)
6192	var row int
6193	found := false
6194	for _, a := range anchors {
6195		if a.MessageIdx == msgIdx {
6196			row = a.Row
6197			found = true
6198			break
6199		}
6200	}
6201	if !found {
6202		i.mu.Lock()
6203		i.statusErr = "could not resolve jump target"
6204		i.mu.Unlock()
6205		return
6206	}
6207
6208	chatLen := len(chat)
6209	page := i.chatPage()
6210	if page < 1 {
6211		page = 1
6212	}
6213	// scrollOffset is measured from the bottom of the chat slice, so
6214	// to place `row` at the top of the viewport we want:
6215	//     chatLen - scrollOffset - page == row
6216	// Solve for scrollOffset and clamp to [0, chatLen-page].
6217	offset := chatLen - (row + page)
6218	if offset < 0 {
6219		offset = 0
6220	}
6221	maxOffset := chatLen - page
6222	if maxOffset < 0 {
6223		maxOffset = 0
6224	}
6225	if offset > maxOffset {
6226		offset = maxOffset
6227	}
6228
6229	i.mu.Lock()
6230	i.scrollOffset = offset
6231	i.parkedTurn = turnNo
6232	i.parkedTotal = totalTurnsLocked(i.view.Messages)
6233	i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6234	i.statusErr = ""
6235	i.mu.Unlock()
6236}
6237
6238// totalTurnsLocked counts user messages in the transcript. Caller is
6239// assumed to hold i.mu (the name is a mild reminder; this function
6240// itself doesn't touch shared state beyond the slice it's handed).
6241func totalTurnsLocked(msgs []provider.Message) int {
6242	n := 0
6243	for _, m := range msgs {
6244		if m.Role == provider.RoleUser {
6245			n++
6246		}
6247	}
6248	return n
6249}
6250
6251// applySessionSelection loads the given session via the cli-provided
6252// callback and snaps the viewport to the bottom (the latest message)
6253// so the user lands at the live tail of the resumed conversation.
6254func (i *Interactive) applySessionSelection(path string) {
6255	if i.cfg.LoadSession == nil {
6256		i.mu.Lock()
6257		i.statusErr = "session loading is not wired in this build"
6258		i.mu.Unlock()
6259		return
6260	}
6261	i.mu.Lock()
6262	if i.sessionLoading {
6263		i.statusErr = "already resuming a session"
6264		i.mu.Unlock()
6265		i.invalidate()
6266		return
6267	}
6268	i.sessionLoading = true
6269	i.statusOK = "resuming session: " + path
6270	i.statusErr = ""
6271	i.mu.Unlock()
6272	i.invalidate()
6273	i.markSessionTitleSwitching()
6274
6275	go func() {
6276		err := i.cfg.LoadSession(path)
6277		if err != nil {
6278			i.restoreFailedSessionTitle()
6279			i.mu.Lock()
6280			i.sessionLoading = false
6281			i.statusErr = err.Error()
6282			i.statusOK = ""
6283			i.mu.Unlock()
6284			i.invalidate()
6285			return
6286		}
6287		i.restoreLoadedSessionTitle()
6288		i.mu.Lock()
6289		i.sessionLoading = false
6290		i.statusOK = "resumed session: " + path
6291		i.statusErr = ""
6292		i.parkedTurn = 0
6293		i.parkedTotal = 0
6294		i.scrollOffset = 0
6295		// Fresh transcript swapped in: drop the auto-follow baseline so
6296		// the next render's follow guard doesn't see the wholesale
6297		// length change as a delta and jump the viewport.
6298		i.prevChatLen = 0
6299		i.prevChatCols = 0
6300		i.extNotes = nil
6301		i.view.InvalidateRenderCache()
6302		if i.agent != nil {
6303			i.view.Messages = i.agent.Messages()
6304			i.cumUsage = i.agent.Cost()
6305			if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6306				i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6307			} else {
6308				i.lastCtxInput = 0
6309			}
6310			// Snap to the tail again — the swap brought in a fresh
6311			// transcript whose markdown / chroma cost we don't want
6312			// blocking the redraw.
6313			if len(i.view.Messages) > initialResumeTailLimit {
6314				i.view.TailLimit = initialResumeTailLimit
6315			} else {
6316				i.view.TailLimit = 0
6317			}
6318		}
6319		i.mu.Unlock()
6320		i.invalidate()
6321	}()
6322}
6323
6324func (i *Interactive) applyModelSelection(prov, model string) {
6325	i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6326}
6327
6328// applyRescueModelSelection is like applyModelSelection but routes
6329// through BuildAgentForRescue so launch-time --api-key / --base-url
6330// overrides are dropped before the new agent is built. Falls back to
6331// the regular builder when the host doesn't wire a rescue builder.
6332func (i *Interactive) applyRescueModelSelection(prov, model string) {
6333	builder := i.cfg.BuildAgentForRescue
6334	if builder == nil {
6335		builder = i.cfg.BuildAgentFor
6336	}
6337	i.swapModel(prov, model, builder, true)
6338}
6339
6340// swapModel applies a /model selection (or a rescue selection) using
6341// the supplied builder. rescue=true tags the success message so the
6342// user can see that launch-time overrides were ignored.
6343func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6344	if i.cfg.SessionTransition != nil {
6345		i.cfg.SessionTransition(func() {
6346			i.swapModelUnserialized(prov, model, builder, rescue)
6347		})
6348		return
6349	}
6350	i.swapModelUnserialized(prov, model, builder, rescue)
6351}
6352
6353func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6354	if model == "" {
6355		return
6356	}
6357	m, err := provider.FindModel(prov, model)
6358	if err != nil {
6359		i.mu.Lock()
6360		i.statusErr = err.Error()
6361		i.mu.Unlock()
6362		return
6363	}
6364	// Same provider AND not a rescue retry: just swap the model on
6365	// the existing agent. Mixed-API providers dispatch from model metadata,
6366	// so the client remains reusable. Rescue retries always rebuild so a stale
6367	// auth header / base URL can't carry over.
6368	if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6369		i.mu.Lock()
6370		i.cfg.Model = m.ID
6371		i.agent.Model = m.ID
6372		i.statusOK = "model: " + m.ID
6373		i.statusErr = ""
6374		i.mu.Unlock()
6375		if i.cfg.PersistModel != nil {
6376			i.cfg.PersistModel(i.cfg.Provider, m.ID)
6377		}
6378		return
6379	}
6380	if builder == nil {
6381		i.mu.Lock()
6382		i.statusErr = "cannot switch provider: no builder configured"
6383		i.mu.Unlock()
6384		return
6385	}
6386	// Snapshot the current transcript and cumulative usage BEFORE we
6387	// build the replacement agent so we can hand them off. Without
6388	// this the user perceives the entire session as wiped on a
6389	// cross-provider /model swap.
6390	var carryMsgs []provider.Message
6391	var carryCost provider.Usage
6392	if i.agent != nil {
6393		carryMsgs = i.agent.Messages()
6394		carryCost = i.agent.Cost()
6395	}
6396
6397	ag, p, md, err := builder(m.Provider, m.ID)
6398	if err != nil {
6399		i.mu.Lock()
6400		i.statusErr = err.Error()
6401		i.mu.Unlock()
6402		return
6403	}
6404
6405	// Replay the transcript and seed the cost on the freshly-built
6406	// agent. Messages travel cleanly between providers because they
6407	// use the same provider.Message shape; tool-call ids are local
6408	// to a turn so cross-provider continuation never confuses the
6409	// new model (it just sees the assistant's reply, no orphan
6410	// tool_use blocks because /model swaps are gated to idle state).
6411	if len(carryMsgs) > 0 {
6412		ag.SetMessages(carryMsgs)
6413	}
6414	ag.SeedCost(carryCost)
6415
6416	if i.agent != nil && i.agent != ag {
6417		_ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6418	}
6419	i.mu.Lock()
6420	i.agent = ag
6421	i.cfg.Provider = p
6422	i.cfg.Model = md
6423	if rescue {
6424		i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6425	} else {
6426		i.statusOK = "switched to " + p + " / " + md
6427	}
6428	i.statusErr = ""
6429	// Render cache keys are width+content based, so the new agent's
6430	// identical messages will reuse the existing entries. Nothing
6431	// to invalidate.
6432	i.mu.Unlock()
6433	// The new agent was built off the base tool registry, so any
6434	// dynamically-registered tools need to be reattached. The apply
6435	// helpers are no-ops when their feature is inactive, so the
6436	// cross-provider path still works on a vanilla setup.
6437	i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6438	i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6439	if i.cfg.PersistModel != nil {
6440		i.cfg.PersistModel(p, md)
6441	}
6442}
6443
6444func (i *Interactive) handleAuthEvent(ev auth.Event) {
6445	switch ev.Kind {
6446	case "started":
6447		i.dialog.ShowWaiting(ev.URL)
6448	case "browser_open":
6449		// no-op
6450	case "error":
6451		i.dialog.ShowResult(false, ev.Message)
6452	case "success":
6453		// Rebuild the agent with the fresh credential.
6454		ag, prov, model, err := i.cfg.BuildAgent()
6455		if err != nil {
6456			i.dialog.ShowResult(false, err.Error())
6457			return
6458		}
6459		commitLogin := func() {
6460			i.mu.Lock()
6461			oldAgent := i.agent
6462			i.agent = ag
6463			i.cfg.Provider = prov
6464			i.cfg.Model = model
6465			i.statusErr = ""
6466			i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6467			i.mu.Unlock()
6468			if oldAgent != nil {
6469				_ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6470			}
6471			i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6472			i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6473			// Authentication can change the provider used by the live
6474			// agent. Persist it on the active session just like /model.
6475			if i.cfg.PersistModel != nil {
6476				i.cfg.PersistModel(prov, model)
6477			}
6478		}
6479		if i.cfg.SessionTransition != nil {
6480			i.cfg.SessionTransition(commitLogin)
6481		} else {
6482			commitLogin()
6483		}
6484		i.dialog.ShowResult(true, "")
6485	}
6486}
6487
6488// clearPendingCompactTurnLocked drops work that only makes sense after the
6489// current compaction. The caller must hold i.mu.
6490func (i *Interactive) clearPendingCompactTurnLocked() {
6491	i.pendingCompactPrompt = ""
6492	i.pendingCompactImages = nil
6493	i.hasPendingCompactPrompt = false
6494	i.continueAfterCompact = false
6495	i.pendingPostCompactNote = ""
6496}
6497
6498const 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.`
6499
6500// startAutoCompactContinuation adds an internal user turn that tells the
6501// model to resume the task after threshold compaction. Provider requests
6502// cannot continue from an assistant tail on every supported API, so the
6503// continuation must be represented as a user message. It is hidden from
6504// the rendered transcript but remains in the persisted/model context.
6505func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6506	i.mu.Lock()
6507	ag := i.agent
6508	i.mu.Unlock()
6509	if ag == nil {
6510		i.mu.Lock()
6511		i.busy = false
6512		pendingIdleWork := i.takePendingIdleWorkLocked()
6513		i.mu.Unlock()
6514		runPendingIdleWork(pendingIdleWork)
6515		i.invalidate()
6516		return
6517	}
6518	if ag.QueuedMessageCount() == 0 {
6519		ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
6520			autoCompactContinueMetaKey: "true",
6521		})
6522	}
6523	i.startTurnRequest(parent, "", nil, true, false)
6524}
6525
6526// runCompact invokes core.Agent.Compact and reflects the progress in
6527// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
6528// cancel via the same cancelTurn channel used for normal turns.
6529//
6530// When auto is true the spinner message is pinned to "condensing
6531// history" and the status bar surfaces "(auto)" next to the context
6532// percentage so it's obvious the system triggered this, not the user.
6533func (i *Interactive) runCompact(parent context.Context, auto bool) {
6534	if i.agent == nil {
6535		i.mu.Lock()
6536		i.statusErr = "not logged in. type /login first."
6537		i.mu.Unlock()
6538		return
6539	}
6540	ctx, cancel := context.WithCancel(parent)
6541	i.mu.Lock()
6542	i.busy = true
6543	i.compacting = true
6544	i.spin.Start()
6545	i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6546	if auto {
6547		i.activity.kind = activityCondensingHistory
6548		i.autoCompacting = true
6549	} else {
6550		i.activity.kind = activityCompactingHistory
6551	}
6552	i.cancelTurn = cancel
6553	i.statusErr = ""
6554	i.statusOK = ""
6555	// Do NOT set streamOn: the summary text should not be visible
6556	// in the chat while compacting. The user just sees the spinner
6557	// and can keep typing / queue prompts.
6558	i.scrollOffset = 0
6559	i.helpBlock = nil
6560	i.mu.Unlock()
6561	i.invalidate()
6562
6563	go func() {
6564		// Sink discards deltas — we don't stream the summary to the UI.
6565		sink := func(delta string) {}
6566		msgsBefore := i.agent.Messages()
6567		autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
6568		// Keep the usual recent tail when possible, but never let it cover
6569		// the whole transcript. A short session can still be at 70–90% of
6570		// a model's window because one prompt or tool result is large; the
6571		// automatic path must summarize that session instead of failing
6572		// before it reaches the compaction provider.
6573		keepTail := 4
6574		if n := len(msgsBefore); n > 0 && keepTail >= n {
6575			keepTail = n - 1
6576		}
6577		summary, err := i.agent.Compact(ctx, keepTail, sink)
6578		_ = summary
6579		i.mu.Lock()
6580		// Keep busy/compacting asserted while cleanup and queue selection run.
6581		// Completion updates can arrive in this window; clearing busy before
6582		// inspecting the queues lets them race a new turn or be stranded.
6583		i.resetStreamingStateLocked()
6584		i.cancelTurn = nil
6585		i.autoCompacting = false
6586		pendingIdleWork := i.takePendingIdleWorkLocked()
6587
6588		// Drain pending work after the transcript is clean. Overflow recovery
6589		// continues the user message already in the transcript. Pre-turn
6590		// compaction preserves its complete text-and-images request. Regular
6591		// prompts typed during compaction remain in the host queue.
6592		var next string
6593		var nextImages []provider.ImageBlock
6594		var hasNext bool
6595		var continueExisting bool
6596		var continueAutomatically bool
6597
6598		switch {
6599		case err != nil && ctx.Err() != nil:
6600			i.statusErr = ""
6601			if auto {
6602				i.statusOK = "auto-condense cancelled"
6603			} else {
6604				i.statusOK = "compaction cancelled"
6605			}
6606			i.queued = nil // drop queue on cancel
6607			i.clearPendingCompactTurnLocked()
6608			if i.agent != nil {
6609				i.agent.DrainQueuedMessages()
6610			}
6611		case err != nil:
6612			i.statusErr = "compaction failed: " + err.Error()
6613			i.statusOK = ""
6614			i.queued = nil // drop queue on error
6615			i.clearPendingCompactTurnLocked()
6616			if i.agent != nil {
6617				i.agent.DrainQueuedMessages()
6618			}
6619		default:
6620			i.statusErr = ""
6621			// Read token count from the compaction message meta.
6622			tokens := ""
6623			msgs := i.agent.Messages()
6624			if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
6625				tokens = msgs[0].Meta["tokens_before"]
6626			}
6627			switch {
6628			case i.pendingPostCompactNote != "":
6629				i.statusOK = i.pendingPostCompactNote
6630			case tokens != "":
6631				i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
6632			default:
6633				i.statusOK = "compacted (ctrl+o to expand)"
6634			}
6635			i.pendingPostCompactNote = ""
6636			i.extNotes = stripAutoCompactNotes(i.extNotes)
6637			i.lastCtxInput = 0
6638			i.toolCalls = map[string]*tui.ToolCallView{}
6639			i.toolOrder = nil
6640			i.toolGate = map[string]int{}
6641			i.resetTranscriptRenderLocked()
6642			switch {
6643			case i.continueAfterCompact:
6644				continueExisting = true
6645				i.continueAfterCompact = false
6646			case i.hasPendingCompactPrompt:
6647				next = i.pendingCompactPrompt
6648				nextImages = i.pendingCompactImages
6649				i.pendingCompactPrompt = ""
6650				i.pendingCompactImages = nil
6651				i.hasPendingCompactPrompt = false
6652				hasNext = true
6653			case len(i.queued) > 0:
6654				next, i.queued = i.queued[0], i.queued[1:]
6655				hasNext = true
6656			case autoContinue:
6657				// A successful threshold compaction must hand control back to
6658				// an unfinished model turn. Completed text answers should settle
6659				// without an invented extra request; the user can still prompt
6660				// again normally.
6661				continueAutomatically = true
6662			}
6663		}
6664		// Keep the host busy until the hand-off decision is committed under
6665		// the mutex. A completion update arriving after the compaction result
6666		// but before this assignment is then queued for the selected follow-up
6667		// instead of starting a competing turn.
6668		i.busy = hasNext || continueExisting || continueAutomatically
6669		i.compacting = false
6670		i.autoCompacting = false
6671		i.mu.Unlock()
6672		runPendingIdleWork(pendingIdleWork)
6673		i.invalidate()
6674
6675		if hasNext || continueExisting || continueAutomatically {
6676			p := i.runCtx
6677			if p == nil {
6678				p = context.Background()
6679			}
6680			switch {
6681			case continueExisting:
6682				i.startTurnRequest(p, "", nil, true, true)
6683			case hasNext:
6684				i.startTurnWithImages(p, next, nextImages)
6685			case continueAutomatically:
6686				i.startAutoCompactContinuation(p)
6687			}
6688		}
6689	}()
6690}
6691
6692// shellEscapeCommand reports whether text is a "!command" shell
6693// escape and, if so, returns the command with the leading '!' (and
6694// surrounding whitespace) stripped. A bare "!" with no command is
6695// treated as not an escape so it falls through to the normal prompt
6696// path rather than running an empty shell.
6697func shellEscapeCommand(text string) (string, bool) {
6698	return ShellEscapeCommand(text)
6699}
6700
6701// ShellEscapeCommand reports whether text is a "!command" shell
6702// escape and, if so, returns the command with the leading '!' (and
6703// surrounding whitespace) stripped. A bare "!" with no command is
6704// treated as not an escape so it falls through to the normal prompt
6705// path rather than running an empty shell.
6706func ShellEscapeCommand(text string) (string, bool) {
6707	trimmed := strings.TrimLeft(text, " \t")
6708	if !strings.HasPrefix(trimmed, "!") {
6709		return "", false
6710	}
6711	cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
6712	if cmd == "" {
6713		return "", false
6714	}
6715	return cmd, true
6716}
6717
6718// startShellEscape runs a "!command" in the same shell the bash tool
6719// uses, in the session working directory, honoring the /jail sandbox.
6720// It shares the busy/cancel state with the agent: esc cancels it, and
6721// it refuses to start while a turn or another shell escape is already
6722// in flight. Its terminal-log output is appended to the transcript as
6723// user context without automatically starting a model turn.
6724func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6725	i.mu.Lock()
6726	if i.busy || i.shellRunning {
6727		i.statusErr = "busy — wait for the current turn to finish before running a shell command"
6728		i.statusOK = ""
6729		i.mu.Unlock()
6730		i.invalidate()
6731		return
6732	}
6733	if parent == nil {
6734		parent = i.runCtx
6735	}
6736	if parent == nil {
6737		parent = context.Background()
6738	}
6739	ctx, cancel := context.WithCancel(parent)
6740	i.busy = true
6741	i.shellRunning = true
6742	i.shellLive = "$ " + cmd + "\n\n"
6743	i.cancelTurn = cancel
6744	i.statusErr = ""
6745	i.statusOK = ""
6746	i.spin.Start()
6747	i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
6748	// Clear stale extension notes the same way a new turn would so the
6749	// screen doesn't accumulate transient state.
6750	i.extNotes = nil
6751	i.scrollOffset = 0
6752	i.parkedTurn = 0
6753	i.parkedTotal = 0
6754	i.helpBlock = nil
6755	sandbox := i.cfg.Sandbox
6756	cwd := i.cfg.CWD
6757	i.mu.Unlock()
6758	i.invalidate()
6759
6760	go func() {
6761		defer cancel()
6762		raw, _ := json.Marshal(map[string]any{"command": cmd})
6763		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
6764		progress := func(chunk string) {
6765			i.mu.Lock()
6766			i.shellLive += chunk
6767			i.mu.Unlock()
6768			i.invalidate()
6769		}
6770		res, err := bash.Execute(ctx, raw, progress)
6771
6772		var out string
6773		if err != nil {
6774			out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
6775		} else {
6776			for _, c := range res.Content {
6777				if tb, ok := c.(provider.TextBlock); ok {
6778					out += tb.Text
6779				}
6780			}
6781		}
6782		cancelled := ctx.Err() != nil
6783		failed := err != nil || res.IsError || cancelled
6784		if cancelled {
6785			out += "\n\n[cancelled]"
6786		}
6787
6788		if i.agent != nil {
6789			i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
6790		}
6791
6792		i.mu.Lock()
6793		i.shellRunning = false
6794		i.shellLive = ""
6795		i.busy = false
6796		i.cancelTurn = nil
6797		pendingIdleWork := i.takePendingIdleWorkLocked()
6798		awaitingPre := i.awaitingStartupPre
6799		if failed {
6800			if cancelled {
6801				i.statusErr = "shell command cancelled"
6802			} else {
6803				i.statusErr = "shell command failed"
6804			}
6805			i.statusOK = ""
6806		} else {
6807			i.statusOK = "shell command finished"
6808			i.statusErr = ""
6809		}
6810		i.mu.Unlock()
6811		runPendingIdleWork(pendingIdleWork)
6812		i.invalidate()
6813		if awaitingPre {
6814			i.completeStartupPre()
6815		}
6816	}()
6817}
6818
6819func (i *Interactive) startTurn(parent context.Context, prompt string) {
6820	i.startTurnWithImages(parent, prompt, nil)
6821}
6822
6823func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
6824	i.startTurnRequest(parent, prompt, images, false, false)
6825}
6826
6827// startTurnRequest starts a new prompt or continues an existing user turn.
6828// continueExisting selects Agent.Continue so callers can retry a transcript
6829// message without appending it twice. overflowRecoveryAttempted suppresses
6830// the pre-turn threshold guard and the one-shot overflow recovery path; a
6831// normal post-compaction continuation leaves it false so a still-too-large
6832// rebuilt context can recover once more.
6833func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
6834	if i.agent == nil {
6835		// Text startup pre cannot run without credentials; continue so
6836		// deferred InitialInput (pre-fill or auto-submit) still applies.
6837		i.mu.Lock()
6838		awaitingPre := i.awaitingStartupPre
6839		i.mu.Unlock()
6840		if awaitingPre {
6841			i.completeStartupPre()
6842		}
6843		return
6844	}
6845	// Pre-turn safety: if the most recent context measurement is
6846	// already past the auto-compact threshold, condense before
6847	// sending so the next outbound request stays under the limit.
6848	// The condense flow re-fires the user's queued prompt for us, so
6849	// we just hand it off and exit.
6850	i.mu.Lock()
6851	needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
6852	if needsPreCompact {
6853		// Reserve the compaction hand-off before releasing i.mu. A completion
6854		// update arriving in the small gap before runCompact acquires the
6855		// mutex must join the host queue rather than starting a competing turn.
6856		i.busy = true
6857		i.compacting = true
6858		i.autoCompacting = true
6859		if continueExisting {
6860			i.continueAfterCompact = true
6861		} else {
6862			i.pendingCompactPrompt = prompt
6863			i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6864			i.hasPendingCompactPrompt = true
6865		}
6866		i.statusErr = ""
6867		i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
6868		i.pendingPostCompactNote = "context auto-compacted; sending your last message"
6869		i.mu.Unlock()
6870		i.invalidate()
6871		i.runCompact(parent, true)
6872		return
6873	}
6874	i.mu.Unlock()
6875
6876	ctx, cancel := context.WithCancel(parent)
6877	i.mu.Lock()
6878	i.busy = true
6879	i.spin.Start()
6880	i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6881	i.cancelTurn = cancel
6882	i.statusErr = ""
6883	i.statusOK = ""
6884	i.streaming.Reset()
6885	i.streamOn = true
6886	i.pendingAlert = nil
6887	i.toolCalls = map[string]*tui.ToolCallView{}
6888	i.toolOrder = nil
6889	i.toolGate = map[string]int{}
6890	i.extNotes = nil   // ext notes are one-shot; a new prompt clears them
6891	i.scrollOffset = 0 // jump back to the bottom on new turn
6892	// Lift the resume tail cap once the user starts interacting. The
6893	// cap is purely a first-paint optimization (don't markdown the
6894	// whole history before showing anything). Keeping it active during
6895	// a turn makes the rendered chat a sliding window: appended
6896	// messages push older ones off the TOP of the buffer, which the
6897	// renderer must treat as a change above the viewport and repaint
6898	// fully, snapping the terminal's native scrollback to the bottom on
6899	// every streamed chunk. A fresh session has no cap (append-only),
6900	// which is why the jump only shows up in resumed sessions. Dropping
6901	// the cap here makes resumed turns append-only too.
6902	i.view.TailLimit = 0
6903	// Reset the auto-follow baseline so the very next render at
6904	// interactive.go:1053 doesn't see a synthetic shrink between
6905	// "last frame had the previous turn's tool overlay" and
6906	// "this frame had it cleared above". Without this, the guard
6907	// reads delta = -(rows in cleared overlay) and decrements
6908	// scrollOffset, which on terminals that mirror zut's pane
6909	// scroll into the host scrollbar visibly yanks the viewport.
6910	// See autofollow_shrink_test.go for the exact arithmetic.
6911	i.prevChatLen = 0
6912	i.prevChatCols = 0
6913	i.parkedTurn = 0 // starting a turn clears the /jump parked state
6914	i.parkedTotal = 0
6915	i.helpBlock = nil // hide the help block once the user asks something
6916	i.mu.Unlock()
6917	i.invalidate()
6918
6919	var (
6920		lastStop    provider.StopReason
6921		lastTurnErr error
6922	)
6923	sink := func(ev core.AgentEvent) {
6924		if e, ok := ev.(core.EvTurnEnd); ok {
6925			lastStop = e.Stop
6926			lastTurnErr = e.Err
6927		}
6928		i.handleEvent(ev)
6929		i.invalidate()
6930	}
6931
6932	go func() {
6933		var err error
6934		if continueExisting {
6935			err = i.agent.Continue(ctx, sink)
6936		} else {
6937			err = i.agent.Prompt(ctx, prompt, images, sink)
6938		}
6939		i.mu.Lock()
6940		// Keep busy asserted through final cleanup and queue selection. A
6941		// watcher may enqueue a completion summary concurrently with the
6942		// provider's final event; publishing idle before inspecting queues can
6943		// strand that summary in the core agent queue or start an overlapping
6944		// turn.
6945		// Don't touch streamPending / streamFlushPending here — the
6946		// pacer may still be draining the final deltas and needs to
6947		// paint them even though Prompt has returned. It will reset
6948		// streamOn on its own once the buffer empties.
6949		if len(i.streamPending) == 0 {
6950			i.streamOn = false
6951		}
6952		i.cancelTurn = nil
6953		pendingIdleWork := i.takePendingIdleWorkLocked()
6954		if err != nil && ctx.Err() == nil {
6955			i.statusErr = err.Error()
6956		}
6957		// Decide whether to offer a model rescue picker for recoverable
6958		// provider failures (auth/rate/temporary). The picker opens after
6959		// the mutex is released so it can take its own locks freely.
6960		var (
6961			offer       bool
6962			rescueWhy   string
6963			rescueImgs  []provider.ImageBlock
6964			rescueModel string
6965			rescueProv  string
6966			rescueFprov string
6967		)
6968		if err != nil && ctx.Err() == nil {
6969			if ok, reason := classifyRescueError(err); ok {
6970				offer = true
6971				rescueWhy = reason
6972				rescueImgs = images
6973				rescueModel = i.cfg.Model
6974				rescueProv = i.cfg.Provider
6975				rescueFprov = extractFailedProvider(err)
6976				if rescueFprov == "" {
6977					rescueFprov = i.cfg.Provider
6978				}
6979				// Suppress the red banner — the rescue dialog already
6980				// surfaces the failure.
6981				i.statusErr = ""
6982			}
6983		}
6984		// Detect responses that reject the current context, either as an
6985		// HTTP 413 payload limit or a model context-window error. Token-
6986		// based auto-compact can miss both when metadata is stale or the
6987		// limit is measured in raw bytes. Compact once, then continue the
6988		// user message already present in the transcript.
6989		contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
6990		recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6991		if recoverContextOverflow {
6992			i.statusErr = ""
6993			i.continueAfterCompact = true
6994			i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
6995			i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
6996		}
6997		// Persist the assistant's reply (and every tool row before
6998		// it) to the session file while the turn memory is hot.
6999		// Without this, WriteNewTranscript only fires at zut exit,
7000		// meaning a crash or ungraceful kill drops the whole
7001		// conversation. FlushSession is idempotent (it advances the
7002		// baseline so subsequent flushes only write new rows).
7003		flush := i.cfg.FlushSession
7004		i.mu.Unlock()
7005		runPendingIdleWork(pendingIdleWork)
7006		if flush != nil {
7007			flush()
7008		}
7009		i.mu.Lock()
7010		awaitingPre := i.awaitingStartupPre
7011		// Pop the next queued message, if any, and relaunch.
7012		var next string
7013		var hasNext bool
7014		if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
7015			next, i.queued = i.queued[0], i.queued[1:]
7016			hasNext = true
7017		}
7018		// If the turn was cancelled or errored, drop the queue so the
7019		// user isn't bombarded with stale messages after an interrupt.
7020		if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
7021			i.queued = nil
7022			if i.agent != nil {
7023				i.agent.DrainQueuedMessages()
7024			}
7025		}
7026		// Decide whether the next thing to do is an auto-compaction.
7027		// Only fires when the turn completed cleanly AND no host-side
7028		// or agent-side queued messages are waiting (otherwise a queued
7029		// message would race the condense).
7030		agentQueued := 0
7031		if i.agent != nil {
7032			agentQueued = i.agent.QueuedMessageCount()
7033		}
7034		continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7035		shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7036		// The agent run can finish before the paced final text reaches the
7037		// transcript. A compaction replaces that transcript, so it must never
7038		// race the still-live stream frame; otherwise stale deltas can repaint
7039		// after the replacement and corrupt the scrollback renderer's model.
7040		if recoverContextOverflow || shouldAutoCompact {
7041			i.resetStreamingStateLocked()
7042		}
7043		alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7044		i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
7045		i.mu.Unlock()
7046		if alertReason != "" {
7047			i.scheduleMainAlert(alertReason)
7048		}
7049		i.invalidate()
7050		parent := i.runCtx
7051		if parent == nil {
7052			parent = context.Background()
7053		}
7054		if awaitingPre {
7055			i.completeStartupPre()
7056			return
7057		}
7058		switch {
7059		case hasNext:
7060			i.startTurn(parent, next)
7061		case continueQueued:
7062			i.startTurnRequest(parent, "", nil, true, false)
7063		case offer:
7064			i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7065		case recoverContextOverflow:
7066			i.runCompact(parent, true)
7067		case shouldAutoCompact:
7068			i.runCompact(parent, true)
7069		}
7070	}()
7071}
7072
7073func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
7074	if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
7075		return ""
7076	}
7077	if rescue {
7078		return "rescue_required"
7079	}
7080	if stop == provider.StopAborted {
7081		return ""
7082	}
7083	if err != nil || turnErr != nil || stop == provider.StopError {
7084		return "agent_error"
7085	}
7086	if stop == provider.StopLength {
7087		return "response_truncated"
7088	}
7089	return "agent_done"
7090}
7091
7092// openRescueDialog surfaces the rescue model picker after a recoverable
7093// provider failure. The pending prompt + images are stashed on the
7094// Interactive so a later applyRescueSelection can re-run the same turn
7095// against the freshly-picked model. activeProvider/failedProvider are
7096// usually the same, but some clients embed different prefixes in their
7097// errors than the configured provider id, so we accept both.
7098func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
7099	if i.rescueDialog == nil {
7100		return
7101	}
7102	loggedIn := []string{}
7103	if i.cfg.LoggedInProviders != nil {
7104		loggedIn = i.cfg.LoggedInProviders()
7105	}
7106	fprov := failedProvider
7107	if fprov == "" {
7108		fprov = activeProvider
7109	}
7110	i.mu.Lock()
7111	i.pendingRescuePrompt = prompt
7112	i.pendingRescueImages = images
7113	i.mu.Unlock()
7114	i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
7115	i.invalidate()
7116}
7117
7118// applyRescueSelection switches model (cross-provider if needed) and
7119// re-runs the same prompt+images that just failed. Mirrors
7120// applyModelSelection's transcript-carry logic so the user keeps full
7121// session continuity across the swap.
7122func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
7123	if model == "" {
7124		return
7125	}
7126	i.applyRescueModelSelection(prov, model)
7127	i.mu.Lock()
7128	images := i.pendingRescueImages
7129	if prompt == "" {
7130		prompt = i.pendingRescuePrompt
7131	}
7132	i.pendingRescuePrompt = ""
7133	i.pendingRescueImages = nil
7134	i.mu.Unlock()
7135	parent := i.runCtx
7136	if parent == nil {
7137		parent = context.Background()
7138	}
7139	i.startTurnWithImages(parent, prompt, images)
7140}
7141
7142func stripAutoCompactNotes(notes []string) []string {
7143	if len(notes) == 0 {
7144		return notes
7145	}
7146	out := notes[:0]
7147	for _, n := range notes {
7148		if strings.Contains(n, "condensing history") {
7149			continue
7150		}
7151		out = append(out, n)
7152	}
7153	return out
7154}
7155
7156// autoCompactNoteLine returns a styled chat-area note for the
7157// inline auto-compact heads-up. Lives in extNotes so it survives
7158// the busy-spinner overwrite of the status row.
7159func autoCompactNoteLine(th tui.Theme, msg string) string {
7160	return "  " + th.FGColor(th.Warning, "⚠ "+msg)
7161}
7162
7163const defaultAutoCompactThreshold = 85
7164
7165func normalizeAutoCompactThreshold(threshold *int) int {
7166	if threshold == nil {
7167		return defaultAutoCompactThreshold
7168	}
7169	switch *threshold {
7170	case 0, 70, 80, 85, 90:
7171		return *threshold
7172	default:
7173		return defaultAutoCompactThreshold
7174	}
7175}
7176
7177func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7178	if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
7179		return false
7180	}
7181	return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
7182}
7183
7184// shouldAutoContinueAfterCompaction reports whether the transcript tail looks
7185// unfinished. A complete assistant text answer should not cause an extra
7186// model turn merely because its context was compacted; tool calls, reasoning-
7187// only replies, and non-assistant tails still need the model to resume.
7188func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7189	if len(msgs) == 0 {
7190		return false
7191	}
7192	last := msgs[len(msgs)-1]
7193	if last.Role != provider.RoleAssistant {
7194		return true
7195	}
7196	hasText := false
7197	for _, content := range last.Content {
7198		switch block := content.(type) {
7199		case provider.ToolCallBlock:
7200			return true
7201		case provider.TextBlock:
7202			if strings.TrimSpace(block.Text) != "" {
7203				hasText = true
7204			}
7205		case provider.ReasoningBlock:
7206			// Reasoning without a visible answer is not terminal.
7207		default:
7208			return true
7209		}
7210	}
7211	return !hasText
7212}
7213
7214// shouldAutoCompactLocked reports whether the last turn pushed context
7215// usage past the auto-compact threshold. Must be called with i.mu
7216// held; it reads lastCtxInput and the current model's context window.
7217func (i *Interactive) shouldAutoCompactLocked() bool {
7218	if i.agent == nil {
7219		return false
7220	}
7221	if i.autoCompacting {
7222		return false
7223	}
7224	m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
7225	if err != nil || m.ContextWindow <= 0 {
7226		return false
7227	}
7228	threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
7229	return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
7230}
7231
7232func (i *Interactive) handleEvent(ev core.AgentEvent) {
7233	i.mu.Lock()
7234	defer i.mu.Unlock()
7235	i.activity.apply(ev)
7236	switch e := ev.(type) {
7237	case core.EvAssistantStart:
7238		// Fires at the top of every oneTurn, including follow-up
7239		// turns after tool use. Without this, the streaming buffer
7240		// is still marked off from the previous assistant message
7241		// and the final summary text pops in all at once instead
7242		// of typewriter-streaming delta by delta.
7243		i.streaming.Reset()
7244		i.streamPending = i.streamPending[:0]
7245		i.streamFlushPending = false
7246		i.streamOn = true
7247		// Clear the live tool-call overlay. Any tools from the
7248		// previous round are now fully folded into the transcript
7249		// (assistant tool_use block + tool role message with the
7250		// result), so keeping them in the overlay would duplicate
7251		// them in the view — once inside the finalised transcript
7252		// and once below the streaming block, with the streaming
7253		// summary sandwiched in between. The next EvToolUseStart
7254		// will populate fresh entries for this turn's tools.
7255		i.toolCalls = map[string]*tui.ToolCallView{}
7256		i.toolOrder = nil
7257		i.toolGate = map[string]int{}
7258	case core.EvTextDelta:
7259		// Buffer into streamPending; the paintPace ticker drains
7260		// it into i.streaming a few runes at a time for a smooth
7261		// typewriter effect independent of upstream chunk size.
7262		i.streamPending = append(i.streamPending, []rune(e.Delta)...)
7263		i.streamOn = true
7264	case core.EvAssistantMessage:
7265		// OnAssistant + telegram mirroring always fire on message
7266		// arrival — they read the FINAL message content, which is
7267		// complete regardless of what's still in the pacer.
7268		i.assistantMessageSideEffects(e.Message)
7269		// If the pacer still has characters to drain, keep streamOn
7270		// true and mark flush pending; the paintPace ticker will
7271		// drain the remainder and reset streaming state when done.
7272		// Otherwise (rare: full-replay sessions, abort paths) clear
7273		// synchronously so a later render doesn't show stale text.
7274		if len(i.streamPending) > 0 {
7275			i.streamFlushPending = true
7276			return
7277		}
7278		i.resetStreamingStateLocked()
7279	case core.EvToolUseStart:
7280		// Live streaming: pre-create the view so the user sees the
7281		// tool call being composed in real time. Any subsequent
7282		// EvToolCall for the same ID updates the same struct (the
7283		// final parsed args + name are already known here).
7284		if _, exists := i.toolCalls[e.ID]; !exists {
7285			i.toolCalls[e.ID] = &tui.ToolCallView{
7286				ID:        e.ID,
7287				Name:      e.Name,
7288				Streaming: true,
7289			}
7290			i.toolOrder = append(i.toolOrder, e.ID)
7291			i.gateToolLocked(e.ID)
7292		}
7293	case core.EvToolUseArgs:
7294		if tc, ok := i.toolCalls[e.ID]; ok {
7295			tc.RawJSONBuf += e.Delta
7296			// Refresh the live path as soon as it parses; used in
7297			// the header (write /Users/example/Desktop/demo.ts)
7298			// while the content is still streaming.
7299			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
7300				tc.LivePath = p
7301			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
7302				tc.LivePath = p
7303			}
7304		}
7305	case core.EvToolUseEnd:
7306		if tc, ok := i.toolCalls[e.ID]; ok {
7307			tc.Streaming = false
7308		}
7309	case core.EvToolCall:
7310		// If we already pre-created the view during streaming, just
7311		// refresh the final Args summary. Otherwise create a new one
7312		// (non-streaming providers or legacy paths).
7313		if tc, ok := i.toolCalls[e.ID]; ok {
7314			tc.Args = tui.ShortArgs(e.Name, e.Args)
7315			if tc.RawJSONBuf == "" {
7316				tc.RawJSONBuf = string(e.Args)
7317			}
7318			tc.Streaming = false
7319		} else {
7320			i.toolCalls[e.ID] = &tui.ToolCallView{
7321				ID:         e.ID,
7322				Name:       e.Name,
7323				Args:       tui.ShortArgs(e.Name, e.Args),
7324				RawJSONBuf: string(e.Args),
7325			}
7326			i.toolOrder = append(i.toolOrder, e.ID)
7327			i.gateToolLocked(e.ID)
7328		}
7329	case core.EvToolResult:
7330		if tc, ok := i.toolCalls[e.ID]; ok {
7331			tc.Done = true
7332			tc.Error = e.Result.IsError
7333			tc.Preview = ""
7334			var text strings.Builder
7335			for _, c := range e.Result.Content {
7336				if tb, ok := c.(provider.TextBlock); ok {
7337					if text.Len() > 0 {
7338						text.WriteString("\n")
7339					}
7340					text.WriteString(tb.Text)
7341				}
7342			}
7343			tc.Result = text.String()
7344		}
7345		if i.cfg.OnToolResult != nil {
7346			i.cfg.OnToolResult(e.ID, e.Result)
7347		}
7348	case core.EvUsage:
7349		i.cumUsage = e.Cumulative
7350		if e.Usage.InputTokens > 0 {
7351			i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
7352		}
7353	case core.EvTurnEnd:
7354		if e.Stop == provider.StopAborted {
7355			i.resetStreamingStateLocked()
7356			i.statusErr = ""
7357			i.statusOK = "cancelled"
7358			return
7359		}
7360		if e.Stop == provider.StopLength {
7361			// The model hit its output-token cap mid-response, so the
7362			// reply (often a long write/edit) is truncated. Surface it
7363			// explicitly, otherwise the turn just ends and reads like
7364			// the UI gave up. The agent already requests the model's
7365			// full MaxOutput budget, so this means the response genuinely
7366			// exceeded that ceiling; ask the user to continue.
7367			i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
7368			i.statusOK = ""
7369			return
7370		}
7371		// Don't surface mid-loop stream errors as a red banner here.
7372		// EvTurnEnd fires after every step in a multi-step tool loop,
7373		// so a transient 503 / network blip would briefly paint a red
7374		// banner over the still-streaming chat before the agent loop
7375		// either retries or exits. The final error (if any) is set by
7376		// startTurnWithImages once Prompt() returns, and recoverable
7377		// failures are routed to the rescue picker instead — which
7378		// keeps the chat clean while the agent is working.
7379		_ = e.Err
7380	}
7381}
7382
7383// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7384// ag is still the live interactive agent. It serializes the identity check
7385// with agent replacement and swaps both values atomically in core.Agent.
7386func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7387	if ag == nil {
7388		return nil, false
7389	}
7390	// Match the lock order used by dynamic tool mutations so a refresh
7391	// cannot race a snapshot-and-replace operation that would reintroduce
7392	// the old registry after the atomic prompt swap.
7393	i.agentMu.Lock()
7394	defer i.agentMu.Unlock()
7395	i.mu.Lock()
7396	defer i.mu.Unlock()
7397	if i.agent != ag {
7398		return nil, false
7399	}
7400	return ag.SetPromptConfig(system, tools), true
7401}
7402
7403// DeferUntilIdle runs work immediately when the interactive agent is idle,
7404// or after the current turn/compaction/shell operation releases the busy
7405// state. Callbacks run outside i.mu.
7406func (i *Interactive) DeferUntilIdle(fn func()) {
7407	if fn == nil {
7408		return
7409	}
7410	i.mu.Lock()
7411	if i.busy {
7412		i.pendingIdleWork = append(i.pendingIdleWork, fn)
7413		i.mu.Unlock()
7414		return
7415	}
7416	i.mu.Unlock()
7417	fn()
7418}
7419
7420func (i *Interactive) takePendingIdleWorkLocked() []func() {
7421	work := i.pendingIdleWork
7422	i.pendingIdleWork = nil
7423	return work
7424}
7425
7426func runPendingIdleWork(work []func()) {
7427	for _, fn := range work {
7428		if fn != nil {
7429			fn()
7430		}
7431	}
7432}
7433
7434// Agent returns the current agent, if any. Used by cli.go to flush the
7435// final transcript to the session file.
7436func (i *Interactive) Agent() *core.Agent {
7437	i.mu.Lock()
7438	defer i.mu.Unlock()
7439	return i.agent
7440}
7441
7442// silence unused import in some build configs
7443var _ = fmt.Sprintf
7444
7445// runReloadExt triggers a live reload of every extension (discovered
7446// + explicit). Runs on a goroutine so the TUI stays responsive; the
7447// Manager.Reload takes a couple of hundred ms to shut down subprocs
7448// and respawn them. Shows a temporary status line throughout.
7449func (i *Interactive) runReloadExt(ctx context.Context) {
7450	if i.cfg.Extensions == nil {
7451		i.mu.Lock()
7452		i.statusErr = "no extension manager in this build"
7453		i.mu.Unlock()
7454		i.invalidate()
7455		return
7456	}
7457	i.mu.Lock()
7458	i.statusOK = "Reloading extensions..."
7459	i.statusErr = ""
7460	i.mu.Unlock()
7461	i.invalidate()
7462
7463	go func() {
7464		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
7465		msg, failed := formatReloadStatus(stats)
7466		seq := i.setReloadStatus(msg, failed)
7467		i.invalidate()
7468		go i.dismissReloadStatus(ctx, seq, msg, failed)
7469	}()
7470}
7471
7472// Confirm implements core.Confirmer. The agent goroutine calls
7473// this synchronously before every tool invocation when --no-yolo is
7474// active. We push the request onto the confirmDialog queue, trigger
7475// a redraw, and block the caller until the user answers.
7476//
7477// If the session is cancelled or the TUI exits mid-prompt, any
7478// pending request is refused via CancelAll so the agent doesn't
7479// deadlock.
7480func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
7481	return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
7482}
7483
7484// ConfirmToolCall attaches a side-effect-free preview to the matching live
7485// tool panel, then blocks until the user approves or refuses the call.
7486func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
7487	resp := make(chan core.ConfirmDecision, 1)
7488	if isBtwOrigin(call.Origin) {
7489		req := &confirmRequest{
7490			toolName:      call.Name,
7491			preview:       call.Summary,
7492			resp:          resp,
7493			returnToChild: true,
7494		}
7495		if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7496			i.confirmDialog.Enqueue(req)
7497		}) {
7498			return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7499		}
7500		i.invalidate()
7501		return <-resp
7502	}
7503	if call.ID != "" {
7504		i.mu.Lock()
7505		if tc, ok := i.toolCalls[call.ID]; ok {
7506			tc.Args = call.Summary
7507			tc.Preview = call.Content
7508			i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
7509		}
7510		i.mu.Unlock()
7511	}
7512	i.confirmDialog.Enqueue(&confirmRequest{
7513		toolName: call.Name,
7514		preview:  call.Summary,
7515		resp:     resp,
7516	})
7517	i.invalidate()
7518	return <-resp
7519}
7520
7521// openTelegramDialog shows the picker for `/telegram` with no arg.
7522// Items depend on current state: disconnect + status when running,
7523// connect + status when stopped.
7524func (i *Interactive) openTelegramDialog() {
7525	items := i.telegramMenuItems()
7526	if len(items) == 0 {
7527		i.mu.Lock()
7528		i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
7529		i.mu.Unlock()
7530		i.invalidate()
7531		return
7532	}
7533	i.telegramDialog.Open(items)
7534	i.invalidate()
7535}
7536
7537// telegramMenuItems builds the dialog entries for the current
7538// bridge state. Returns empty when no bot.json exists so the
7539// caller can show a helpful status line instead of an empty menu.
7540func (i *Interactive) telegramMenuItems() []telegramItem {
7541	cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
7542	if err != nil || cfg.BotToken == "" {
7543		return nil
7544	}
7545	var items []telegramItem
7546	if i.telegramBridge != nil && i.telegramBridge.Active() {
7547		items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
7548		st := i.telegramBridge.State()
7549		hint := "active"
7550		if st.Username != "" {
7551			hint += " as @" + st.Username
7552		}
7553		items = append(items, telegramItem{label: "status", action: "status", hint: hint})
7554	} else {
7555		label := "connect"
7556		hint := "start mirroring dms into this session"
7557		if cfg.AllowedUserID == 0 {
7558			hint = "awaiting pairing (send /start to the bot once connected)"
7559		}
7560		items = append(items, telegramItem{label: label, action: "connect", hint: hint})
7561		items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
7562	}
7563	return items
7564}
7565
7566// doTelegram dispatches one of the three explicit actions. Called
7567// from /telegram <action> or after the picker selects a row.
7568func (i *Interactive) doTelegram(action string) {
7569	switch action {
7570	case "connect":
7571		i.telegramConnect()
7572	case "disconnect":
7573		i.telegramDisconnect()
7574	case "status":
7575		i.telegramStatus()
7576	default:
7577		i.mu.Lock()
7578		i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
7579		i.mu.Unlock()
7580		i.invalidate()
7581	}
7582}
7583
7584// telegramConnect starts the bridge. Refuses if it's already
7585// running or if the on-disk bot.json is missing a token.
7586func (i *Interactive) telegramConnect() {
7587	if i.telegramBridge != nil && i.telegramBridge.Active() {
7588		i.mu.Lock()
7589		i.statusOK = "telegram already connected"
7590		i.statusErr = ""
7591		i.mu.Unlock()
7592		i.invalidate()
7593		return
7594	}
7595	cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
7596	if err != nil {
7597		i.mu.Lock()
7598		i.statusErr = "telegram: " + err.Error()
7599		i.mu.Unlock()
7600		i.invalidate()
7601		return
7602	}
7603	if cfg.BotToken == "" {
7604		i.mu.Lock()
7605		i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
7606		i.mu.Unlock()
7607		i.invalidate()
7608		return
7609	}
7610	// Refuse to start when a background daemon is already polling
7611	// the same bot. Two concurrent long-poll consumers race each
7612	// update and one always loses, so DMs get half-delivered. The
7613	// user can `zut telegram-bot stop` first, then /telegram connect.
7614	if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
7615		i.mu.Lock()
7616		i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
7617		i.mu.Unlock()
7618		i.invalidate()
7619		return
7620	}
7621	i.telegramBridge = &telegram.Bridge{
7622		Client: telegram.NewClient(cfg.BotToken),
7623		Config: cfg,
7624		Save: func(next telegram.Config) error {
7625			return telegram.SaveConfig(i.cfg.ZutHome, next)
7626		},
7627		Host: &telegramHost{iv: i},
7628	}
7629	if err := i.telegramBridge.Start(i.runCtx); err != nil {
7630		i.telegramBridge = nil
7631		i.mu.Lock()
7632		i.statusErr = "telegram connect failed: " + err.Error()
7633		i.mu.Unlock()
7634		i.invalidate()
7635		return
7636	}
7637	i.applyTelegramTools(true)
7638	state := i.telegramBridge.State()
7639	label := "telegram connected"
7640	if state.Username != "" {
7641		label += " as @" + state.Username
7642	}
7643	if state.PairedID == 0 {
7644		label += " — send /start to the bot from your phone to claim it"
7645	}
7646	i.mu.Lock()
7647	i.statusOK = label
7648	i.statusErr = ""
7649	i.mu.Unlock()
7650	i.invalidate()
7651}
7652
7653// telegramDisconnect stops the bridge. No-op when already stopped.
7654func (i *Interactive) telegramDisconnect() {
7655	if i.telegramBridge == nil || !i.telegramBridge.Active() {
7656		i.mu.Lock()
7657		i.statusOK = "telegram already disconnected"
7658		i.statusErr = ""
7659		i.mu.Unlock()
7660		i.invalidate()
7661		return
7662	}
7663	i.telegramBridge.Stop()
7664	i.applyTelegramTools(false)
7665	i.mu.Lock()
7666	i.statusOK = "telegram disconnected"
7667	i.statusErr = ""
7668	i.mu.Unlock()
7669	i.invalidate()
7670}
7671
7672// telegramSenderAdapter wraps the bridge so the tools package can
7673// drive it without importing telegram directly. The Active() check
7674// is forwarded to the bridge so the tool can fail clearly with a
7675// model-readable error when the user disconnected mid-turn.
7676type telegramSenderAdapter struct {
7677	bridge *telegram.Bridge
7678}
7679
7680func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
7681	if a.bridge == nil {
7682		return fmt.Errorf("telegram bridge is not connected")
7683	}
7684	return a.bridge.SendImage(ctx, path, caption)
7685}
7686
7687func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
7688	if a.bridge == nil {
7689		return fmt.Errorf("telegram bridge is not connected")
7690	}
7691	return a.bridge.SendDocument(ctx, path, caption)
7692}
7693
7694func (a telegramSenderAdapter) Active() bool {
7695	return a.bridge != nil && a.bridge.Active()
7696}
7697
7698// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
7699// delegated turn's outcome separately from the long-lived daemon status.
7700type subagentWatchEntry struct {
7701	agent   *subagents.Agent
7702	task    string
7703	done    bool
7704	outcome string
7705	err     string
7706}
7707
7708// TrackSubagentWorker is the exported entry point used by the cli to
7709// hand a freshly-spawned auto-subagents agent off to the watcher.
7710func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
7711	i.trackSubagentWorker(a, task)
7712}
7713
7714// trackSubagentWorker records a freshly-spawned auto-subagents agent and
7715// subscribes to both ways its delegated task can finish. Successful
7716// long-lived agents report a prompt-level turn_end and keep listening;
7717// startup failures and unexpected daemon exits only unblock Agent.Wait.
7718// Whichever signal arrives first completes the entry exactly once.
7719//
7720// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
7721// subagent is enabled, so this is a no-op when the feature is off.
7722func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
7723	if i == nil || a == nil {
7724		return
7725	}
7726	entry := &subagentWatchEntry{agent: a, task: task}
7727	i.subagentWatchMu.Lock()
7728	i.subagentWatch = append(i.subagentWatch, entry)
7729	i.subagentWatchMu.Unlock()
7730	i.invalidate()
7731
7732	a.SetOnTurnEnd(func(step int, errMsg string) {
7733		outcome := "completed"
7734		if errMsg != "" {
7735			outcome = "failed"
7736		}
7737		i.completeSupervisorWatchEntry(entry, outcome, errMsg)
7738	})
7739	go func() {
7740		a.Wait()
7741		snap := a.Snapshot()
7742		outcome := string(snap.Status)
7743		if snap.Status == subagents.StatusDone {
7744			outcome = "completed"
7745		}
7746		i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
7747	}()
7748}
7749
7750func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
7751	i.subagentWatchMu.Lock()
7752	if entry.done {
7753		i.subagentWatchMu.Unlock()
7754		return
7755	}
7756	entry.done = true
7757	entry.outcome = outcome
7758	entry.err = errMsg
7759	allDone := true
7760	for _, e := range i.subagentWatch {
7761		if !e.done {
7762			allDone = false
7763			break
7764		}
7765	}
7766	var batch []*subagentWatchEntry
7767	if allDone {
7768		batch = i.subagentWatch
7769		i.subagentWatch = nil
7770	}
7771	i.subagentWatchMu.Unlock()
7772	if len(batch) != 0 {
7773		i.flushSupervisorSummary(batch)
7774	}
7775}
7776
7777// flushSupervisorSummary composes a synthetic user turn describing every
7778// sub-agent's outcome and injects it via SubmitOrQueue so the main
7779// agent picks it up at the next safe boundary. The summary is
7780// phrased as a system update ("Auto-subagents finished: ...") so the
7781// model treats it as observed state, not as a fresh user request.
7782func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
7783	if len(batch) == 0 {
7784		return
7785	}
7786	var sb strings.Builder
7787	fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
7788	for idx, e := range batch {
7789		snap := e.agent.Snapshot()
7790		status := e.outcome
7791		if status == "" {
7792			status = string(snap.Status)
7793		}
7794		task := snap.Task
7795		if task == "" {
7796			task = e.task
7797		}
7798		fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
7799		fmt.Fprintf(&sb, "   task: %s\n", truncateForSummary(task, 240))
7800		if snap.Err != "" {
7801			fmt.Fprintf(&sb, "   error: %s\n", truncateForSummary(snap.Err, 240))
7802		} else if e.err != "" {
7803			fmt.Fprintf(&sb, "   turn error: %s\n", truncateForSummary(e.err, 240))
7804		}
7805		if snap.LastAssistant != "" {
7806			sb.WriteString("   final response:\n")
7807			sb.WriteString(snap.LastAssistant)
7808			sb.WriteString("\n")
7809		} else if tail := strings.TrimSpace(snap.Tail); tail != "" {
7810			fmt.Fprintf(&sb, "   tail: %s\n", truncateForSummary(tail, 600))
7811		}
7812		sb.WriteString("\n")
7813	}
7814	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.")
7815	i.SubmitOrQueue(sb.String(), nil)
7816}
7817
7818func truncateForSummary(s string, n int) string {
7819	s = strings.TrimSpace(s)
7820	if len(s) <= n {
7821		return s
7822	}
7823	return s[:n-3] + "..."
7824}
7825
7826// autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
7827// toggle, in the same order Resolve appends them to a new agent.
7828func autoSubagentsAddenda(cfg InteractiveConfig) []string {
7829	addenda := make([]string, 0, 2)
7830	if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
7831		addenda = append(addenda, addendum)
7832	}
7833	if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
7834		addenda = append(addenda, addendum)
7835	}
7836	return addenda
7837}
7838
7839func containsAutoSubagentsAddendum(addenda []string, want string) bool {
7840	for _, addendum := range addenda {
7841		if addendum == want {
7842			return true
7843		}
7844	}
7845	return false
7846}
7847
7848// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
7849// have been appended by auto-subagents. Resolve appends owned blocks at the
7850// end, so removing the last occurrence preserves an identical base block.
7851func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
7852	idx := strings.LastIndex(system, addendum)
7853	if idx < 0 {
7854		return system, false
7855	}
7856	return system[:idx] + system[idx+len(addendum):], true
7857}
7858
7859// applyAutoSubagentsSystemPrompt appends (active=true) or strips
7860// (active=false) the auto-subagents prompt blocks on the running agent.
7861// The profile manifest and delegation guidance are managed together so
7862// toggling auto-subagents never leaves the model with names but no tool.
7863func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7864	// i.mu is sufficient here: this path updates the existing agent's system
7865	// prompt and managed addenda in place; it does not replace the agent or
7866	// its tool registry, so agentMu is not required.
7867	i.mu.Lock()
7868	defer i.mu.Unlock()
7869	if i.agent == nil {
7870		return
7871	}
7872	addenda := autoSubagentsAddenda(i.cfg)
7873	if active {
7874		sys, _ := i.agent.PromptConfig()
7875		for _, addendum := range addenda {
7876			if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
7877				continue
7878			}
7879			if sys != "" && !strings.HasSuffix(sys, "\n\n") {
7880				sys += "\n\n"
7881			}
7882			sys += addendum
7883			i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
7884		}
7885		i.agent.SetSystemPrompt(sys)
7886		return
7887	}
7888
7889	sys, _ := i.agent.PromptConfig()
7890	changed := false
7891	for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
7892		var removed bool
7893		sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
7894		changed = changed || removed
7895	}
7896	i.managedAutoSubagentsAddenda = nil
7897	if changed {
7898		i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
7899	}
7900}
7901
7902// applyAutoSubagentsTool registers (active=true) or removes (active=false)
7903// the auto-subagent tools on the running agent so the model only sees them
7904// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
7905// snapshot+mutate pattern so extension tools and /reload-ext additions
7906// survive a toggle.
7907func (i *Interactive) autoSubagentsAvailable() bool {
7908	return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
7909}
7910
7911func (i *Interactive) autoSubagentsUnavailableHint() string {
7912	var hints []string
7913	if i.cfg.Supervisor == nil {
7914		hints = append(hints, "subagent supervisor not available in this mode")
7915	}
7916	if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
7917		hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
7918	}
7919	return strings.Join(hints, "; ")
7920}
7921
7922func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
7923	return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
7924}
7925
7926func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
7927	if cfg.AutoSubagentsStatusToolAllowed != nil {
7928		return *cfg.AutoSubagentsStatusToolAllowed
7929	}
7930	// Preserve the old single-boolean embedding contract: when callers have
7931	// not supplied the new field, status follows the existing delegation gate.
7932	return autoSubagentsToolAllowedConfig(cfg)
7933}
7934
7935func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
7936	return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
7937}
7938
7939func (i *Interactive) autoSubagentsToolAllowed() bool {
7940	return autoSubagentsToolAllowedConfig(i.cfg)
7941}
7942
7943func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
7944	return autoSubagentsStatusToolAllowedConfig(i.cfg)
7945}
7946
7947func (i *Interactive) autoSubagentsEnabled() bool {
7948	i.mu.Lock()
7949	defer i.mu.Unlock()
7950	return i.autoSubagentsEnabledLocked()
7951}
7952
7953func (i *Interactive) autoSubagentsEnabledLocked() bool {
7954	return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
7955}
7956
7957func (i *Interactive) applyAutoSubagentsTool(active bool) {
7958	i.agentMu.Lock()
7959	defer i.agentMu.Unlock()
7960	i.mu.Lock()
7961	defer i.mu.Unlock()
7962	if i.agent == nil {
7963		return
7964	}
7965	current := i.agent.ToolsSnapshot()
7966	next := core.Registry{}
7967	for name, t := range current {
7968		if name == "subagent_spawn" || name == "subagent_status" {
7969			continue
7970		}
7971		next[name] = t
7972	}
7973	if active && i.autoSubagentsAvailable() {
7974		if i.autoSubagentsToolAllowed() {
7975			canonical := &tools.SubagentSpawnTool{
7976				Supervisor:       i.cfg.Supervisor,
7977				Enabled:          func() bool { return i.autoSubagentsEnabled() },
7978				DefaultModel:     func() string { return i.cfg.Model },
7979				DefaultProvider:  func() string { return i.cfg.Provider },
7980				DefaultReasoning: func() string { return i.cfg.Reasoning },
7981				ResolveSubagent:  i.cfg.ResolveSubagent,
7982				OnSpawned:        i.trackSubagentWorker,
7983			}
7984			next[canonical.Name()] = canonical
7985		}
7986		if i.autoSubagentsStatusToolAllowed() {
7987			statusTool := &tools.SubagentStatusTool{
7988				Supervisor: i.cfg.Supervisor,
7989				Enabled:    func() bool { return i.autoSubagentsEnabled() },
7990			}
7991			next[statusTool.Name()] = statusTool
7992		}
7993	}
7994	i.agent.SetTools(next)
7995}
7996
7997// applyTelegramTools registers (active=true) or removes (active=false)
7998// the telegram_send_image and telegram_send_file tools on the running
7999// agent so the model only sees them while the bridge is connected.
8000// Snapshots and mutates the live tool registry so any extension or
8001// /reload-ext additions made while Telegram is connected survive a
8002// later /telegram disconnect (we only add or strip the two telegram
8003// entries, never the rest).
8004func (i *Interactive) applyTelegramTools(active bool) {
8005	i.agentMu.Lock()
8006	defer i.agentMu.Unlock()
8007	i.mu.Lock()
8008	defer i.mu.Unlock()
8009	if i.agent == nil {
8010		return
8011	}
8012	current := i.agent.ToolsSnapshot()
8013	next := core.Registry{}
8014	for name, t := range current {
8015		if name == "telegram_send_image" || name == "telegram_send_file" {
8016			continue
8017		}
8018		next[name] = t
8019	}
8020	if active {
8021		sender := telegramSenderAdapter{bridge: i.telegramBridge}
8022		next["telegram_send_image"] = &tools.TelegramSendImageTool{
8023			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
8024		}
8025		next["telegram_send_file"] = &tools.TelegramSendFileTool{
8026			CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
8027		}
8028	}
8029	i.agent.SetTools(next)
8030}
8031
8032// telegramStatus writes a one-liner describing the bridge state.
8033// Reports on both the in-tui bridge and the background daemon so
8034// the user isn't confused when the daemon owns the poll loop.
8035func (i *Interactive) telegramStatus() {
8036	var msg string
8037	if i.telegramBridge != nil && i.telegramBridge.Active() {
8038		s := i.telegramBridge.State()
8039		msg = "telegram: connected (tui bridge)"
8040		if s.Username != "" {
8041			msg += " as @" + s.Username
8042		}
8043		if s.PairedID != 0 {
8044			msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
8045		} else {
8046			msg += " - awaiting pairing"
8047		}
8048	} else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8049		msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
8050	} else {
8051		cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
8052		if cfg.BotToken == "" {
8053			msg = "telegram: not configured. run `zut telegram-bot setup` first."
8054		} else {
8055			msg = "telegram: disconnected"
8056			if cfg.BotUsername != "" {
8057				msg += " (@" + cfg.BotUsername + " ready to connect)"
8058			}
8059		}
8060	}
8061	i.mu.Lock()
8062	i.statusOK = msg
8063	i.statusErr = ""
8064	i.mu.Unlock()
8065	i.invalidate()
8066}
8067
8068// telegramHost adapts *Interactive to telegram.Host so the bridge
8069// can call back into the TUI without importing modes directly.
8070type telegramHost struct{ iv *Interactive }
8071
8072func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
8073	h.iv.SubmitOrQueue(prompt, images)
8074}
8075
8076func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
8077
8078func (h *telegramHost) Status() string {
8079	h.iv.mu.Lock()
8080	providerName := h.iv.cfg.Provider
8081	model := h.iv.cfg.Model
8082	cwd := h.iv.cfg.CWD
8083	usage := h.iv.cumUsage
8084	subscription := h.iv.cfg.AuthMethod == "oauth"
8085	ctxUsed := h.iv.lastCtxInput
8086	busy := h.iv.busy
8087	queued := len(h.iv.queued)
8088	h.iv.mu.Unlock()
8089
8090	ctxMax := 0
8091	if m, err := provider.FindModel(providerName, model); err == nil {
8092		ctxMax = m.ContextWindow
8093	}
8094	return telegram.FormatStatus(telegram.StatusSnapshot{
8095		Provider:     providerName,
8096		Model:        model,
8097		CWD:          cwd,
8098		Usage:        usage,
8099		Subscription: subscription,
8100		ContextUsed:  ctxUsed,
8101		ContextMax:   ctxMax,
8102		Busy:         busy,
8103		Queued:       queued,
8104	})
8105}
8106
8107func (h *telegramHost) Notify(level, message string) {
8108	h.iv.mu.Lock()
8109	switch level {
8110	case "error", "warn":
8111		h.iv.statusErr = message
8112		h.iv.statusOK = ""
8113	default:
8114		h.iv.statusOK = message
8115		h.iv.statusErr = ""
8116	}
8117	h.iv.mu.Unlock()
8118	h.iv.invalidate()
8119}
8120
8121// openSessionOpsDialog shows the picker for `/session` with no arg.
8122// Always offers export, import, fork, tree; the handlers bail with
8123// a clear status message when the precondition isn't met (empty
8124// transcript on fork; no parent/siblings on tree).
8125func (i *Interactive) openSessionOpsDialog() {
8126	items := []sessionOpsItem{
8127		{label: "export", action: "export", hint: "write the current session to a .zutsession file"},
8128		{label: "import", action: "import", hint: "load a .zutsession file into this directory"},
8129		{label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
8130		{label: "tree", action: "tree", hint: "switch between branches in this directory"},
8131	}
8132	i.sessionOpsDialog.Open(items)
8133	i.invalidate()
8134}
8135
8136// doSessionOp dispatches export, import, fork, or tree. arg is the
8137// optional positional argument from e.g. /session export <path>
8138// or /session import <path>; fork and tree ignore it.
8139func (i *Interactive) doSessionOp(action, arg string) {
8140	switch action {
8141	case "export":
8142		i.doSessionExport(arg)
8143	case "import":
8144		i.doSessionImport(arg)
8145	case "fork":
8146		i.doSessionFork()
8147	case "tree":
8148		i.doSessionTree()
8149	default:
8150		i.mu.Lock()
8151		i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
8152		i.mu.Unlock()
8153		i.invalidate()
8154	}
8155}
8156
8157// doSessionExport writes the live session file to destination path
8158// dst. When dst is empty we default to ~/Downloads (falling back to
8159// the user's home directory if it doesn't exist). The helper
8160// expands a leading `~` and creates any missing parent directories.
8161func (i *Interactive) doSessionExport(dst string) {
8162	src := i.currentSessionPath()
8163	if src == "" {
8164		i.mu.Lock()
8165		i.statusErr = "export: no session is active (running with --no-session?)"
8166		i.mu.Unlock()
8167		i.invalidate()
8168		return
8169	}
8170	// Persist any in-memory agent messages to the session file so
8171	// the export carries the full conversation. Without this, the
8172	// default lazy-flush-at-exit strategy leaves most of a running
8173	// session unwritten and the export ends up with only the meta.
8174	if i.cfg.FlushSession != nil {
8175		i.cfg.FlushSession()
8176	}
8177	dst = unquotePath(dst)
8178	if dst == "" {
8179		dst = defaultExportDir()
8180	} else {
8181		dst = expandTilde(dst)
8182	}
8183	out, err := core.ExportSession(src, dst)
8184	if err != nil {
8185		i.mu.Lock()
8186		i.statusErr = "export: " + err.Error()
8187		i.mu.Unlock()
8188		i.invalidate()
8189		return
8190	}
8191	i.mu.Lock()
8192	i.statusOK = "exported session to " + friendlyPath(out)
8193	i.statusErr = ""
8194	i.mu.Unlock()
8195	i.invalidate()
8196}
8197
8198// doSessionImport copies the .zutsession file at src into the
8199// running cwd's sessions directory and loads it as the active
8200// session, same as `/sessions` -> pick. When src is empty we ask
8201// the user to pass a path (no usable default here).
8202func (i *Interactive) doSessionImport(src string) {
8203	src = unquotePath(src)
8204	if src == "" {
8205		i.mu.Lock()
8206		i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
8207		i.mu.Unlock()
8208		i.invalidate()
8209		return
8210	}
8211	src = expandTilde(src)
8212	if _, err := os.Stat(src); err != nil {
8213		i.mu.Lock()
8214		i.statusErr = "import: " + err.Error()
8215		i.mu.Unlock()
8216		i.invalidate()
8217		return
8218	}
8219	newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
8220	if err != nil {
8221		i.mu.Lock()
8222		i.statusErr = "import: " + err.Error()
8223		i.mu.Unlock()
8224		i.invalidate()
8225		return
8226	}
8227	if i.cfg.LoadSession == nil {
8228		i.mu.Lock()
8229		i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
8230		i.statusErr = ""
8231		i.mu.Unlock()
8232		i.invalidate()
8233		return
8234	}
8235	i.markSessionTitleSwitching()
8236	if err := i.cfg.LoadSession(newPath); err != nil {
8237		i.restoreFailedSessionTitle()
8238		i.mu.Lock()
8239		i.statusErr = "import: load failed: " + err.Error()
8240		i.mu.Unlock()
8241		i.invalidate()
8242		return
8243	}
8244	i.restoreLoadedSessionTitle()
8245	i.mu.Lock()
8246	i.statusOK = "imported and switched to session " + friendlyPath(newPath)
8247	i.statusErr = ""
8248	i.mu.Unlock()
8249	i.invalidate()
8250}
8251
8252// defaultExportDir returns ~/Downloads when it exists, or ~ as a
8253// fallback, or /tmp on exotic machines with no home dir.
8254func defaultExportDir() string {
8255	home, err := os.UserHomeDir()
8256	if err != nil || home == "" {
8257		return os.TempDir()
8258	}
8259	downloads := filepath.Join(home, "Downloads")
8260	if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
8261		return downloads
8262	}
8263	return home
8264}
8265
8266// expandTilde turns a leading ~ into the user's home directory.
8267// Returns the input unchanged when there's no tilde or no home.
8268func expandTilde(p string) string {
8269	if p == "" || p[0] != '~' {
8270		return p
8271	}
8272	home, err := os.UserHomeDir()
8273	if err != nil || home == "" {
8274		return p
8275	}
8276	if len(p) == 1 {
8277		return home
8278	}
8279	if p[1] == '/' || p[1] == filepath.Separator {
8280		return filepath.Join(home, p[2:])
8281	}
8282	return p
8283}
8284
8285// unquotePath strips a matching pair of surrounding single or
8286// double quotes. Drag-drop paste in the tui auto-quotes dropped
8287// file paths so the shell-like `/session import 'foo bar.zs'`
8288// stays well-formed; when the TUI's own slash handler consumes
8289// the arg, we want the raw path back.
8290func unquotePath(p string) string {
8291	p = strings.TrimSpace(p)
8292	if len(p) >= 2 {
8293		first := p[0]
8294		last := p[len(p)-1]
8295		if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
8296			p = p[1 : len(p)-1]
8297		}
8298	}
8299	return p
8300}
8301
8302// friendlyPath collapses the user's home directory to a leading ~
8303// so status messages read cleanly. Falls back to the raw path when
8304// the home dir is unknown.
8305func friendlyPath(p string) string {
8306	home, err := os.UserHomeDir()
8307	if err != nil || home == "" {
8308		return p
8309	}
8310	if strings.HasPrefix(p, home+string(filepath.Separator)) {
8311		return "~" + p[len(home):]
8312	}
8313	return p
8314}
8315
8316// doSessionFork opens the /jump turn picker in "fork mode". The
8317// next selection branches the current session at that user turn
8318// instead of scrolling the viewport to it.
8319func (i *Interactive) doSessionFork() {
8320	path := i.currentSessionPath()
8321	if path == "" {
8322		i.mu.Lock()
8323		i.statusErr = "fork: no session is active (running with --no-session?)"
8324		i.mu.Unlock()
8325		i.invalidate()
8326		return
8327	}
8328	i.mu.Lock()
8329	busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
8330	queued := len(i.queued) != 0
8331	ag := i.agent
8332	i.mu.Unlock()
8333	if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
8334		i.mu.Lock()
8335		i.statusErr = "fork: wait until the current turn and queued messages finish"
8336		i.mu.Unlock()
8337		i.invalidate()
8338		return
8339	}
8340	if i.cfg.FlushSession != nil {
8341		i.cfg.FlushSession()
8342	}
8343	snapshot, err := core.ReadSessionSnapshot(path)
8344	if err != nil {
8345		i.mu.Lock()
8346		i.statusErr = "fork: " + err.Error()
8347		i.mu.Unlock()
8348		i.invalidate()
8349		return
8350	}
8351	msgs := snapshot.Messages
8352	if len(msgs) == 0 {
8353		i.mu.Lock()
8354		i.statusErr = "fork: transcript is empty; nothing to fork from"
8355		i.mu.Unlock()
8356		i.invalidate()
8357		return
8358	}
8359	i.pendingFork = true
8360	i.jumpDialog.Open(msgs, "")
8361	i.invalidate()
8362}
8363
8364// doSessionTree opens the current session family after the shared
8365// fail-closed gate succeeds. It intentionally does not fall back to the
8366// in-memory transcript: a tree that cannot be persisted and reloaded cannot
8367// safely create a navigation branch.
8368func (i *Interactive) doSessionTree() {
8369	i.openSessionTree()
8370}
8371
8372// applySessionTreeMessageSelection keeps the old scalar integration
8373// source-compatible for package consumers. New dialog selections use the
8374// structured sessionTreeTarget path below.
8375func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
8376	target := sessionTreeTarget{
8377		SourcePath:        src,
8378		EffectiveIndex:    msgIdx,
8379		SelectionBoundary: msgIdx + 1,
8380		Role:              role,
8381		UserDraft:         prompt,
8382		Boundary:          sessionTreeMessageBoundary,
8383	}
8384	if role == provider.RoleUser {
8385		target.SelectionBoundary = msgIdx
8386	}
8387	i.applySessionTreeTarget(target, turnNo)
8388}
8389
8390// applySessionTreeTarget checks out a structured dialog target. The target's
8391// source and effective indices come from the same preflight snapshot the
8392// dialog rendered, while the live read below preserves compatibility with
8393// the existing LoadSession(path) error callback.
8394func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
8395	// Selection is a state-changing operation too. Use the selection gate so
8396	// a queued/busy turn cannot race the hidden branch or accidentally submit
8397	// a provider turn while the session is being swapped. The open gate cannot
8398	// be reused here because the tree dialog itself owns the keyboard.
8399	if !i.canCommitSessionTreeSelection() {
8400		i.setSessionTreeError("tree: session branching is not available in this build")
8401		return
8402	}
8403	src := target.SourcePath
8404	if src == "" {
8405		src = i.currentSessionPath()
8406	}
8407	if src == "" {
8408		i.setSessionTreeError("tree: no session is active")
8409		return
8410	}
8411
8412	// Read and validate the selected row before flushing or creating anything.
8413	// This gives tool-call/result pairs an atomic boundary and prevents a
8414	// malformed selection from changing the active session. Historical rows
8415	// come from a pre-compaction segment; current rows use the effective
8416	// snapshot that the running agent resumes from.
8417	var msgs []provider.Message
8418	var historicalSegment core.SessionHistorySegment
8419	if target.Historical {
8420		history, err := core.ReadSessionHistory(src)
8421		if err != nil {
8422			i.setSessionTreeError("tree: read selection: " + err.Error())
8423			return
8424		}
8425		if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
8426			i.setSessionTreeError("tree: selected history segment is unavailable")
8427			return
8428		}
8429		historicalSegment = history.Segments[target.HistorySegment]
8430		msgs = historicalSegment.Messages
8431	} else {
8432		sess, current, err := core.OpenSession(src)
8433		if err != nil {
8434			i.setSessionTreeError("tree: read selection: " + err.Error())
8435			return
8436		}
8437		_ = sess.Close()
8438		msgs = current
8439	}
8440	selection, err := sessionTreeSelection(msgs, target)
8441	if err != nil {
8442		i.setSessionTreeError("tree: " + err.Error())
8443		return
8444	}
8445	if i.cfg.FlushSession != nil {
8446		i.cfg.FlushSession()
8447	}
8448	var newPath string
8449	if target.Historical {
8450		newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
8451	} else {
8452		newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
8453	}
8454	if err != nil {
8455		i.setSessionTreeError("tree: " + err.Error())
8456		return
8457	}
8458	i.markSessionTitleSwitching()
8459	if err := i.cfg.LoadSession(newPath); err != nil {
8460		i.restoreFailedSessionTitle()
8461		i.setSessionTreeError("tree: checkout failed: " + err.Error())
8462		return
8463	}
8464
8465	// LoadSession is intentionally the old func(string) error callback: the
8466	// CLI and embedders already own agent/session swapping. Refresh the view
8467	// from the swapped agent here instead of requiring a new callback result.
8468	i.resetSessionTitleForFreshBranch()
8469	i.scrollToBottom()
8470	i.mu.Lock()
8471	i.lastCtxInput = 0
8472	if i.agent != nil {
8473		i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
8474		i.cumUsage = i.agent.Cost()
8475		last := i.agent.LastTurnUsage()
8476		i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
8477		i.view.InvalidateRenderCache()
8478	}
8479	i.toolCalls = map[string]*tui.ToolCallView{}
8480	i.toolOrder = nil
8481	i.toolGate = map[string]int{}
8482	i.extNotes = nil
8483	if selection.restoreDraft {
8484		i.ed.SetValue(selection.draftText)
8485		i.clipboardImages = selection.images
8486		i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
8487	} else {
8488		i.ed.Clear()
8489		i.clipboardImages = nil
8490		i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
8491	}
8492	i.statusErr = ""
8493	i.mu.Unlock()
8494	i.scrollToBottom()
8495	// Selection only changes local/session state. In particular, do not call
8496	// Submit, startTurn, or queue a message here; the restored user draft is
8497	// submitted only by a later explicit Enter.
8498	i.invalidate()
8499}
8500
8501// applyForkSelection branches the current session at msgIdx+1 (so
8502// the selected user message and everything before it is included
8503// in the new branch), then switches the running agent to the new
8504// file. Called from the jump-dialog handler when pendingFork=true.
8505func (i *Interactive) applyForkSelection(msgIdx int) {
8506	i.pendingFork = false
8507	src := i.currentSessionPath()
8508	if src == "" {
8509		i.mu.Lock()
8510		i.statusErr = "fork: no session is active"
8511		i.mu.Unlock()
8512		i.invalidate()
8513		return
8514	}
8515	if i.cfg.FlushSession != nil {
8516		i.cfg.FlushSession()
8517	}
8518	// msgIdx is 0-indexed message position; copy msgIdx+1 rows so
8519	// the selected user message is included.
8520	upTo := msgIdx + 1
8521	newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
8522	if err != nil {
8523		i.mu.Lock()
8524		i.statusErr = "fork: " + err.Error()
8525		i.mu.Unlock()
8526		i.invalidate()
8527		return
8528	}
8529	if i.cfg.LoadSession == nil {
8530		i.mu.Lock()
8531		i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
8532		i.statusErr = ""
8533		i.mu.Unlock()
8534		i.invalidate()
8535		return
8536	}
8537	i.markSessionTitleSwitching()
8538	if err := i.cfg.LoadSession(newPath); err != nil {
8539		i.restoreFailedSessionTitle()
8540		i.mu.Lock()
8541		i.statusErr = "fork: switch failed: " + err.Error()
8542		i.mu.Unlock()
8543		i.invalidate()
8544		return
8545	}
8546	i.resetSessionTitleForFreshBranch()
8547	i.mu.Lock()
8548	i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
8549	i.statusErr = ""
8550	i.mu.Unlock()
8551	i.invalidate()
8552}
8553
8554// formatInt is a tiny strconv.Itoa shim; keeps the handler above
8555// from needing a strconv import just for one call.
8556func formatInt(n int) string {
8557	return fmt.Sprintf("%d", n)
8558}
8559
8560// assistantText returns the concatenated text of every TextBlock in
8561// m. Used by the streaming-view dedupe guard to tell when a live
8562// streamed reply has already been promoted into the transcript.
8563func assistantText(m provider.Message) string {
8564	var sb strings.Builder
8565	for _, c := range m.Content {
8566		if tb, ok := c.(provider.TextBlock); ok {
8567			if sb.Len() > 0 {
8568				sb.WriteByte('\n')
8569			}
8570			sb.WriteString(tb.Text)
8571		}
8572	}
8573	return sb.String()
8574}
8575
8576// resetTranscriptRenderLocked invalidates every render cache that assumes
8577// the previous transcript remains structurally intact. Compaction is a
8578// replacement, not an append: the flow renderer must repaint from the new
8579// transcript rather than diff it against scrollback rows from the old one.
8580// Must be called with i.mu held.
8581func (i *Interactive) resetTranscriptRenderLocked() {
8582	i.view.InvalidateRenderCache()
8583	i.chatCacheValid = false
8584	i.prevChatLen = 0
8585	i.prevChatCols = 0
8586	i.prevChatRows = 0
8587	i.prevScrollOffset = 0
8588	if i.rend != nil {
8589		i.rend.Invalidate()
8590	}
8591}
8592
8593// resetStreamingStateLocked clears every piece of streaming state
8594// in one shot. Used by abort paths (turn cancel, compact hand-off,
8595// queue drain) so the pacer doesn't keep draining stale runes from
8596// a prior turn. Must be called with i.mu held.
8597func (i *Interactive) resetStreamingStateLocked() {
8598	i.streaming.Reset()
8599	i.streamPending = i.streamPending[:0]
8600	i.streamFlushPending = false
8601	i.streamOn = false
8602	i.openAllToolGatesLocked()
8603}
8604
8605// openAllToolGatesLocked drops every pending tool gate so that any
8606// tool registered during this turn renders unconditionally from now
8607// on. Called when streaming finalizes (the paced text has fully
8608// drained and `streaming` is about to reset to length 0): without
8609// this, the gate comparison against a freshly-reset streaming buffer
8610// would wrongly re-hide tools that had already cleared their gate.
8611// Must be called with i.mu held.
8612func (i *Interactive) openAllToolGatesLocked() {
8613	for id := range i.toolGate {
8614		i.toolGate[id] = 0
8615	}
8616}
8617
8618// gateToolLocked records the stream position at which a tool call may
8619// become visible. The gate is the total length the streaming buffer
8620// will reach once the pacer has drained everything currently queued
8621// (already painted + still pending). Holding the tool block back
8622// until the pacer crosses that mark guarantees the prose emitted
8623// before the tool call finishes typing out above it, instead of the
8624// tool block snapping in while the paragraph is still filling in.
8625//
8626// We only gate while text is actively streaming. If no stream is in
8627// flight (gate 0), the tool shows immediately, which is the correct
8628// behaviour for tool-only turns and replayed sessions. First
8629// registration wins so a later EvToolCall can't move an existing
8630// gate. Must be called with i.mu held.
8631func (i *Interactive) gateToolLocked(id string) {
8632	if _, ok := i.toolGate[id]; ok {
8633		return
8634	}
8635	if !i.streamOn {
8636		i.toolGate[id] = 0
8637		return
8638	}
8639	i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
8640}
8641
8642// toolGateOpenLocked reports whether the gated tool block may render
8643// yet, i.e. the pacer has drained enough text to reach the position
8644// recorded when the tool call arrived. Must be called with i.mu held.
8645func (i *Interactive) toolGateOpenLocked(id string) bool {
8646	gate, ok := i.toolGate[id]
8647	if !ok || gate == 0 {
8648		return true
8649	}
8650	return i.streaming.Len() >= gate
8651}
8652
8653// assistantMessageSideEffects runs the non-visual hooks attached to
8654// EvAssistantMessage: the host-provided OnAssistant callback and the
8655// telegram-bridge mirror. Called with i.mu held.
8656//
8657// Factored out of handleEvent because the streaming pacer may defer
8658// visual reset until after the last buffered rune has painted, but
8659// the callbacks themselves must fire on message arrival so
8660// downstream observers (session persistence, telegram, cost panels)
8661// don't wait on a UI animation to catch up.
8662func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
8663	if i.cfg.OnAssistant != nil {
8664		i.cfg.OnAssistant(m)
8665	}
8666	if i.telegramBridge != nil && i.telegramBridge.Active() {
8667		var sb strings.Builder
8668		for _, c := range m.Content {
8669			if tb, ok := c.(provider.TextBlock); ok {
8670				if sb.Len() > 0 {
8671					sb.WriteString("\n")
8672				}
8673				sb.WriteString(tb.Text)
8674			}
8675		}
8676		if text := sb.String(); strings.TrimSpace(text) != "" {
8677			go i.telegramBridge.OnAssistantText(text)
8678		}
8679	}
8680}
8681
8682// paintPaceRate is how many runes the streaming pacer releases per
8683// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
8684// that a 500-rune summary finishes in ~1.3s, slow enough to look
8685// like a human typing. Empirically matches the feel of provider
8686// paths that already drip-stream natively.
8687const paintPaceRate = 6
8688
8689// paintPaceInterval is the tick interval for the streaming pacer.
8690// 16ms lines up with the redraw throttle so we never paint faster
8691// than the terminal can keep up.
8692const paintPaceInterval = 16 * time.Millisecond
8693
8694// runStreamPacer drains buffered deltas from streamPending into
8695// streaming a small batch per tick, invalidating after each move.
8696// It stops when the context cancels (tui shutdown).
8697//
8698// Why a pacer: providers differ wildly in how they chunk their
8699// text_delta events. The API-key path on Anthropic emits ~30 drips
8700// for a 400-token summary; the OAuth path can coalesce the same
8701// summary into 3 fat chunks, visually indistinguishable from "the
8702// whole reply just appeared". The pacer normalizes that so every
8703// path looks the same on screen.
8704func (i *Interactive) runStreamPacer(ctx context.Context) {
8705	t := time.NewTicker(paintPaceInterval)
8706	defer t.Stop()
8707	for {
8708		select {
8709		case <-ctx.Done():
8710			return
8711		case <-t.C:
8712			i.mu.Lock()
8713			if len(i.streamPending) == 0 {
8714				// EvAssistantMessage already fired but the pacer
8715				// was still draining a tick ago. Everything is now
8716				// painted; clear the streaming flags so the next
8717				// redraw shows the finalised transcript message
8718				// and hides the streaming overlay.
8719				if i.streamFlushPending {
8720					i.streamFlushPending = false
8721					i.streaming.Reset()
8722					i.streamOn = false
8723					i.openAllToolGatesLocked()
8724					i.mu.Unlock()
8725					i.invalidate()
8726					continue
8727				}
8728				i.mu.Unlock()
8729				continue
8730			}
8731			n := paintPaceRate
8732			if n > len(i.streamPending) {
8733				n = len(i.streamPending)
8734			}
8735			i.streaming.WriteString(string(i.streamPending[:n]))
8736			i.streamPending = i.streamPending[n:]
8737			i.mu.Unlock()
8738			i.invalidate()
8739		}
8740	}
8741}

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