v0.13.0 befd698

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close