v0.44.0 efd47b3

interactive.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"sync"
8	"sync/atomic"
9	"time"
10
11	"github.com/bnema/zut/packages/agent/extensions"
12	"github.com/bnema/zut/packages/agent/extproto"
13	"github.com/bnema/zut/packages/agent/internal/orchestration"
14	"github.com/bnema/zut/packages/agent/modes/telegram"
15	"github.com/bnema/zut/packages/agent/skills"
16	"github.com/bnema/zut/packages/agent/subagents"
17	"github.com/bnema/zut/packages/agent/tools"
18	"github.com/bnema/zut/packages/core"
19	"github.com/bnema/zut/packages/provider"
20	"github.com/bnema/zut/packages/provider/auth"
21	"github.com/bnema/zut/packages/tui"
22)
23
24// InteractiveConfig configures the interactive loop.
25type InteractiveConfig struct {
26	Terminal        tui.Terminal
27	Theme           tui.Theme
28	Model           string
29	Provider        string
30	AuthMethod      string // "apikey" | "oauth" — distinguishes API charges from subscription estimates
31	BaseURL         string
32	Reasoning       string
33	SystemPrompt    string
34	WritingGuidance string
35	Tools           core.Registry
36	MaxSteps        int
37	CWD             string
38
39	// Startup resource fields are loaded inputs available to list before the
40	// transcript. They are never sent to the provider or persisted by the view.
41	StartupAgentName      string
42	StartupContextPaths   []string
43	StartupExtensionNames []string
44	StartupSkillNames     []string
45
46	// StartupExtensionErrors are non-fatal extension load failures collected
47	// before the TUI starts. They are display-only and never enter the transcript.
48	StartupExtensionErrors []string
49
50	// ShowInstructionsAtStartup controls whether startup resources are visible.
51	// nil means the default, off.
52	ShowInstructionsAtStartup *bool
53
54	// InlineImagesEnabled overrides terminal image rendering. nil means
55	// auto-detect and render when supported; false disables; true uses
56	// the detected protocol when available.
57	InlineImagesEnabled *bool
58
59	// TerminalAlertsEnabled controls interactive alerts from the main
60	// agent and extensions. nil means enabled; false disables them.
61	TerminalAlertsEnabled *bool
62
63	// TerminalTitleEnabled controls the hidden title request and terminal
64	// title updates. nil means enabled; false disables both.
65	TerminalTitleEnabled *bool
66
67	// AutoSubagentsEnabled mirrors the persisted config flag at startup so
68	// the /settings dialog can render the current state without
69	// re-reading config.json on every open.
70	AutoSubagentsEnabled *bool
71
72	// PonytailEnabled mirrors the persisted default-on coding-guidance
73	// setting at startup. nil/missing means enabled.
74	PonytailEnabled *bool
75
76	// WebSearchEnabled mirrors the persisted default-on web-search setting.
77	// nil/missing means enabled for normal interactive sessions.
78	WebSearchEnabled *bool
79
80	// WebSearchToolAllowed is the immutable invocation-level capability ceiling.
81	// false means --no-tools, --tools, or a packaged PermissionSet excludes it.
82	WebSearchToolAllowed *bool
83
84	// WebSearchInvocationOverride is true when an explicit --tools list includes
85	// web_search. That invocation-level opt-in takes precedence over the
86	// persisted setting, so the settings row is informational for this session.
87	WebSearchInvocationOverride bool
88
89	// AutoSubagentsToolAllowed is nil for normal sessions and false when the
90	// launch-time --no-tools/--tools policy excludes subagent spawning.
91	AutoSubagentsToolAllowed *bool
92
93	// AutoSubagentsStatusToolAllowed is nil for normal sessions and false when
94	// the launch-time --no-tools/--tools policy excludes status queries.
95	AutoSubagentsStatusToolAllowed *bool
96
97	// AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil
98	// for normal sessions and false when the launch-time tool policy excludes
99	// manager lifecycle actions.
100	AutoSubagentsStopToolAllowed   *bool
101	AutoSubagentsResumeToolAllowed *bool
102
103	// FastMode mirrors the active profile's or global persisted OpenAI
104	// fast-mode flag at startup. nil/missing means disabled. Unsupported
105	// providers reject attempts to enable it and reject requests when it
106	// remains enabled.
107	FastMode *bool
108
109	// LSPEnabled controls the main session's built-in lsp tool. nil means
110	// enabled by default.
111	LSPEnabled *bool
112
113	// SubagentLSPEnabled controls whether newly spawned subagent children
114	// receive the lsp tool. nil means enabled by default.
115	SubagentLSPEnabled *bool
116
117	// AutoCompactThreshold is the context-window percentage that triggers
118	// automatic compaction. nil means 85; zero disables percentage-based
119	// triggers while preserving payload-too-large recovery.
120	AutoCompactThreshold *int
121
122	// JailByDefault mirrors the persisted jail_by_default preference.
123	// The current sandbox may differ after a session-scoped /jail or /unjail.
124	JailByDefault *bool
125
126	// RecursiveFileSuggest mirrors the persisted recursive_file_suggest
127	// flag at startup. When true the @-mention picker fuzzy-searches the
128	// whole project tree instead of browsing one directory at a time.
129	RecursiveFileSuggest *bool
130
131	// RespectGitignore mirrors the persisted respect_gitignore flag at
132	// startup. nil means the default (on); when false the @-mention
133	// picker shows files matched by the project's root .gitignore.
134	RespectGitignore *bool
135
136	// ThemeName mirrors the persisted config theme value. Empty means auto.
137	ThemeName string
138	// EffectiveThemeName is the process-local selection after ZUT_THEME
139	// precedence. ThemeForced keeps settings persistent without changing a
140	// dark/light environment override during this run.
141	EffectiveThemeName string
142	ThemeForced        bool
143	// TerminalProfile is distinct from the applied Theme because an explicit
144	// or custom theme is not evidence about the controlling terminal.
145	TerminalProfile tui.TerminalProfile
146	// ThemeWatchInterval is test-only when non-zero; production uses 500ms.
147	ThemeWatchInterval time.Duration
148
149	// FlatTools renders tool calls without the bordered panel (a quiet
150	// header line plus indented, frameless output). Mirrors the
151	// resolved tool_render config / ZUT_FLAT_TOOLS env at startup.
152	FlatTools bool
153
154	// CompactUser renders sent user messages as a single quiet gutter
155	// line instead of a padded, tinted bubble. Mirrors the resolved
156	// compact_input config / ZUT_COMPACT_INPUT env at startup.
157	CompactUser bool
158
159	// CompactMode mirrors the persisted compact_mode flag at startup.
160	// When true, it enables both flat tool calls and compact user turns.
161	CompactMode *bool
162
163	// TUIInputStyle controls the main input rendering: plain or lines.
164	TUIInputStyle string
165
166	// TUIStatusPosition controls whether the status block renders above
167	// or below the main input.
168	TUIStatusPosition string
169
170	// TUIWorkingPosition controls whether the busy/working spinner renders
171	// above or below the main input.
172	TUIWorkingPosition string
173
174	// QuickModelShortcuts maps slots 1-9 to model profiles. The shortcuts
175	// are Ctrl+1..9. Cmd+1..9 may also work when the terminal forwards
176	// Command/Super keypresses, but Ctrl is the displayed chord.
177	QuickModelShortcuts []QuickModelShortcut
178	ActiveModelProfile  int // 0 means no active profile; otherwise 1–9
179
180	// ExtensionThemes returns themes bundled with loaded extensions.
181	ExtensionThemes func() []tui.ThemeOption
182
183	// AutoSubagentsSystemAddendum is the proactive collaboration block that
184	// gets appended when the user enables auto-subagents at runtime. Plumbed in
185	// from the cli so this package doesn't have to import agent (cycle).
186	AutoSubagentsSystemAddendum string
187
188	// OnDemandSubagentsSystemAddendum limits delegation to explicit user
189	// requests while auto-subagents is disabled.
190	OnDemandSubagentsSystemAddendum string
191
192	// SubagentsSystemAddendum is the metadata-only [subagents_list]
193	// block available whenever the primary agent can spawn subagents.
194	SubagentsSystemAddendum string
195	SettingsStore           SettingsStore
196
197	// Agent is optional. If nil, zut opens without credentials; the
198	// user must /login before they can prompt.
199	Agent *core.Agent
200
201	// InitialCompactHandoff is the opaque, persisted handoff snapshot for a
202	// resumed interactive session. Invalid data is ignored by this package.
203	InitialCompactHandoff json.RawMessage
204	// PersistCompactHandoff records the current opaque handoff snapshot in the
205	// active session. It must not re-enter Interactive.
206	PersistCompactHandoff func(json.RawMessage) error
207	// CurrentCompactHandoff returns the opaque handoff snapshot for the session
208	// currently owned by the host after a session switch. Invalid data is ignored.
209	CurrentCompactHandoff func() json.RawMessage
210
211	InitialInput string
212
213	// StartupPre is auto-submitted once when the interactive session
214	// opens, before InitialInput is applied. Uses the same path as a
215	// typed Enter submit ("!" → shell escape, otherwise a model turn).
216	StartupPre string
217
218	// OnStartupPreDone runs after StartupPre finishes (shell escape or
219	// model turn), before deferred InitialInput is applied. Hosts use
220	// it to rediscover skills / reload extensions installed by pre.
221	OnStartupPreDone func()
222
223	// RefreshTools re-resolves the live agent registry after a setting
224	// changes the main session's tool availability. Optional for embedders.
225	RefreshTools func() error
226
227	// SetWebSearchAvailable updates session-wide execution and generic-child
228	// ceilings after a live registry commit or fail-closed revocation. It can
229	// run while agentMu and i.mu are held, so callbacks must not re-enter
230	// Interactive or wait on either lock. The CLI callback only updates its
231	// web-search guard and resident child policy.
232	SetWebSearchAvailable func(bool)
233
234	// RefreshPrompt re-resolves the complete live prompt and tool registry
235	// after a prompt-affecting setting changes. Optional for embedders.
236	RefreshPrompt func() error
237
238	// AutoSubmitInitial, when true, auto-submits InitialInput after
239	// StartupPre completes (or immediately when StartupPre is empty).
240	// When false, InitialInput only pre-fills the editor.
241	AutoSubmitInitial bool
242
243	// Auth is required. When the user runs /login, Interactive talks to
244	// AuthManager to open a browser and wait for the callback.
245	AuthManager *auth.Manager
246
247	// LlamaCPPConfig resolves the router URL and optional API key from env or
248	// the credential saved by /login.
249	LlamaCPPConfig func() (baseURL, apiKey string, err error)
250
251	// RefreshLlamaCPPModels synchronizes currently loaded router models into
252	// the catalog before /model opens.
253	RefreshLlamaCPPModels func(context.Context) error
254
255	// BuildAgent is called after a successful login to (re)construct the
256	// agent with the fresh credential. It returns the new agent and
257	// the concrete provider/model in use.
258	BuildAgent func() (*core.Agent, string, string, error)
259
260	// SetKimiCLIFallbackDisabled controls whether zut may fall back to
261	// the official Kimi Code CLI token when zut has no stored Kimi token.
262	SetKimiCLIFallbackDisabled func(disabled bool) error
263
264	// BuildAgentFor rebuilds the agent with an explicit provider/model
265	// override (used by the /model picker when switching providers).
266	// If providerOverride is empty, the current provider is kept.
267	BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
268
269	// BuildAgentForRescue rebuilds the agent for the rescue picker that
270	// opens after a recoverable provider failure. Unlike BuildAgentFor,
271	// this builder drops launch-time --api-key and --base-url overrides
272	// because those are usually the reason rescue triggered. Re-resolves
273	// credentials from env vars / auth.json / provider defaults so the
274	// retry has a real chance of succeeding. Falls back to BuildAgentFor
275	// when nil so embedders that don't wire it keep working.
276	BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
277
278	// LoggedInProviders returns the list of provider names that
279	// currently have credentials. Used by /model to filter the
280	// picker to only show reachable models.
281	LoggedInProviders func() []string
282
283	// ZutHome is zut's global state directory, used by authentication,
284	// themes, extensions, and other shared configuration.
285	ZutHome string
286
287	// SessionsRoot is the root passed to core session operations. It differs
288	// from ZutHome for Zutfile agents, whose sessions are isolated by agent
289	// name. Empty falls back to ZutHome for embedders and tests.
290	SessionsRoot string
291	// SessionsDisabled is true for --no-session invocations. The picker must
292	// fail closed instead of offering stale or non-persisted state.
293	SessionsDisabled bool
294
295	// Version is the binary's current version (from main.version).
296	// Used only for display; the update check itself is done outside
297	// this package to avoid an import cycle.
298	Version string
299
300	// UpdateInfoChan is an optional channel that delivers the result
301	// of the github-release update check. Interactive reads at most
302	// one value, drops it if the check reported nothing, and otherwise
303	// surfaces a yellow "update available" banner at the top of the
304	// chat. Nil channel = no banner, no startup cost.
305	UpdateInfoChan <-chan UpdateInfo
306
307	// Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
308	Sandbox *tools.Sandbox
309
310	// LoadSession swaps the current session for the one at path. The
311	// callback returns the new agent message slice so the TUI can invalidate.
312	LoadSession func(path string) error
313	// LoadSessionContext is the cancellation-aware picker path. Embedders that
314	// only implement LoadSession retain the existing synchronous contract.
315	LoadSessionContext func(context.Context, string) error
316
317	// ChangeCWD switches the running zut session's working directory
318	// to path. The host closes the current session, rebuilds the
319	// agent so tools / AGENTS.md / sandbox bind to the new cwd, and
320	// opens a fresh session there. Returns an error if path doesn't
321	// exist, isn't a directory, or the host can't rebuild the agent.
322	//
323	// Optional: not wired by every embedder. When nil the hidden /cd
324	// command surfaces a clear error rather than no-oping.
325	ChangeCWD func(path string) error
326
327	// CurrentSessionPath returns the path of the live session file
328	// on disk (the one every AppendMessage writes to). Used by
329	// /session export so the exporter ships the exact bytes on
330	// disk. Returns an empty string when --no-session is set or
331	// no session is open.
332	CurrentSessionPath func() string
333
334	// FlushSession writes any in-memory agent messages to the
335	// session file that haven't been persisted yet. Called by
336	// /session export right before reading the file so the
337	// exported bytes reflect the full current conversation, not
338	// just the rows the agent happened to write synchronously.
339	// The default WriteNewTranscript-at-exit strategy means most
340	// of a running session lives only in memory until the tui
341	// closes; without a flush hook, /session export writes a
342	// file that only has the meta row.
343	FlushSession func()
344
345	// SessionTransition serializes a state-changing session/model
346	// operation with the host's session load. It is optional for embedders;
347	// the CLI uses it to prevent a slow provider rebuild from being replaced
348	// by a concurrent resume (or vice versa).
349	SessionTransition func(func())
350
351	// PersistModel is called whenever the user switches model or provider.
352	// It should update config.json and (if there's an active session)
353	// write a new meta row so resume picks up the same model.
354	PersistModel func(providerName, model string)
355	// OnReasoningChanged updates host-owned runtime defaults after the setting
356	// has been persisted successfully.
357	OnReasoningChanged func(level string)
358	// OnFastModeChanged updates host-owned runtime defaults after the setting
359	// has been persisted successfully.
360	OnFastModeChanged func(enabled bool)
361
362	// InitialSessionTitle is the persisted title of the session loaded before
363	// the TUI starts. It is shown in the terminal without another model call.
364	InitialSessionTitle string
365	// InitialSessionTitlePending is true for a newly forked branch that only
366	// contains its copied prefix. The copied user messages must not prevent a
367	// new title after the branch's first real prompt.
368	InitialSessionTitlePending bool
369
370	// CurrentSessionTitle returns the title of the session currently owned by
371	// the host. It is used after /sessions and import switches.
372	CurrentSessionTitle func() string
373	// CurrentSessionTitlePending reports whether the loaded session is a new
374	// branch that has not received a post-fork prompt yet.
375	CurrentSessionTitlePending func() bool
376
377	// PersistTitle stores an automatically generated title without adding a
378	// user or assistant message to the transcript. It returns persistence
379	// errors without affecting the main turn.
380	PersistTitle func(title string) error
381	// OnSessionTitleChanged updates host-side in-memory title bookkeeping
382	// after a manual rename has already been written to disk.
383	OnSessionTitleChanged func(title string)
384
385	// CurrentGoal and PersistGoal expose the autonomous goal attached to the
386	// current session. CurrentGoal must return a copy, not the live session
387	// pointer, so failed persistence cannot mutate in-memory state. The callbacks
388	// follow session switches without coupling the TUI to the session file owner.
389	CurrentGoal        func() *core.SessionGoal
390	CurrentGoalHistory func() []core.SessionGoal
391	EnsureMission      func(objective string) error
392	PersistGoal        func(goal *core.SessionGoal) error
393	// PersistGoalRuntime records execution bookkeeping without adding a mission
394	// transition to goal history.
395	PersistGoalRuntime func(goal *core.SessionGoal) error
396	// CurrentPlan returns a copy of the agent-owned checklist. PersistPlan
397	// writes it as a session meta transition. They let the TUI seed its plan
398	// counters and snapshot map on resume and clear them on /clear without
399	// owning session state.
400	CurrentPlan func() []core.PlanStep
401	PersistPlan func(plan *core.SessionPlan) error
402	// PersistedPlan reports the plan stored on the bound session, or nil. It
403	// lets /clear drop a file plan that diverged from core state.
404	PersistedPlan func() *core.SessionPlan
405	// InitialPlanSnapshots, when non-nil, seeds the historical plan checklist
406	// snapshots at construction. The CLI reconstructs them from the session's
407	// full transcript, which reaches further back than the resumed agent's
408	// trimmed message window: replaying a delta against an empty base would
409	// rebuild the wrong checklist. When nil, the constructor derives snapshots
410	// from the agent's own messages.
411	InitialPlanSnapshots map[string]core.PlanUpdate
412	// GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited.
413	GoalMaxTokenBudget *uint64
414
415	OnAssistant func(m provider.Message)
416
417	// Extensions, if non-nil, lets users invoke extension-registered
418	// slash commands. Commands declared by extensions are looked up
419	// AFTER the built-in catalog so a built-in name always wins.
420	Extensions *extensions.Manager
421
422	// ResidentManager owns the /subagents runtime and provides bounded journal
423	// pages plus live projections to the child-session UI.
424	ResidentManager *subagents.ResidentManager
425	// SpawnResident resolves the host's live registry/configuration and commits
426	// a new resident child. Keeping that construction callback in agent avoids
427	// leaking provider or credential assembly into modes.
428	SpawnResident     func(context.Context, tools.ResidentSpawnRequest) (string, error)
429	BuildResidentSpec func(context.Context, tools.ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
430
431	// ResolveSubagent validates a named markdown profile for direct
432	// /subagents commands. Auto-subagents uses the equivalent callback on its
433	// tool; keeping this optional preserves lightweight embedders.
434	ResolveSubagent func(name string) (*subagents.Profile, error)
435
436	// SkillSnapshot, if non-nil, returns the current list of
437	// discovered SKILL.md files. Re-invoked each time /skills opens
438	// so the picker reflects edits made during the session.
439	SkillSnapshot func() []*skills.Skill
440
441	// ChangelogChan, if non-nil, delivers release-notes for the
442	// current binary version once at startup. Interactive opens a
443	// dismissible overlay when the channel produces a non-empty
444	// body. Receiver fires at most once.
445	ChangelogChan <-chan ChangelogPayload
446
447	// OnChangelogDismiss, if non-nil, is called once the user
448	// closes the changelog overlay. The cli wires this to a
449	// MarkChangelogShown call so the same version doesn't show
450	// again on the next launch.
451	OnChangelogDismiss func()
452
453	// NoYolo is true when --no-yolo was passed. Interactive opens
454	// a confirmation dialog before every tool call and blocks the
455	// tool until the user picks yes / always-this-tool /
456	// always-all / no. When false (default), tools run freely.
457	NoYolo bool
458
459	// ConfirmGate is the session-scoped gate wrapping this
460	// interactive's Confirmer. When non-nil, /yolo can call
461	// AllowAll() on it to disable confirmation for the rest of the
462	// session. When nil (yolo mode), /yolo reports that there's
463	// nothing to disable.
464	ConfirmGate *core.ConfirmGate
465}
466
467// ChangelogPayload mirrors agent.ChangelogInfo without the import
468// cycle. The cli builds one from the http response, the tui opens
469// the overlay when one arrives.
470type ChangelogPayload struct {
471	Version string
472	Body    string
473	URL     string
474}
475
476type modelRefreshResult struct{ err error }
477
478// Interactive is the TUI chat loop.
479type chatCacheKey struct {
480	cols                 int
481	agentRev             uint64
482	statusOK             string
483	statusErr            string
484	help                 string
485	sessionInfo          string
486	extNotes             string
487	extStatuses          string
488	extWidgets           string
489	reloadErrors         string
490	updateAvailable      bool
491	updateCurrent        string
492	updateLatest         string
493	updateURL            string
494	welcomeShowVer       bool
495	expandAll            bool
496	tailLimit            int
497	planRev              uint64
498	renderedMessageCount int
499	viewCacheRev         uint64
500}
501
502// QuickModelShortcut is one configured model profile slot.
503type QuickModelShortcut struct {
504	Provider  string
505	Model     string
506	Reasoning string
507	FastMode  bool
508}
509
510type extensionStatus struct {
511	Level string
512	Text  string
513}
514
515type extensionWidget struct {
516	Position string
517	Title    string
518	Lines    []string
519}
520
521// SettingsStore persists user-toggleable settings surfaced by /settings.
522type SettingsStore interface {
523	SetQuickModelShortcut(slot int, providerName, model string) error
524	SetInlineImages(enabled bool) error
525	SetAutoSubagents(enabled bool) error
526	SetJailByDefault(enabled bool) error
527	SetRecursiveFileSuggest(enabled bool) error
528	SetRespectGitignore(enabled bool) error
529	SetCompactMode(enabled bool) error
530	SetTUIInputStyle(style string) error
531	SetTUIStatusPosition(position string) error
532	SetTUIWorkingPosition(position string) error
533	SetReasoning(level string) error
534	SetTheme(name string) error
535}
536
537// Optional so existing SettingsStore implementations remain source-compatible.
538// Stores without this method retain model-only shortcuts.
539type modelProfileSettingsStore interface {
540	SetModelProfile(slot int, profile QuickModelShortcut, activeSlot int) error
541}
542
543type terminalAlertsSettingsStore interface {
544	SetTerminalAlertsEnabled(enabled bool) error
545}
546
547type terminalTitleSettingsStore interface {
548	SetTerminalTitleEnabled(enabled bool) error
549}
550
551type lspSettingsStore interface {
552	SetLSPEnabled(enabled bool) error
553	SetSubagentLSPEnabled(enabled bool) error
554}
555
556type showInstructionsSettingsStore interface {
557	SetShowInstructionsAtStartup(enabled bool) error
558}
559
560type autoCompactThresholdSettingsStore interface {
561	SetAutoCompactThreshold(percent int) error
562}
563
564type fastModeSettingsStore interface {
565	SetFastMode(enabled bool) error
566}
567
568type ponytailSettingsStore interface {
569	SetPonytailEnabled(enabled bool) error
570}
571
572type webSearchSettingsStore interface {
573	SetWebSearchEnabled(enabled bool) error
574}
575
576type compactContinuationOrigin uint8
577
578type compactContinuationRequest struct {
579	origin    compactContinuationOrigin
580	force     bool
581	lastStop  provider.StopReason
582	turnError error
583}
584
585const (
586	compactOriginManual compactContinuationOrigin = iota
587	compactOriginPreTurnThreshold
588	compactOriginAfterTurnThreshold
589	compactOriginRecovery
590)
591
592type compactContinuationReason uint8
593
594const (
595	compactContinuationNone compactContinuationReason = iota
596	compactContinuationStructuralTail
597	compactContinuationForcedLength
598	compactContinuationGoalCompactionPending
599	compactContinuationGoal
600	compactContinuationStatusRescue
601)
602
603const maxStatusRescueContinuations = 2
604
605const compactHandoffVersion = 1
606
607type compactContinuationState struct {
608	reason         compactContinuationReason
609	rescueAttempts int
610}
611
612type persistedCompactHandoff struct {
613	Version        int    `json:"version"`
614	Reason         string `json:"reason"`
615	RescueAttempts int    `json:"rescue_attempts,omitempty"`
616}
617
618func decodeCompactHandoff(raw json.RawMessage) compactContinuationState {
619	var persisted persistedCompactHandoff
620	if len(raw) == 0 || json.Unmarshal(raw, &persisted) != nil || persisted.Version != compactHandoffVersion {
621		return compactContinuationState{}
622	}
623	switch persisted.Reason {
624	case "structural_tail":
625		if persisted.RescueAttempts == 0 {
626			return compactContinuationState{reason: compactContinuationStructuralTail}
627		}
628	case "forced_length":
629		if persisted.RescueAttempts == 0 {
630			return compactContinuationState{reason: compactContinuationForcedLength}
631		}
632	case "goal_compaction_pending":
633		if persisted.RescueAttempts == 0 {
634			return compactContinuationState{reason: compactContinuationGoalCompactionPending}
635		}
636	case "goal":
637		if persisted.RescueAttempts == 0 {
638			return compactContinuationState{reason: compactContinuationGoal}
639		}
640	case "status_rescue":
641		if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {
642			return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}
643		}
644	}
645	return compactContinuationState{}
646}
647
648func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
649	var reason string
650	switch state.reason {
651	case compactContinuationStructuralTail:
652		reason = "structural_tail"
653	case compactContinuationForcedLength:
654		reason = "forced_length"
655	case compactContinuationGoalCompactionPending:
656		reason = "goal_compaction_pending"
657	case compactContinuationGoal:
658		reason = "goal"
659	case compactContinuationStatusRescue:
660		reason = "status_rescue"
661	default:
662		return nil
663	}
664	encoded, err := json.Marshal(persistedCompactHandoff{
665		Version:        compactHandoffVersion,
666		Reason:         reason,
667		RescueAttempts: state.rescueAttempts,
668	})
669	if err != nil {
670		return nil
671	}
672	return encoded
673}
674
675type scheduledFollowUp struct {
676	text     string
677	accepted chan error
678}
679
680type Interactive struct {
681	cfg  InteractiveConfig
682	view *tui.View
683
684	terminalProfile   tui.TerminalProfile
685	activeThemeSource *tui.ThemeSource
686	themeWatchCancel  context.CancelFunc
687	themeWatchEvents  chan themeWatchEvent
688	themeWatchWarning string
689	ed                *tui.Editor
690	rend              *tui.Renderer
691
692	mu    sync.Mutex
693	agent *core.Agent
694	// agentMu serializes live registry replacement with side-dialog
695	// snapshots, whose copied-agent constructor reads the registry field.
696	agentMu sync.Mutex
697	// webSearchPolicyGeneration invalidates a resolve that began before a
698	// session policy transition. The final registry commit compares its captured
699	// value while agentMu and mu serialize it with live-agent replacement.
700	webSearchPolicyGeneration atomic.Uint64
701	// managedAutoSubagentsAddenda records exact prompt blocks appended by
702	// auto-subagents. Disable only removes these owned occurrences, leaving
703	// identical text that came from the user's base prompt untouched.
704	managedAutoSubagentsAddenda []string
705	// stream owns the paced live assistant text and decides how much of
706	// the transcript is visible while that text is on screen.
707	stream       streamPresenter
708	streamWake   chan struct{}
709	pendingAlert *extproto.AlertRequest
710	toolCalls    map[string]*tui.ToolCallView
711	toolOrder    []string
712	// toolRenderRevision is monotonic for the lifetime of the interactive
713	// view. Per-call counters would let unrelated tools alias the same rendered
714	// cache frame when they reach the same local revision.
715	toolRenderRevision uint64
716	statusErr          string
717	statusOK           string
718	goalStatus         core.GoalStatus
719	planCurrent        int
720	planTotal          int
721	planSnapshots      map[string]core.PlanUpdate
722	planRevision       uint64
723	goalRun            *goalContinuationRun
724	reloadStatusSeq    uint64
725	extStatuses        map[string]map[string]extensionStatus
726	extWidgets         map[string]map[string]extensionWidget
727	liveBlock          []string // live streaming/tool progress rendered outside scrollback
728	helpBlock          []string // rendered above the chat when /help was typed
729	sessionInfoBlocks  []sessionInfoBlock
730	cumUsage           provider.Usage
731	codexUsage         codexUsageState
732	lastCtxInput       int // input_tokens of the most recent turn — approximates current context size
733	busy               bool
734	ctrlCExit          bool
735	activity           agentActivity
736	pendingIdleWork    []func()
737	dirty              chan struct{}
738	renderScheduler    atomic.Pointer[latestFrameScheduler]
739	renderRevision     atomic.Uint64
740	renderOutsideLock  bool
741	modelRefresh       chan modelRefreshResult
742	modelRefreshing    bool
743	startupPreDone     chan startupPreResult
744	cancelTurn         context.CancelFunc
745	scrollOffset       int // rows from the bottom; 0 = pinned to latest
746	prevScrollOffset   int // last value redraw snapped against; tracks intent
747
748	// prevChatLen and prevChatCols track the chat buffer's size at the
749	// last redraw so that when content grows below the user's viewport
750	// while they're scrolled up reading history, we can bump
751	// scrollOffset by exactly the growth and keep the visible content
752	// pinned. Without this, every streamed line shifts the visible
753	// window down through the buffer (because scrollOffset is measured
754	// from the bottom) and the user's reading position drifts upward
755	// and off the top.
756	prevChatLen     int
757	prevChatCols    int
758	prevChatRows    int
759	prevOverlayOpen bool
760	cursorDimmed    bool
761
762	// chatCache stores the built transcript/status-note rows for idle
763	// frames. Editor typing changes only the bottom input region, so
764	// reusing this cache avoids copying/walking/reassembling a long
765	// session on every keypress.
766	chatCache      []string
767	chatCacheKey   chatCacheKey
768	chatCacheValid bool
769
770	// stableChatCache contains only transcript/startup rows. Live streaming,
771	// tool overlays, and errors are appended separately so spinner/progress
772	// frames can reuse finalized chat while a turn is busy.
773	stableChatCache      []string
774	stableChatCacheKey   chatCacheKey
775	stableChatCacheValid bool
776
777	// Messages typed while a turn is in flight. Each is delivered as
778	// its own follow-up turn once the current one finishes. Rendered
779	// above the status bar as "sliding in: ..." chips.
780	queued []core.QueuedMessage
781
782	// scheduled holds prompt-only follow-ups injected by the in-process
783	// scheduler. Unlike agent.QueueMessage, these always wait until the
784	// current turn has completed before starting their own turn.
785	scheduled []scheduledFollowUp
786
787	// runCtx is the top-level context passed to Run(). Follow-up turns
788	// drained from `queued` are started against this context so they
789	// survive past the ctx of the key event that enqueued them.
790	runCtx context.Context
791
792	// pendingPostCompactNote is a status_ok message to surface after
793	// a successful auto-compact pass triggered by a 413 or by the
794	// pre-turn fraction guard. Cleared by runCompact once shown.
795	pendingPostCompactNote string
796
797	// A pre-turn compaction must preserve the complete pending request,
798	// including images. Overflow recovery instead continues the user message
799	// already appended to the agent transcript, avoiding a duplicate message.
800	pendingCompactPrompt    string
801	pendingCompactImages    []provider.ImageBlock
802	hasPendingCompactPrompt bool
803	continueAfterCompact    bool
804
805	// compacting is true for both manual and automatic compaction. Prompts
806	// submitted while it is set stay in the host queue because Compact does
807	// not run the agent loop that drains the agent-owned queue.
808	compacting bool
809
810	// autoCompacting is true while a model-triggered compaction is in
811	// flight. Surfaced in the status bar so the user can tell a
812	// condense pass from a regular assistant turn.
813	autoCompacting bool
814
815	// compactContinuation records the private reason and bounded rescue
816	// budget for the live compaction handoff. It is never persisted or
817	// inferred from rendered transcript rows.
818	compactContinuation compactContinuationState
819
820	// interruptedGoalReturn records an active goal whose running turn was
821	// cancelled by Esc. It is transient controller state, never persisted:
822	// the goal itself stays active and the next ordinary user turn owns a
823	// single automatic return to the same goal after it completes cleanly.
824	interruptedGoalReturn *interruptedGoalState
825
826	// updateInfo is the result of the async update check. Zero value
827	// while the check hasn't completed or nothing is available.
828	updateInfo UpdateInfo
829
830	dialog                  *loginDialog
831	modelDialog             *modelDialog
832	llamaDialog             *llamaDialog
833	rescueDialog            *rescueDialog
834	sessionDialog           *sessionDialog
835	residentSubagentsDialog *residentSubagentsDialog
836	residentChildSession    *residentChildSession
837	residentChildMouse      bool
838	jumpDialog              *jumpDialog
839	btwDialog               *btwDialog
840	skillsDialog            *skillsDialog
841	changelogDialog         *changelogDialog
842	confirmDialog           *confirmDialog
843	logoutDialog            *logoutDialog
844	telegramDialog          *telegramDialog
845	settingsDialog          *settingsDialog
846	floatingPane            tui.FloatingPane
847	quickModelAssign        int
848	quickModelActivate      bool // picker was opened by an empty-slot shortcut
849	telegramBridge          *telegram.Bridge
850	sessionOpsDialog        *sessionOpsDialog
851	sessionTreeDialog       *sessionTreeDialog
852	extPanel                *extPanelDialog
853	llamaConfigured         bool
854
855	// completionTracker collects resident child terminal turns for delivery to
856	// the parent orchestration wave.
857	// turnCoordinator seals worker waves at manager-turn boundaries and decides
858	// the next wake without relying on timing or polling.
859	completionTracker         *subagents.CompletionTracker
860	turnCoordinator           *orchestration.Coordinator
861	coordinatorWorkerIDs      map[string][]string
862	coordinatorWorkerSeq      uint64
863	completionDeliveryMu      sync.Mutex
864	completionDeliveryRunning bool
865	completionDeliveryRequest bool
866	completionDeliveryHolds   int
867	// completionDeliveryGeneration identifies the current hold wave. It is
868	// bumped whenever a new wave starts (a first hold, an implicit one-worker
869	// wave, or a coordinator replacement after cancellation), so a release can
870	// tell whether a newer wave replaced the one it drained while it sampled
871	// goal state.
872	completionDeliveryGeneration uint64
873
874	// pendingFork is true when the user ran /session fork: the next
875	// jump-picker selection should branch off that message instead
876	// of scrolling. Flag resets after the action fires or the dialog
877	// is dismissed, so repeated /jump calls don't turn into forks.
878	pendingFork       bool
879	suggest           *slashSuggester
880	fileSuggest       *fileSuggester
881	spin              *spinner
882	residentAnimating atomic.Bool
883
884	// parkedTurn is the 1-based turn number the viewport is currently
885	// scrolled to by /jump. 0 = not parked, showing the tail as usual.
886	// Rendered as a muted footer at the bottom of the chat so users
887	// don't forget they're looking at history.
888	parkedTurn  int
889	parkedTotal int
890
891	// inputHistoryIndex is -1 when not browsing history. When the
892	// editor is empty, Up/Down can walk previous user prompts without
893	// stealing normal vertical cursor movement in non-empty input.
894	inputHistoryIndex int
895
896	// lastCtrlC is when the user last pressed ctrl+c. The first press
897	// clears the editor / cancels a turn / shows a hint; a second press
898	// within ctrlCExitWindow exits. Mirrors the python-repl convention.
899	lastCtrlC time.Time
900
901	// doubleEscape tracks the optional bare-Escape session-tree gesture.
902	// clock is injectable so the gesture can be tested without sleeping.
903	doubleEscape doubleEscapeTracker
904	clock        func() time.Time
905
906	// welcomeStart is when the interactive run began. The welcome
907	// banner shows the binary version for welcomeVersionDuration
908	// after this point and reverts to plain text after.
909	welcomeStart time.Time
910
911	// interactiveStarted gates session-title generation so tests and
912	// embedders that call Submit before Run do not issue a hidden request.
913	interactiveStarted bool
914	// titleRealPromptSeen prevents a fresh session from generating more than
915	// one title. It is reset only when a fork or a new cwd starts a new branch.
916	titleRealPromptSeen    bool
917	titleGenerationStarted bool
918	firstRealPrompt        string
919	titleVersion           uint64
920	titleCancel            context.CancelFunc
921	sessionTitle           string
922
923	// extNotes are one-shot styled lines pushed by extensions via
924	// Notify / Display. They live above the editor (just below the
925	// transcript) until cleared by /clear or another reset.
926	extNotes []string
927
928	// reloadErrors are host-only extension reload failures. They stay in
929	// the scrolling chat until /clear without entering the agent transcript.
930	reloadErrors []string
931
932	// shellRunning is true while a !command is executing. It shares
933	// i.busy/i.cancelTurn so esc cancels it and no turn or other shell
934	// escape can start while one is in flight.
935	shellRunning bool
936	// shellLive is the accumulating stdout/stderr of the in-flight
937	// shell escape, updated via BashTool progress for live rendering.
938	shellLive string
939
940	// awaitingStartupPre is true while the zutfile entry.pre auto-submit
941	// is in flight. When it clears, deferredInitialInput is applied.
942	awaitingStartupPre   bool
943	deferredInitialInput string
944	autoSubmitDeferred   bool
945
946	// sessionLoading is true while a /sessions selection is being read
947	// on a background goroutine. Keeping this off the input goroutine
948	// lets ctrl+c/exit remain responsive for very large JSONL sessions.
949	sessionLoading bool
950
951	// sessionLoads carries per-entry results for the /sessions picker. The
952	// picker owns the load lifecycle; the interactive loop applies results so
953	// rendering never races with background file reads.
954	sessionLoads <-chan sessionLoadEvent
955	// sessionSearches carries the one-time corpus read and query-match results.
956	// It stays separate from summary loading so a query edit never reopens JSONL.
957	sessionSearches <-chan sessionSearchEvent
958
959	// sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
960	// picker, the main loop is the sole owner of dialog mutation and rendering.
961	sessionTreeLoads <-chan sessionTreeLoadEvent
962
963	// pendingRescuePrompt / pendingRescueImages stash the prompt and
964	// images that should be re-run after the user picks a model in
965	// the rescue dialog. Cleared once applyRescueSelection consumes
966	// them (or when the dialog is dismissed via esc).
967	pendingRescuePrompt string
968	pendingRescueImages []provider.ImageBlock
969
970	clipboardImages []clipboardImageAttachment
971}
972
973// welcomeVersionDuration is how long the welcome banner shows the
974// version suffix before reverting to the plain headline. 1.5s is
975// enough to read at a glance and keeps the splash short.
976const welcomeVersionDuration = 1500 * time.Millisecond
977
978// initialResumeTailLimit caps how many messages from a freshly-resumed
979// transcript we render on the first paint. The full transcript is
980// still in memory; older messages are rendered (and their cached
981// lines kept for the lifetime of the View) as soon as the user
982// scrolls past the rendered tail. Picked to comfortably fill the
983// largest realistic terminal viewport while keeping first paint
984// snappy on multi-thousand-message sessions where markdown / syntax
985// highlighting dominates the redraw cost.
986const initialResumeTailLimit = 80
987
988// resumeTailExpandStep is how many additional messages the tail
989// limit grows by each time the user scrolls past the currently
990// rendered top. Pre-rendering this many messages on a single tick
991// keeps scroll-up smooth without falling back to a one-by-one
992// reveal that would feel jerky.
993const resumeTailExpandStep = 80
994
995type startupPreResult struct {
996	deferred   string
997	autoSubmit bool
998}
999
1000// NewInteractive constructs an Interactive from cfg.
1001func NewInteractive(cfg InteractiveConfig) *Interactive {
1002	if cfg.ActiveModelProfile < 1 || cfg.ActiveModelProfile > 9 {
1003		cfg.ActiveModelProfile = 1
1004	}
1005	// An unassigned default remains active so subsequent model/reasoning
1006	// edits save into slot 1. Explicit overrides must not overwrite a favorite.
1007	if slot := cfg.ActiveModelProfile; slot <= len(cfg.QuickModelShortcuts) {
1008		if p := cfg.QuickModelShortcuts[slot-1]; p.Provider != "" || p.Model != "" {
1009			cfg.detachMismatchedModelProfile()
1010		}
1011	}
1012	if cfg.SettingsStore != nil {
1013		if _, ok := cfg.SettingsStore.(modelProfileSettingsStore); !ok {
1014			cfg.ActiveModelProfile = 0
1015		}
1016	}
1017	renderer := tui.NewRenderer(cfg.Terminal)
1018	renderer.SetTheme(cfg.Theme)
1019	startupAgentName := ""
1020	startupContextPaths := []string(nil)
1021	startupExtensionNames := []string(nil)
1022	startupSkillNames := []string(nil)
1023	initialGoalStatus := core.GoalStatus("")
1024	if cfg.CurrentGoal != nil {
1025		if goal := cfg.CurrentGoal(); goal != nil {
1026			initialGoalStatus = goal.Status
1027		}
1028	}
1029	if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
1030		startupAgentName = cfg.StartupAgentName
1031		startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
1032		startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
1033		startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
1034	}
1035	profile := cfg.TerminalProfile
1036	if profile == (tui.TerminalProfile{}) {
1037		profile = cfg.Theme.Terminal
1038	}
1039	preference := cfg.EffectiveThemeName
1040	if preference == "" {
1041		preference = cfg.ThemeName
1042	}
1043	if preference == "" {
1044		preference = "auto"
1045	}
1046	source, sourceErr := tui.LoadThemeSource(cfg.ZutHome, preference)
1047	// Embedders historically supplied a ready-made Theme without a selection.
1048	// Preserve that explicit construction; CLI startup always supplies an
1049	// explicit effective preference and therefore uses the pure resolver.
1050	implicitReadyTheme := cfg.ThemeName == "" && cfg.EffectiveThemeName == "" && len(cfg.Theme.SpinnerFrames) != 0
1051	// Source loading is only a startup optimization. Invalid selected files are
1052	// handled by the existing invalid-selection path in the main loop.
1053	if sourceErr == nil && !implicitReadyTheme {
1054		cfg.Theme = tui.ResolveTheme(preference, source, profile).Theme
1055	}
1056	cfg.TerminalProfile = profile
1057	cfg.EffectiveThemeName = preference
1058	i := &Interactive{
1059		cfg:               cfg,
1060		terminalProfile:   profile,
1061		activeThemeSource: source,
1062		themeWatchEvents:  make(chan themeWatchEvent, 8),
1063		view: &tui.View{
1064			Theme:                 cfg.Theme,
1065			ImageProto:            effectiveImageProtocol(cfg.InlineImagesEnabled),
1066			FlatTools:             cfg.FlatTools,
1067			CompactUser:           cfg.CompactUser,
1068			CompactMode:           cfg.CompactMode != nil && *cfg.CompactMode,
1069			StartupAgentName:      startupAgentName,
1070			StartupContextPaths:   startupContextPaths,
1071			StartupExtensionNames: startupExtensionNames,
1072			StartupSkillNames:     startupSkillNames,
1073		},
1074		// Prompt is the standard half-block accent bar used by chat
1075		// speaker labels too, so the input gutter matches the rest
1076		// of the UI.
1077		ed:                      tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
1078		rend:                    renderer,
1079		toolCalls:               map[string]*tui.ToolCallView{},
1080		streamWake:              make(chan struct{}, 1),
1081		dirty:                   make(chan struct{}, 8),
1082		modelRefresh:            make(chan modelRefreshResult, 1),
1083		startupPreDone:          make(chan startupPreResult, 1),
1084		dialog:                  newLoginDialog(),
1085		modelDialog:             newModelDialog(),
1086		llamaDialog:             newLlamaDialog(),
1087		rescueDialog:            newRescueDialog(),
1088		sessionDialog:           newSessionDialog(),
1089		residentSubagentsDialog: newResidentSubagentsDialog(),
1090		jumpDialog:              newJumpDialog(),
1091		btwDialog:               newBtwDialog(),
1092		skillsDialog:            newSkillsDialog(),
1093		changelogDialog:         newChangelogDialog(),
1094		confirmDialog:           newConfirmDialog(),
1095		logoutDialog:            newLogoutDialog(),
1096		telegramDialog:          newTelegramDialog(),
1097		settingsDialog:          newSettingsDialog(),
1098		sessionOpsDialog:        newSessionOpsDialog(),
1099		sessionTreeDialog:       newSessionTreeDialog(),
1100		extPanel:                newExtPanelDialog(),
1101		extStatuses:             map[string]map[string]extensionStatus{},
1102		extWidgets:              map[string]map[string]extensionWidget{},
1103		suggest:                 newSlashSuggester(),
1104		fileSuggest:             newFileSuggester(),
1105		spin:                    newSpinner(cfg.Theme),
1106		inputHistoryIndex:       -1,
1107		clock:                   time.Now,
1108		goalStatus:              initialGoalStatus,
1109		reloadErrors:            append([]string(nil), cfg.StartupExtensionErrors...),
1110		compactContinuation:     decodeCompactHandoff(cfg.InitialCompactHandoff),
1111	}
1112	i.btwDialog.setCloseHook(func() {
1113		i.confirmDialog.CancelChildConfirmations("side chat closed")
1114	})
1115	i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
1116	i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
1117	if cfg.LlamaCPPConfig != nil {
1118		baseURL, _, err := cfg.LlamaCPPConfig()
1119		i.llamaConfigured = err == nil && baseURL != ""
1120	}
1121	i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg, cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled)
1122	if cfg.ResidentManager != nil {
1123		// Resident updates arrive from the child control goroutine. The
1124		// renderer reads its immutable live projection on the next throttled
1125		// frame, so this callback must only request that frame.
1126		cfg.ResidentManager.SetUpdateObserver(func(string) { i.invalidate() })
1127		cfg.ResidentManager.SetHistoryUpdateObserver(func(childID string) {
1128			go i.reloadOpenResidentChildSession(childID)
1129		})
1130		cfg.ResidentManager.SetActivityObserver(func(active bool) {
1131			i.residentAnimating.Store(active)
1132		})
1133	}
1134	if cfg.Agent != nil {
1135		i.agent = cfg.Agent
1136		i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
1137		i.cumUsage = cfg.Agent.Cost()
1138		// Rehydrate the "context used" gauge from the last persisted
1139		// turn. Without this the status bar reads 0.0% after a resume
1140		// until the next turn lands a usage event.
1141		if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1142			i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1143		}
1144		// Cap the first paint at the tail of the transcript so
1145		// resuming a multi-thousand-message session doesn't block
1146		// on rendering every prior turn before showing anything.
1147		if len(i.view.Messages) > initialResumeTailLimit {
1148			i.view.TailLimit = initialResumeTailLimit
1149		}
1150		i.seedPlanState()
1151	}
1152	i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle)
1153	if cfg.InitialSessionTitlePending {
1154		i.sessionTitle = ""
1155		i.titleRealPromptSeen = false
1156		i.titleGenerationStarted = false
1157	} else {
1158		i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
1159		i.titleGenerationStarted = i.titleRealPromptSeen
1160	}
1161	i.applyAutoSubagentsTool()
1162	i.recoverGoalRun()
1163	return i
1164}
1165
1166// ExitedViaCtrlC reports whether the last Run returned because the user
1167// deliberately exited with Ctrl+C.
1168func (i *Interactive) ExitedViaCtrlC() bool {
1169	i.mu.Lock()
1170	defer i.mu.Unlock()
1171	return i.ctrlCExit
1172}
1173
1174// markCtrlCExit records that the current Run is ending via Ctrl+C.
1175func (i *Interactive) markCtrlCExit() {
1176	i.mu.Lock()
1177	i.ctrlCExit = true
1178	i.mu.Unlock()
1179}
1180
1181// setResidentChildMouseReporting enables mouse reporting only while the
1182// transcript overlay owns scrolling. Normal chat keeps native terminal
1183// selection and wheel scrolling intact.
1184func (i *Interactive) setResidentChildMouseReporting(enabled bool) {
1185	i.mu.Lock()
1186	if i.residentChildMouse == enabled {
1187		i.mu.Unlock()
1188		return
1189	}
1190	i.residentChildMouse = enabled
1191	term, running := i.cfg.Terminal, i.runCtx != nil
1192	i.mu.Unlock()
1193	if term == nil || !running {
1194		return
1195	}
1196	seq := tui.SeqMouseOff
1197	if enabled {
1198		seq = tui.SeqMouseOn
1199	}
1200	_, _ = term.Write([]byte(seq))
1201}
1202
1203// Run blocks until the user quits.
1204func (i *Interactive) Run(ctx context.Context) error {
1205	i.mu.Lock()
1206	i.runCtx = ctx
1207	i.mu.Unlock()
1208	i.restartThemeWatch()
1209	defer i.stopThemeWatch()
1210	term := i.cfg.Terminal
1211	restore, err := term.EnterRaw()
1212	if err != nil {
1213		return err
1214	}
1215	defer restore()
1216	i.markInteractiveStarted()
1217	defer i.cancelSessionTitle()
1218	defer i.sessionDialog.Close()
1219	defer func() {
1220		if i.telegramBridge != nil {
1221			i.telegramBridge.Stop()
1222		}
1223	}()
1224
1225	// Enabling mouse reporting steals click-drag selection from the
1226	// host terminal (VS Code, Ghostty, iTerm). The user prefers native
1227	// selection over the wheel-speed boost, so we no longer turn it
1228	// on automatically. Wheel events fall through to the terminal's
1229	// own scrollback handler.
1230	// Keep zut on the terminal's main screen. We intentionally do not
1231	// enter the alternate-screen buffer (CSI ?1049h). The renderer emits
1232	// chat as normal terminal flow/scrollback and redraws only the live
1233	// input/status block on normal typing.
1234	_, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
1235	// Tell the terminal our working directory (OSC 7) so "new tab /
1236	// split here" opens in the launch cwd instead of inheriting a stale
1237	// directory from an extension subprocess (see issue #38). Harmless
1238	// on terminals that ignore it.
1239	if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
1240		_, _ = term.Write([]byte(seq))
1241	}
1242	// Erase the live frame and place the cursor deterministically before any
1243	// exit summary or shell prompt is written. Do not erase scrollback: users
1244	// should still be able to review the session after closing zut.
1245	mode2031Owned := false
1246	defer func() {
1247		cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
1248		if mode2031Owned {
1249			cleanup = tui.SeqDisableMode2031 + cleanup
1250		}
1251		_, _ = term.Write([]byte(cleanup))
1252	}()
1253	i.applyInputCursorColor()
1254
1255	// Streaming pacer: drains buffered text deltas at a steady rate
1256	// so typewriter feel is identical across providers regardless of
1257	// upstream chunk size. Starts here so it lives for the whole
1258	// session and exits with ctx.
1259	go i.runStreamPacer(ctx)
1260
1261	cols, rows := term.Size()
1262	i.rend.Resize(cols, rows)
1263	renderScheduler := newLatestFrameScheduler()
1264	i.renderScheduler.Store(renderScheduler)
1265	go renderScheduler.run(func(req renderRequest) {
1266		c, r := term.Size()
1267		i.rend.Resize(c, r)
1268		if req.theme != nil {
1269			i.rend.SetTheme(*req.theme)
1270		}
1271		if req.clear {
1272			i.rend.Clear()
1273		}
1274		if req.invalidate {
1275			i.rend.Invalidate()
1276		}
1277		i.redraw()
1278	})
1279	defer renderScheduler.stop()
1280	term.OnResize(func() {
1281		// Resize and redraw share the renderer owner. The owner reads the
1282		// latest terminal size just before its next frame.
1283		i.renderRevision.Add(1)
1284		renderScheduler.request(false, false)
1285	})
1286
1287	if i.cfg.StartupPre != "" {
1288		i.deferredInitialInput = i.cfg.InitialInput
1289		i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
1290		i.awaitingStartupPre = true
1291		i.Submit(i.cfg.StartupPre)
1292	} else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
1293		i.Submit(i.cfg.InitialInput)
1294	} else {
1295		if i.cfg.InitialInput != "" {
1296			i.ed.SetValue(i.cfg.InitialInput)
1297		}
1298		i.startRestoredCompactHandoff(ctx)
1299	}
1300
1301	// Stamp the welcome time and schedule a one-shot redraw at the
1302	// expiry so the version suffix disappears on its own even if the
1303	// user hasn't typed anything yet.
1304	i.welcomeStart = time.Now()
1305	time.AfterFunc(welcomeVersionDuration, i.invalidate)
1306
1307	// If the agent was constructed with a pre-loaded transcript
1308	// (--continue, --resume, --session) pin the viewport at the
1309	// bottom so the most recent reply (and any prompt the user just
1310	// typed) is fully visible. Earlier behaviour parked the view at
1311	// the last user turn, which could leave the latest message clipped
1312	// off the bottom of the page on long sessions.
1313	if i.agent != nil {
1314		if msgs := i.agent.Messages(); len(msgs) > 0 {
1315			i.scrollToBottom()
1316		}
1317	}
1318
1319	// No credential at startup? Auto-open the login dialog, and mark
1320	// the status line. The user can Esc out of the dialog if they
1321	// want to dismiss it (e.g. to check /help or /exit first).
1322	if i.agent == nil {
1323		i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
1324		i.dialog.Open(i.cfg.ZutHome)
1325	}
1326
1327	// The appearance-aware source is the only stdin reader. It consumes only
1328	// replies for queries currently in flight, keeps bracketed-paste payload
1329	// opaque, and sends every other byte through the established key parser.
1330	keys := make(chan tui.Key, 256)
1331	appearanceEvents := make(chan tui.InputEvent, 256)
1332	appearanceParser := &tui.AppearanceParser{}
1333	appearanceParser.SetPendingColors(true)
1334	appearanceParser.SetPendingScheme(true)
1335	inputDone := make(chan struct{})
1336	go func() {
1337		defer close(inputDone)
1338		source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) {
1339			select {
1340			case appearanceEvents <- event:
1341			case <-ctx.Done():
1342			}
1343		})
1344		reader := tui.NewReader(source.ReadByte)
1345		for {
1346			k, err := reader.Read()
1347			if err != nil {
1348				return
1349			}
1350			select {
1351			case keys <- k:
1352			case <-ctx.Done():
1353				return
1354			}
1355		}
1356	}()
1357	// Probe before mutating mode 2031. Replies are handled in the main loop so
1358	// ownership and profile publication stay serialized with rendering.
1359	_, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery()))
1360	defer func() {
1361		// Test terminals and normal EOF join immediately. A physical tty read
1362		// may be uncancellable on some platforms, so shutdown never waits
1363		// indefinitely for a kernel read that cannot be interrupted.
1364		select {
1365		case <-inputDone:
1366		case <-time.After(50 * time.Millisecond):
1367		}
1368	}()
1369
1370	// Subscribe to auth events.
1371	var authEvents <-chan auth.Event
1372	if i.cfg.AuthManager != nil {
1373		authEvents = i.cfg.AuthManager.Events()
1374	}
1375
1376	// Animation ticker: drives spinner and dialog-related redraws when
1377	// nothing else changed. 120ms is slow enough that highlighting a huge
1378	// transcript doesn't spin the cpu.
1379	tick := time.NewTicker(120 * time.Millisecond)
1380	defer tick.Stop()
1381	i.refreshCodexUsage(ctx, time.Now())
1382	defer i.resetCodexUsage()
1383
1384	// Redraw throttle: coalesce bursts of invalidate() calls so we paint
1385	// at most once every redrawMinInterval. Huge tool-result dumps can
1386	// fire hundreds of invalidations while the user is typing; without
1387	// this, the input goroutine never gets CPU and keystrokes lag.
1388	const redrawMinInterval = 16 * time.Millisecond
1389	var lastRedraw time.Time
1390	var pendingRedraw bool
1391	var pendingTimer *time.Timer
1392
1393	drainPending := func() {
1394		if pendingTimer != nil {
1395			pendingTimer.Stop()
1396			pendingTimer = nil
1397		}
1398		if pendingRedraw {
1399			pendingRedraw = false
1400			lastRedraw = time.Now()
1401			renderScheduler.request(false, false)
1402		}
1403	}
1404
1405	requestRedraw := func() {
1406		since := time.Since(lastRedraw)
1407		if since >= redrawMinInterval {
1408			// Redrawing right now subsumes any pending redraw, so clear
1409			// the throttle state. Without this, a pending flag stays
1410			// stuck at true and subsequent invalidate() calls within
1411			// redrawMinInterval get dropped — which is exactly how the
1412			// final "turn finished" frame went missing until the user
1413			// nudged the ui by typing or scrolling.
1414			if pendingTimer != nil {
1415				pendingTimer.Stop()
1416			}
1417			pendingRedraw = false
1418			lastRedraw = time.Now()
1419			renderScheduler.request(false, false)
1420			return
1421		}
1422		if pendingRedraw {
1423			return // already scheduled
1424		}
1425		pendingRedraw = true
1426		wait := redrawMinInterval - since
1427		if pendingTimer == nil {
1428			pendingTimer = time.AfterFunc(wait, func() {
1429				// Poke the dirty channel so the main loop wakes and
1430				// drains the pending redraw on its own goroutine. We
1431				// can't call drainPending here directly — it touches
1432				// closure state shared with the main loop.
1433				i.invalidate()
1434			})
1435		} else {
1436			pendingTimer.Reset(wait)
1437		}
1438	}
1439
1440	i.invalidate()
1441
1442	updates := i.cfg.UpdateInfoChan  // nil-safe; nil channel blocks forever in select
1443	changelog := i.cfg.ChangelogChan // single-shot, see case below
1444	profileCandidate := i.terminalProfile
1445	profileDirty := false
1446	appearanceDeadline := time.Now().Add(500 * time.Millisecond)
1447	lastAppearancePoll := time.Now()
1448	modeEnableRequested := false
1449	notificationsSupported := false
1450
1451	for {
1452		select {
1453		case <-ctx.Done():
1454			return ctx.Err()
1455		case event := <-i.themeWatchEvents:
1456			if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path {
1457				break
1458			}
1459			switch {
1460			case event.source != nil:
1461				i.activeThemeSource = event.source
1462				i.themeWatchWarning = ""
1463				i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1464				i.mu.Lock()
1465				i.statusOK = "theme reloaded"
1466				i.statusErr = ""
1467				i.mu.Unlock()
1468			case event.deleted:
1469				i.activeThemeSource = nil
1470				i.stopThemeWatch()
1471				i.cfg.ThemeName = ""
1472				i.cfg.EffectiveThemeName = "auto"
1473				if i.cfg.SettingsStore != nil {
1474					_ = i.cfg.SettingsStore.SetTheme("auto")
1475				}
1476				i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1477				i.mu.Lock()
1478				i.statusOK = "theme removed; using auto"
1479				i.statusErr = ""
1480				i.mu.Unlock()
1481			case event.err != nil && event.err.Error() != i.themeWatchWarning:
1482				i.themeWatchWarning = event.err.Error()
1483				i.mu.Lock()
1484				i.statusErr = "theme reload: " + event.err.Error()
1485				i.mu.Unlock()
1486			}
1487			i.invalidate()
1488		case event := <-appearanceEvents:
1489			switch {
1490			case event.Color != nil:
1491				switch event.Color.Kind {
1492				case 10:
1493					profileCandidate.Foreground = event.Color.Color
1494					profileCandidate.HasForeground = true
1495				case 11:
1496					profileCandidate.Background = event.Color.Color
1497					profileCandidate.HasBackground = true
1498				case 4:
1499					profileCandidate.Palette[event.Color.Slot] = event.Color.Color
1500					profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot
1501				}
1502				profileDirty = true
1503			case event.Scheme != nil:
1504				profileCandidate.Light = event.Scheme.Light
1505				profileCandidate.SchemeKnown = true
1506				profileDirty = true
1507				if notificationsSupported && !event.Scheme.Solicited {
1508					// A scheme notification means the terminal defaults and palette
1509					// may have changed too. Refresh the complete profile atomically.
1510					appearanceParser.SetPendingColors(true)
1511					appearanceParser.SetPendingScheme(true)
1512					appearanceDeadline = time.Now().Add(500 * time.Millisecond)
1513					_, _ = term.Write([]byte(tui.AppearanceQuery()))
1514				}
1515			case event.Mode != nil && event.Mode.Mode == 2031:
1516				switch event.Mode.Status {
1517				case 1, 3:
1518					notificationsSupported = true
1519					appearanceParser.SetNotifications(true)
1520					if modeEnableRequested {
1521						mode2031Owned = true
1522					}
1523				case 2:
1524					modeEnableRequested = true
1525					_, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031))
1526				default:
1527					appearanceParser.SetNotifications(false)
1528				}
1529			}
1530			// The parser gate closes after a bounded collection window; accepted
1531			// notifications remain enabled separately once mode 2031 is known.
1532			if profileDirty {
1533				i.invalidate()
1534			}
1535		case k := <-keys:
1536			if done := i.handleKey(ctx, k); done {
1537				return nil
1538			}
1539			// Drain any keystrokes that arrived during this iteration.
1540			// VS Code (and other terminals that don't bracket drops as
1541			// paste) deliver a path one rune at a time — without this
1542			// loop the editor would render between every rune and a
1543			// long path on a heavy transcript would visibly type in.
1544		drain:
1545			for {
1546				select {
1547				case k2 := <-keys:
1548					if done := i.handleKey(ctx, k2); done {
1549						return nil
1550					}
1551				default:
1552					break drain
1553				}
1554			}
1555			i.invalidate()
1556		case ev := <-authEvents:
1557			i.handleAuthEvent(ev)
1558			i.invalidate()
1559		case result := <-i.modelRefresh:
1560			i.mu.Lock()
1561			i.modelRefreshing = false
1562			i.mu.Unlock()
1563			i.openModelPickerAfterRefresh(result.err)
1564			i.invalidate()
1565		case result := <-i.startupPreDone:
1566			i.applyStartupPreResult(result)
1567			i.invalidate()
1568		case info, ok := <-updates:
1569			if ok && info.Available {
1570				i.mu.Lock()
1571				i.updateInfo = info
1572				i.mu.Unlock()
1573				i.invalidate()
1574			}
1575			updates = nil // single-shot; subsequent iterations skip this case
1576		case cl, ok := <-changelog:
1577			if ok && cl.Body != "" {
1578				i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
1579				i.invalidate()
1580			}
1581			changelog = nil // single-shot
1582		case event, ok := <-i.sessionLoads:
1583			i.mu.Lock()
1584			if !ok {
1585				i.sessionLoads = nil
1586				i.sessionDialog.ApplyLoadClosed()
1587				i.mu.Unlock()
1588				i.invalidate()
1589				break
1590			}
1591			i.sessionDialog.ApplyLoad(event)
1592			i.mu.Unlock()
1593			i.invalidate()
1594		case event, ok := <-i.sessionSearches:
1595			i.mu.Lock()
1596			if !ok {
1597				i.sessionSearches = nil
1598				i.mu.Unlock()
1599				break
1600			}
1601			i.sessionDialog.ApplySearch(event)
1602			i.mu.Unlock()
1603			i.invalidate()
1604		case event, ok := <-i.sessionTreeLoads:
1605			i.mu.Lock()
1606			if !ok {
1607				i.sessionTreeLoads = nil
1608				i.sessionTreeDialog.ApplyLoadClosed()
1609				i.mu.Unlock()
1610				i.invalidate()
1611				break
1612			}
1613			if err := i.sessionTreeDialog.ApplyLoad(event); err != nil {
1614				i.sessionTreeLoads = nil
1615				i.statusErr = "tree: no readable session family"
1616				i.statusOK = ""
1617			}
1618			i.mu.Unlock()
1619			i.invalidate()
1620		case <-i.dirty:
1621			requestRedraw()
1622		case <-tick.C:
1623			now := time.Now()
1624			i.refreshCodexUsage(ctx, now)
1625			if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1626				appearanceParser.SetPendingColors(false)
1627				appearanceParser.SetPendingScheme(false)
1628				appearanceDeadline = time.Time{}
1629			}
1630			if profileDirty {
1631				i.applyTerminalProfile(profileCandidate)
1632				profileDirty = false
1633			}
1634			if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
1635				appearanceParser.SetPendingColors(true)
1636				appearanceParser.SetPendingScheme(true)
1637				appearanceDeadline = now.Add(500 * time.Millisecond)
1638				lastAppearancePoll = now
1639				_, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
1640			}
1641			// Always drain a pending redraw on the tick. This is the
1642			// safety net that catches the case where the dirty channel
1643			// was saturated when the final "turn finished" invalidate
1644			// fired, or where the throttle scheduled a deferred redraw
1645			// and the AfterFunc-driven invalidate got dropped on a
1646			// full channel.
1647			drainPending()
1648			// Only force a periodic redraw when something is actually
1649			// animating (the main spinner during a busy turn, or the
1650			// btw side-chat spinner while it's awaiting a response).
1651			// Static pickers (model, completed session, jump, etc.) don't
1652			// need the tick and firing it cancels the terminal's cursor
1653			// blink inside dialogs that host their own editor (btw),
1654			// because each frame re-emits hide-cursor + show-cursor.
1655			// A session picker that is still loading is an exception so
1656			// its dialog-local spinner can advance.
1657			//
1658			i.mu.Lock()
1659			busy := i.busy
1660			sessionLoading := i.sessionDialog.Loading()
1661			sessionSearching := i.sessionDialog.SearchLoading()
1662			i.mu.Unlock()
1663			if busy || sessionLoading || sessionSearching || i.btwDialog.Loading() || i.residentAnimating.Load() {
1664				requestRedraw()
1665			}
1666		}
1667	}
1668}
1669
1670const maxExtensionWidgetRows = 12
1671const maxExtensionStatusRows = 6
1672
1673// sessionsRoot returns the session namespace for this run.
1674
1675// lastCols returns the current terminal width in columns.
1676
1677// chatPage returns the number of chat rows currently visible, used
1678// as the page size for PageUp/PageDown.
1679
1680// scrollBy adjusts the scroll offset. Positive = up (into history).
1681// Clearing the parked-turn label when we're back at the bottom means
1682// the "viewing turn N" footer goes away automatically as soon as you
1683// scroll back to the live tail.
1684
1685// anchorScrollOffset keeps the user's reading position pinned when the
1686// chat buffer grows/shrinks or the viewport height changes between two
1687// redraws while they're scrolled up.
1688//
1689// scrollOffset is measured from the bottom of the chat buffer, so the
1690// top visible row is start = chatLen - scrollOffset - chatRows. To hold
1691// `start` constant we adjust the offset by the buffer-length delta minus
1692// the viewport-height delta. The result is clamped to [0, newLen] so a
1693// shrinking buffer can't push it negative.
1694
1695// scrollToBottom pins the view to the latest content.
1696
1697// snapViewportStartToImageBlock treats inline images as atomic blocks for
1698// scrolling. Terminal image protocols draw from a single escape row into a
1699// separate graphics layer; the following blank rows are only zut's reserved
1700// footprint. If the viewport starts on one of those blank rows, there is no
1701// correct partial-image state to render. Snap back to the escape row instead
1702// so the image is either shown from its beginning or skipped entirely.
1703
1704const (
1705	hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1706	autoCompactContinueMetaKey    = "auto_compact_continue"
1707	shellEscapeMetaKey            = "shell_escape"
1708)
1709
1710// isBoxBlankLine reports whether line is visually empty after
1711// stripping ANSI escape sequences, surrounding whitespace, and the
1712// vertical box edges drawn by the tool-box renderer. Used by
1713// clipBottomClippedImages so an image's reservation rows still count
1714// as blank when those rows are wrapped in "│  ...  │" inside a tool box.
1715
1716// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1717// byte) from s without pulling in the regexp package. Mirrors the
1718// internal helper in package tui; the duplicated copy avoids exporting
1719// it just for one caller.
1720
1721// truncateLine shortens s so it fits within n display cells, with an
1722// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1723// novel doesn't blow past the status line.
1724
1725// ctrlCExitWindow is how long after a ctrl+c press a *second* press
1726// will exit instead of just clearing input. Long enough to be
1727// deliberate (rules out accidental key chord), short enough that the
1728// hint stays meaningful.
1729const ctrlCExitWindow = 2 * time.Second
1730
1731// armCtrlCExit records the timestamp of the current ctrl+c so the next
1732// one within ctrlCExitWindow exits.
1733
1734// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
1735// that another press should now exit.
1736
1737// clearFileSuggestQuery strips the filter the user typed after the
1738// last "@", leaving the bare "@" so the picker stays open. Called when
1739// navigating between directory levels (Right/Left): the filter applied
1740// to the level the user was on, not the one being entered, so carrying
1741// it forward would wrongly hide the new directory's contents.
1742
1743// setToolExpansion updates long tool result expansion and replays the
1744// transcript so already-emitted rows reflect the new state.
1745
1746// confirmChildActive reports whether an interaction opened from slash input
1747// currently owns the keyboard while a tool confirmation remains pending.
1748
1749// restoreConfirmFocus returns input to confirmation after slash input is
1750// cleared or a child interaction closes. A non-empty editor keeps command
1751// input focused, and an active child continues to own its keys.
1752
1753// invokeExtensionCommand fires an extension-registered slash command
1754// in a background goroutine, awaits the response, and applies the
1755// requested action (prompt / insert / display / noop). Errors and
1756// timeouts surface as a status_err line.
1757
1758// appendExtensionNote renders an extension-originated note in the
1759// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1760// "success" (tool/ok green).
1761
1762// HostHooks implementation for the extension manager. The manager
1763// holds an interface, not a concrete *Interactive, so these methods
1764// are the only thing the manager sees.
1765
1766// ApplySessionAgent swaps the live agent after a validated session resume.
1767// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
1768// context; the session loader is changing the transcript/provider only.
1769
1770// SetSubagentSessionScope redraws after a host session transition. Resident
1771// children retain their explicit parent session in their durable journal.
1772
1773// ApplyChangedCWD is called by hosts after a successful /cd hook that do
1774// not provide startup context metadata.
1775
1776// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
1777// startup context into the running TUI after a successful /cd hook.
1778
1779// SubmitSlash runs text as a slash command in the TUI as if the user
1780// had typed it. text must start with '/' — callers that hand it
1781// plain prose silently get a no-op so a misbehaving extension can't
1782// run a stray prompt through this path. Read-only commands run in
1783// place; commands that would mutate the transcript or replace the
1784// agent cancel the active turn first via the same path the editor
1785// uses for typed slash commands.
1786
1787// SubmitOrQueue runs a text-and-image prompt immediately if the agent is idle,
1788// or appends it to the pending queue if a turn is already in flight. Used by
1789// the Telegram bridge and editor submit path so both input sources share the
1790// same queue semantics.
1791
1792// CancelTurn aborts the active turn if one is running. Used by the
1793// telegram bridge when the paired user sends /stop.
1794// ChangelogVersion returns the version string of the changelog
1795// currently shown (or last shown). Used by the dismiss callback
1796// to store the correct version for dev builds.
1797
1798// Insert places text at the cursor in the editor.
1799
1800// Display appends a styled note from extName to the chat without a
1801// model call.
1802
1803// ReportError surfaces a host-side error in the interactive status area.
1804
1805// SetStatus replaces one persistent status item owned by an extension.
1806
1807// SetWidget replaces one persistent widget owned by an extension.
1808
1809// ClearWidget removes one persistent widget owned by an extension.
1810
1811// ClearExtensionChrome removes every persistent UI item owned by an
1812// extension that has exited. This keeps crashes, disable/reload operations,
1813// and startup failures from leaving stale widgets beside the transcript.
1814
1815const modalBackdropDimPercent = 50
1816
1817// setInputCursorDimmed updates the terminal-owned cursor only when its layer
1818// changes. Reapplying cursor controls on every redraw can reset its blink
1819// timing before the terminal completes a normal blink cycle.
1820
1821// stripWebSearchTool removes the callable web_search entry from the live
1822// registry without asking the resolver to run again. It is the fail-closed
1823// fallback when persistence rollback fails after a refresh failure.
1824
1825// buildStudyPrompt returns the canned prompt the /study command
1826// submits to the agent.
1827//
1828// With no argument, /study targets the current directory — the
1829// historical behaviour. With an argument, /study targets that path
1830// instead; either a directory ("read every file in here") or a
1831// single file ("read this file"). The argument can be:
1832//
1833//   - a relative path (resolved against cwd)
1834//   - an absolute path
1835//   - an @-picker chip, which has already been expanded to an
1836//     absolute path by expandFileChips before runSlash sees it
1837//
1838// The path is stat'd to pick the right wording ("directory" vs
1839// "file"). If the path doesn't exist, we still build a sensible
1840// prompt rather than erroring — the agent will surface the
1841// missing-file failure itself when it tries to read it, which is
1842// more useful than a refusal here.
1843
1844// tryPathTabCompleteEditor looks at ed's current value, finds the
1845// path-like token immediately before the cursor (the cursor is always
1846// at the end of the buffer after a keystroke, so "before the cursor"
1847// is the trailing non-whitespace run), and rewrites it to its shell-
1848// style completion against the filesystem.
1849//
1850// Returns true when it consumed the Tab keystroke (token recognised,
1851// completion attempted — even if no candidates matched, the keystroke
1852// is still consumed so it doesn't insert a literal tab character).
1853// Returns false when the token doesn't look like a path; callers then
1854// let Tab fall through to its normal no-op.
1855//
1856// Recognised path shapes:
1857//   - ~ or ~/foo                  expanded via os.UserHomeDir()
1858//   - /abs/path or /abs/path/foo  absolute
1859//   - ./foo, ../foo, foo/bar      relative to cwd
1860//
1861// A bare word like "hello" is not treated as a path so plain text
1862// keeps Tab as a literal no-op.
1863//
1864// Free function (not a method) so the same logic runs against the
1865// editor instances owned by local dialogs without each
1866// dialog needing its own copy.
1867
1868// tryPathTabComplete is the Interactive-bound convenience wrapper.
1869// It calls the free helper against the main editor and invalidates
1870// the frame on a successful rewrite.
1871
1872// looksLikePathToken reports whether tok is shaped like a filesystem
1873// path. Paths must either start with ~, /, ./, ../ or contain a /.
1874// Plain words are excluded so Tab on "hello" stays a no-op.
1875
1876// resolvePathTabToken splits tok into (absolute parent dir, basename
1877// prefix to match, display-form parent the user typed). ok is false
1878// when the parent dir can't be resolved (e.g. ~ with no $HOME).
1879
1880// splitDirBase is like filepath.Split but preserves the trailing
1881// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
1882// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
1883// the trailing separator when non-empty so callers can rebuild paths
1884// by concatenation.
1885
1886// openLogoutDialog shows the provider picker for `/logout` with no
1887// argument. Only providers the user is currently logged into are
1888// listed, plus an "all" entry when more than one is present. If
1889// nothing's logged in, writes a status line instead of opening an
1890// empty dialog.
1891
1892// doLogout clears credentials for the given provider (or all providers)
1893// from auth.json. If the active agent was using those credentials, it
1894// is torn down so the user is forced through /login before their next
1895// prompt.
1896//
1897// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
1898
1899// applyModelSelection switches the active model (and provider, if the
1900// new model belongs to a different one). It rebuilds the underlying
1901// client when needed so the provider wire-protocol matches.
1902// cancelAndWaitForIdle cancels the active turn (if any) and blocks
1903// briefly until the turn goroutine has updated i.busy = false. Used
1904// before destructive slash commands so transcript-mutating work
1905// (/clear, /compact, /logout, /login completion, cross-provider
1906// /model swap) doesn't race with the still-running stream.
1907//
1908// The wait is bounded; if the turn doesn't release within the timeout
1909// we proceed anyway. Worst case is a brief overlap that the agent's
1910// own mutex protects against.
1911
1912// openBtwDialog opens the side-chat overlay with a frozen snapshot
1913// of the current main session. The optional argument is auto-
1914// submitted as the first question, so '/btw does X work?' fires the
1915// model call immediately instead of just opening an empty dialog.
1916
1917// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
1918// or queues it behind the active turn.
1919
1920// openSkillsDialog opens the skill inspector. The picker reflects
1921// whatever SkillSnapshot returns at call time, so edits to a
1922// SKILL.md made during a session show up on the next /skills.
1923
1924// openJumpDialog builds a /jump picker from the current transcript.
1925// If the user typed "/jump foo" with a filter and it matches exactly
1926// one turn, jump there directly without showing the dialog.
1927
1928// applyJumpSelection scrolls the chat viewport so the user message at
1929// msgIdx is visible at (or near) the top of the chat area. Uses the
1930// anchor slice returned by view.BuildWithAnchors so the mapping from
1931// message index to row is exact, regardless of variable-height tool
1932// blocks above the target.
1933
1934// totalTurnsLocked counts user messages in the transcript. Caller is
1935// assumed to hold i.mu (the name is a mild reminder; this function
1936// itself doesn't touch shared state beyond the slice it's handed).
1937
1938// applySessionSelection loads the given session via the cli-provided
1939// callback and snaps the viewport to the bottom (the latest message)
1940// so the user lands at the live tail of the resumed conversation.
1941
1942// applyRescueModelSelection is like applyModelSelection but routes
1943// through BuildAgentForRescue so launch-time --api-key / --base-url
1944// overrides are dropped before the new agent is built. Falls back to
1945// the regular builder when the host doesn't wire a rescue builder.
1946
1947// swapModel applies a /model selection (or a rescue selection) using
1948// the supplied builder. rescue=true tags the success message so the
1949// user can see that launch-time overrides were ignored.
1950
1951// clearPendingCompactTurnLocked drops work that only makes sense after the
1952// current compaction. The caller must hold i.mu.
1953
1954// compactHandoffLocked returns a self-contained persistence snapshot. The
1955// caller must hold i.mu.
1956
1957// setCompactContinuationLocked updates the private handoff state and returns
1958// its persistence snapshot plus whether persistence is needed. The caller must
1959// hold i.mu.
1960
1961// resetCompactContinuationLocked clears the private handoff state and returns
1962// its persistence snapshot plus whether persistence is needed. The caller must
1963// hold i.mu.
1964
1965// autoCompactContinuationPrompt is an interactive-only compact-handoff
1966// instruction. Its completion criteria must stay separate from Ponytail so
1967// toggling the optional coding-style addendum never changes recovery behavior.
1968const 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.`
1969
1970type compactHandoffResume uint8
1971
1972const (
1973	compactHandoffDiscard compactHandoffResume = iota
1974	compactHandoffContinueExisting
1975	compactHandoffAppendPrompt
1976)
1977
1978// classifyCompactHandoffResume validates that the persisted checkpoint still
1979// describes the effective transcript. A normal assistant reply or a newer
1980// explicit user message supersedes the checkpoint; tool activity remains an
1981// unfinished existing turn. No hidden prompt means the process stopped after
1982// recording the handoff but before appending its context message.
1983
1984// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
1985// active session. When its hidden context message was already persisted, the
1986// agent continues that existing turn rather than appending it again.
1987
1988// startAutoCompactContinuation adds an internal user turn that tells the
1989// model to resume the task after threshold compaction. Provider requests
1990// cannot continue from an assistant tail on every supported API, so the
1991// continuation must be represented as a user message. It is hidden from
1992// the rendered transcript but remains in the persisted/model context.
1993
1994// runCompact invokes core.Agent.Compact and reflects the progress in
1995// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
1996// cancel via the same cancelTurn channel used for normal turns.
1997//
1998// When auto is true the spinner message is pinned to "condensing
1999// history" and the status bar surfaces "(auto)" next to the context
2000// percentage so it's obvious the system triggered this, not the user.
2001// request.force tells the post-compact hand-off to resume even if the
2002// transcript tail has visible assistant text, for example after StopLength.
2003
2004// shellEscapeCommand reports whether text is a "!command" shell
2005// escape and, if so, returns the command with the leading '!' (and
2006// surrounding whitespace) stripped. A bare "!" with no command is
2007// treated as not an escape so it falls through to the normal prompt
2008// path rather than running an empty shell.
2009
2010// ShellEscapeCommand reports whether text is a "!command" shell
2011// escape and, if so, returns the command with the leading '!' (and
2012// surrounding whitespace) stripped. A bare "!" with no command is
2013// treated as not an escape so it falls through to the normal prompt
2014// path rather than running an empty shell.
2015const ShellEscapeTimeoutSeconds = 600
2016
2017// startShellEscape runs a "!command" in the same shell the bash tool
2018// uses, in the session working directory, honoring the /jail sandbox.
2019// It shares the busy/cancel state with the agent: esc cancels it, and
2020// it refuses to start while a turn or another shell escape is already
2021// in flight. Its terminal-log output is appended to the transcript as
2022// user context without automatically starting a model turn.
2023
2024// startTurnRequest starts a new prompt or continues an existing user turn.
2025// continueExisting selects Agent.Continue so callers can retry a transcript
2026// message without appending it twice. overflowRecoveryAttempted suppresses
2027// the pre-turn threshold guard and the one-shot overflow recovery path; a
2028// normal post-compaction continuation leaves it false so a still-too-large
2029// rebuilt context can recover once more.
2030
2031// openRescueDialog surfaces the rescue model picker after a recoverable
2032// provider failure. The pending prompt + images are stashed on the
2033// Interactive so a later applyRescueSelection can re-run the same turn
2034// against the freshly-picked model. activeProvider/failedProvider are
2035// usually the same, but some clients embed different prefixes in their
2036// errors than the configured provider id, so we accept both.
2037
2038// applyRescueSelection switches model (cross-provider if needed) and
2039// re-runs the same prompt+images that just failed. Mirrors
2040// applyModelSelection's transcript-carry logic so the user keeps full
2041// session continuity across the swap.
2042
2043// autoCompactNoteLine returns a styled chat-area note for the
2044// inline auto-compact heads-up. Lives in extNotes so it survives
2045// the busy-spinner overwrite of the status row.
2046
2047// NormalizeAutoCompactThreshold applies the persisted auto-compaction
2048// setting's supported values and default. It is exported so non-interactive
2049// hosts can share the interactive mode's exact configuration semantics.
2050
2051// ShouldAutoCompact reports whether the latest request context reached the
2052// configured percentage of the model's advertised context window.
2053
2054// classifyCompactionContinuation chooses a private handoff reason from the
2055// successful turn tail. Structural unfinished-tail handling is independent of
2056// the status matcher. A status rescue is admitted only for an after-turn
2057// threshold compaction or an already-active status handoff, and only after a
2058// normal successful end.
2059
2060// structuralCompactionContinuation preserves the pre-existing unfinished-tail
2061// behavior without consulting the status matcher.
2062
2063// likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
2064// first-person future-work commitments and never treats generic plan prose as
2065// a host control signal.
2066
2067// shouldAutoCompactLocked reports whether the last turn pushed context
2068// usage past the auto-compact threshold. Must be called with i.mu
2069// held; it reads lastCtxInput and the current model's context window.
2070
2071// WebSearchPolicyGeneration returns the generation a resolver must present at
2072// its final interactive commit. Policy transitions advance it before changing
2073// the gate or generic-child ceiling.
2074
2075// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
2076// ag is still the live interactive agent. Callers without a long-running
2077// resolve use the current policy generation.
2078
2079// ApplyAgentPromptConfigAtWebSearchGeneration additionally rejects a registry
2080// resolved before a web-search policy transition. This final check is made in
2081// the same critical section as live-agent replacement and the core prompt swap.
2082
2083// prepareReplacementAgentLocked applies session-wide capability policy before
2084// ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
2085
2086// DeferUntilIdle runs work immediately when the interactive agent is idle,
2087// or after the current turn/compaction/shell operation releases the busy
2088// state. Callbacks run outside i.mu.
2089
2090// Agent returns the current agent, if any. Used by cli.go to flush the
2091// final transcript to the session file.
2092
2093// silence unused import in some build configs
2094var _ = fmt.Sprintf
2095
2096// runReloadExt triggers a live reload of every extension (discovered
2097// + explicit). Runs on a goroutine so the TUI stays responsive; the
2098// Manager.Reload takes a couple of hundred ms to shut down subprocs
2099// and respawn them. Shows a temporary status line throughout.
2100
2101// Confirm implements core.Confirmer. The agent goroutine calls
2102// this synchronously before every tool invocation when --no-yolo is
2103// active. We push the request onto the confirmDialog queue, trigger
2104// a redraw, and block the caller until the user answers.
2105//
2106// If the session is cancelled or the TUI exits mid-prompt, any
2107// pending request is refused via CancelAll so the agent doesn't
2108// deadlock.
2109
2110// ConfirmToolCall attaches a side-effect-free preview to the matching live
2111// tool panel, then blocks until the user approves or refuses the call.
2112
2113// openTelegramDialog shows the picker for `/telegram` with no arg.
2114// Items depend on current state: disconnect + status when running,
2115// connect + status when stopped.
2116
2117// telegramMenuItems builds the dialog entries for the current
2118// bridge state. Returns empty when no bot.json exists so the
2119// caller can show a helpful status line instead of an empty menu.
2120
2121// doTelegram dispatches one of the three explicit actions. Called
2122// from /telegram <action> or after the picker selects a row.
2123
2124// telegramConnect starts the bridge. Refuses if it's already
2125// running or if the on-disk bot.json is missing a token.
2126
2127// telegramDisconnect stops the bridge. No-op when already stopped.
2128
2129// refreshToolsAfterTelegram restores normal tools only through a successful
2130// resolver refresh. Failure removes Telegram-only tools but leaves web_search
2131// revoked in the live registry, stale snapshots, and generic-child policy.
2132
2133// telegramSenderAdapter wraps the bridge so the tools package can
2134// drive it without importing telegram directly. The Active() check
2135// is forwarded to the bridge so the tool can fail clearly with a
2136// model-readable error when the user disconnected mid-turn.
2137type telegramSenderAdapter struct {
2138	bridge *telegram.Bridge
2139}
2140
2141// TrackResidentSubagent reserves completion delivery after the manager has
2142// durably accepted a resident child prompt and before it is scheduled.
2143
2144// ReportResidentSubagent delivers a typed resident terminal outcome through
2145// the same coordinator path used by legacy worker completions.
2146
2147// registerCoordinatorWorker associates a tracker registration with the open
2148// manager wave. Direct embedders that register a worker outside startTurn get
2149// a one-worker sealed wave, retaining the exported tracker API's old behavior.
2150
2151// requestCompletionDelivery starts at most one waiter for the current active
2152// set. A request arriving while that waiter is formatting or submitting is
2153// picked up before the waiter exits, so a later worker cannot be lost in the
2154// handoff between batches.
2155
2156// beginCompletionDeliveryHold marks the owning parent turn's registration
2157// boundary. Results slide into an active turn; release seals the remaining
2158// worker wave for idle-parent batching rather than delaying active delivery.
2159
2160// autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,
2161// in the same order Resolve appends them to a new agent. The profile manifest
2162// is independent of strict orchestration and remains available in on-demand
2163// mode whenever the primary agent can spawn a worker.
2164
2165// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
2166// have been appended by auto-subagents. Resolve appends owned blocks at the
2167// end, so removing the last occurrence preserves an identical base block.
2168
2169// applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by subagent
2170// prompting on the running agent. Toggling strict orchestration changes the
2171// delegation guidance while retaining the profile manifest when usable.
2172
2173// applyAutoSubagentsTool registers the canonical subagent tools whenever
2174// launch-time policy permits them. Auto-subagents controls prompt behavior,
2175// while the tools remain available for explicit user-requested delegation.
2176// Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
2177// /reload-ext additions survive a settings change.
2178
2179// applyTelegramTools registers (active=true) or removes (active=false)
2180// the telegram_send_image and telegram_send_file tools on the running
2181// agent so the model only sees them while the bridge is connected.
2182// Snapshots and mutates the live tool registry so any extension or
2183// /reload-ext additions made while Telegram is connected survive a
2184// later /telegram disconnect. The two Telegram entries are always replaced;
2185// web_search is additionally stripped only while the external bridge is active.
2186
2187// telegramStatus writes a one-liner describing the bridge state.
2188// Reports on both the in-tui bridge and the background daemon so
2189// the user isn't confused when the daemon owns the poll loop.
2190
2191// telegramHost adapts *Interactive to telegram.Host so the bridge
2192// can call back into the TUI without importing modes directly.
2193type telegramHost struct{ iv *Interactive }
2194
2195// openSessionOpsDialog shows the picker for `/session` with no arg.
2196// Always offers export, import, fork, tree; the handlers bail with
2197// a clear status message when the precondition isn't met (empty
2198// transcript on fork; no parent/siblings on tree).
2199
2200// doSessionOp dispatches export, import, fork, or tree. arg is the
2201// optional positional argument from e.g. /session export <path>
2202// or /session import <path>; fork and tree ignore it.
2203
2204// doSessionExport writes the live session file to destination path
2205// dst. When dst is empty we default to ~/Downloads (falling back to
2206// the user's home directory if it doesn't exist). The helper
2207// expands a leading `~` and creates any missing parent directories.
2208
2209// doSessionImport copies the .zutsession file at src into the
2210// running cwd's sessions directory and loads it as the active
2211// session, same as `/sessions` -> pick. When src is empty we ask
2212// the user to pass a path (no usable default here).
2213
2214// defaultExportDir returns ~/Downloads when it exists, or ~ as a
2215// fallback, or /tmp on exotic machines with no home dir.
2216
2217// expandTilde turns a leading ~ into the user's home directory.
2218// Returns the input unchanged when there's no tilde or no home.
2219
2220// unquotePath strips a matching pair of surrounding single or
2221// double quotes. Drag-drop paste in the tui auto-quotes dropped
2222// file paths so the shell-like `/session import 'foo bar.zs'`
2223// stays well-formed; when the TUI's own slash handler consumes
2224// the arg, we want the raw path back.
2225
2226// friendlyPath collapses the user's home directory to a leading ~
2227// so status messages read cleanly. Falls back to the raw path when
2228// the home dir is unknown.
2229
2230// doSessionFork opens the /jump turn picker in "fork mode". The
2231// next selection branches the current session at that user turn
2232// instead of scrolling the viewport to it.
2233
2234// doSessionTree opens the current session family after the shared
2235// fail-closed gate succeeds. It intentionally does not fall back to the
2236// in-memory transcript: a tree that cannot be persisted and reloaded cannot
2237// safely create a navigation branch.
2238
2239// applySessionTreeMessageSelection keeps the old scalar integration
2240// source-compatible for package consumers. New dialog selections use the
2241// structured sessionTreeTarget path below.
2242
2243// applySessionTreeTarget checks out a structured dialog target. The target's
2244// source and effective indices come from the same preflight snapshot the
2245// dialog rendered, while the live read below preserves compatibility with
2246// the existing LoadSession(path) error callback.
2247
2248// applyForkSelection branches the current session at msgIdx+1 (so
2249// the selected user message and everything before it is included
2250// in the new branch), then switches the running agent to the new
2251// file. Called from the jump-dialog handler when pendingFork=true.
2252
2253// formatInt is a tiny strconv.Itoa shim; keeps the handler above
2254// from needing a strconv import just for one call.
2255
2256// resetTranscriptRenderLocked invalidates every render cache that assumes
2257// the previous transcript remains structurally intact. Compaction is a
2258// replacement, not an append: the flow renderer must repaint from the new
2259// transcript rather than diff it against scrollback rows from the old one.
2260// Must be called with i.mu held.
2261
2262// assistantMessageSideEffects runs the non-visual hooks attached to
2263// EvAssistantMessage: the host-provided OnAssistant callback and the
2264// telegram-bridge mirror. Called with i.mu held.
2265//
2266// Factored out of handleEvent because the streaming pacer may defer
2267// visual reset until after the last buffered rune has painted, but
2268// the callbacks themselves must fire on message arrival so
2269// downstream observers (session persistence, telegram, cost panels)
2270// don't wait on a UI animation to catch up.

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close