v0.45.0 cb619f7

interactive.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close