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