v0.3.0 761f038

interactive.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close