v0.11.4 020512d

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close