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