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