v0.48.1 deaab59

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

↑↓ moveenter openesc close