v0.50.1 a637010

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close