v0.27.0 01c3d31

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