v0.6.0 01ec125

interactive.go RawHistoryFolder

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

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