v0.23.0 edaaa7c

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close