v0.24.0 3dbf466

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close