v0.39.0 33c980b

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close