v0.9.0 84a0b9d

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close