v0.15.0 6b178f8

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close