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