v0.11.5 c4261b5

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close