v0.2.0 42a5079

interactive.go RawHistoryFolder

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

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