v0.12.0 0c5f8d5

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close