v0.11.1 ab30a57

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close