v0.11.0 e1eefc2

interactive.go RawHistoryFolder

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

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