v0.21.0 cd9706d

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

↑↓ moveenter openesc close