v0.14.0 75d4151

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close