1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "runtime"
11 "sort"
12 "strconv"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "time"
17 "unicode"
18 "unicode/utf8"
19
20 "github.com/bnema/zut/packages/agent/extensions"
21 "github.com/bnema/zut/packages/agent/extproto"
22 "github.com/bnema/zut/packages/agent/internal/orchestration"
23 "github.com/bnema/zut/packages/agent/modes/telegram"
24 "github.com/bnema/zut/packages/agent/skills"
25 "github.com/bnema/zut/packages/agent/subagents"
26 "github.com/bnema/zut/packages/agent/tools"
27 "github.com/bnema/zut/packages/core"
28 "github.com/bnema/zut/packages/provider"
29 "github.com/bnema/zut/packages/provider/auth"
30 "github.com/bnema/zut/packages/tui"
31)
32
33// InteractiveConfig configures the interactive loop.
34type InteractiveConfig struct {
35 Terminal tui.Terminal
36 Theme tui.Theme
37 Model string
38 Provider string
39 AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
40 BaseURL string
41 Reasoning string
42 SystemPrompt string
43 Tools core.Registry
44 MaxSteps int
45 CWD string
46
47 // Startup resource fields are loaded inputs available to list before the
48 // transcript. They are never sent to the provider or persisted by the view.
49 StartupAgentName string
50 StartupContextPaths []string
51 StartupExtensionNames []string
52 StartupSkillNames []string
53
54 // StartupExtensionErrors are non-fatal extension load failures collected
55 // before the TUI starts. They are display-only and never enter the transcript.
56 StartupExtensionErrors []string
57
58 // ShowInstructionsAtStartup controls whether startup resources are visible.
59 // nil means the default, off.
60 ShowInstructionsAtStartup *bool
61
62 // InlineImagesEnabled overrides terminal image rendering. nil means
63 // auto-detect and render when supported; false disables; true uses
64 // the detected protocol when available.
65 InlineImagesEnabled *bool
66
67 // TerminalAlertsEnabled controls interactive alerts from the main
68 // agent and extensions. nil means enabled; false disables them.
69 TerminalAlertsEnabled *bool
70
71 // TerminalTitleEnabled controls the hidden title request and terminal
72 // title updates. nil means enabled; false disables both.
73 TerminalTitleEnabled *bool
74
75 // AutoSubagentsEnabled mirrors the persisted config flag at startup so
76 // the /settings dialog can render the current state without
77 // re-reading config.json on every open.
78 AutoSubagentsEnabled *bool
79
80 // PonytailEnabled mirrors the persisted default-on coding-guidance
81 // setting at startup. nil/missing means enabled.
82 PonytailEnabled *bool
83
84 // WebSearchEnabled mirrors the persisted default-on web-search setting.
85 // nil/missing means enabled for normal interactive sessions.
86 WebSearchEnabled *bool
87
88 // WebSearchToolAllowed is the immutable invocation-level capability ceiling.
89 // false means --no-tools, --tools, or a packaged PermissionSet excludes it.
90 WebSearchToolAllowed *bool
91
92 // WebSearchInvocationOverride is true when an explicit --tools list includes
93 // web_search. That invocation-level opt-in takes precedence over the
94 // persisted setting, so the settings row is informational for this session.
95 WebSearchInvocationOverride bool
96
97 // AutoSubagentsToolAllowed is nil for normal sessions and false when the
98 // launch-time --no-tools/--tools policy excludes subagent spawning.
99 AutoSubagentsToolAllowed *bool
100
101 // AutoSubagentsStatusToolAllowed is nil for normal sessions and false when
102 // the launch-time --no-tools/--tools policy excludes status queries.
103 AutoSubagentsStatusToolAllowed *bool
104
105 // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil
106 // for normal sessions and false when the launch-time tool policy excludes
107 // manager lifecycle actions.
108 AutoSubagentsStopToolAllowed *bool
109 AutoSubagentsResumeToolAllowed *bool
110
111 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
112 // nil/missing means disabled. Unsupported providers reject attempts
113 // to enable it and reject requests when it remains enabled.
114 FastMode *bool
115
116 // LSPEnabled controls the main session's built-in lsp tool. nil means
117 // enabled by default.
118 LSPEnabled *bool
119
120 // SubagentLSPEnabled controls whether newly spawned subagent children
121 // receive the lsp tool. nil means enabled by default.
122 SubagentLSPEnabled *bool
123
124 // AutoCompactThreshold is the context-window percentage that triggers
125 // automatic compaction. nil means 85; zero disables percentage-based
126 // triggers while preserving payload-too-large recovery.
127 AutoCompactThreshold *int
128
129 // JailByDefault mirrors the persisted jail_by_default preference.
130 // The current sandbox may differ after a session-scoped /jail or /unjail.
131 JailByDefault *bool
132
133 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
134 // flag at startup. When true the @-mention picker fuzzy-searches the
135 // whole project tree instead of browsing one directory at a time.
136 RecursiveFileSuggest *bool
137
138 // RespectGitignore mirrors the persisted respect_gitignore flag at
139 // startup. nil means the default (on); when false the @-mention
140 // picker shows files matched by the project's root .gitignore.
141 RespectGitignore *bool
142
143 // ThemeName mirrors the persisted config theme value. Empty means auto.
144 ThemeName string
145
146 // FlatTools renders tool calls without the bordered panel (a quiet
147 // header line plus indented, frameless output). Mirrors the
148 // resolved tool_render config / ZUT_FLAT_TOOLS env at startup.
149 FlatTools bool
150
151 // CompactUser renders sent user messages as a single quiet gutter
152 // line instead of a padded, tinted bubble. Mirrors the resolved
153 // compact_input config / ZUT_COMPACT_INPUT env at startup.
154 CompactUser bool
155
156 // CompactMode mirrors the persisted compact_mode flag at startup.
157 // When true, it enables both flat tool calls and compact user turns.
158 CompactMode *bool
159
160 // TUIInputStyle controls the main input rendering: plain or lines.
161 TUIInputStyle string
162
163 // TUIStatusPosition controls whether the status block renders above
164 // or below the main input.
165 TUIStatusPosition string
166
167 // TUIWorkingPosition controls whether the busy/working spinner renders
168 // above or below the main input.
169 TUIWorkingPosition string
170
171 // TUISubagentPosition controls whether live subagent activity renders
172 // above or below the main input. Empty defaults below the input.
173 TUISubagentPosition string
174
175 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
176 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
177 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
178 QuickModelShortcuts []QuickModelShortcut
179
180 // ExtensionThemes returns themes bundled with loaded extensions.
181 ExtensionThemes func() []tui.ThemeOption
182
183 // AutoSubagentsSystemAddendum is the strict orchestrator block that gets
184 // appended when the user enables auto-subagents at runtime. Plumbed in from
185 // the cli so this package doesn't have to import agent (cycle).
186 AutoSubagentsSystemAddendum string
187
188 // OnDemandSubagentsSystemAddendum limits delegation to explicit user
189 // requests while auto-subagents is disabled.
190 OnDemandSubagentsSystemAddendum string
191
192 // SubagentsSystemAddendum is the metadata-only [subagents_list]
193 // block available whenever the primary agent can spawn subagents.
194 SubagentsSystemAddendum string
195 SettingsStore SettingsStore
196
197 // Agent is optional. If nil, zut opens without credentials; the
198 // user must /login before they can prompt.
199 Agent *core.Agent
200
201 // InitialCompactHandoff is the opaque, persisted handoff snapshot for a
202 // resumed interactive session. Invalid data is ignored by this package.
203 InitialCompactHandoff json.RawMessage
204 // PersistCompactHandoff records the current opaque handoff snapshot in the
205 // active session. It must not re-enter Interactive.
206 PersistCompactHandoff func(json.RawMessage) error
207 // CurrentCompactHandoff returns the opaque handoff snapshot for the session
208 // currently owned by the host after a session switch. Invalid data is ignored.
209 CurrentCompactHandoff func() json.RawMessage
210
211 InitialInput string
212
213 // StartupPre is auto-submitted once when the interactive session
214 // opens, before InitialInput is applied. Uses the same path as a
215 // typed Enter submit ("!" → shell escape, otherwise a model turn).
216 StartupPre string
217
218 // OnStartupPreDone runs after StartupPre finishes (shell escape or
219 // model turn), before deferred InitialInput is applied. Hosts use
220 // it to rediscover skills / reload extensions installed by pre.
221 OnStartupPreDone func()
222
223 // RefreshTools re-resolves the live agent registry after a setting
224 // changes the main session's tool availability. Optional for embedders.
225 RefreshTools func() error
226
227 // SetWebSearchAvailable updates session-wide execution and generic-child
228 // ceilings after a live registry commit or fail-closed revocation. It can
229 // run while agentMu and i.mu are held, so callbacks must not re-enter
230 // Interactive or wait on either lock. The CLI callback only updates its
231 // web-search guard and supervisor policy.
232 SetWebSearchAvailable func(bool)
233
234 // RefreshPrompt re-resolves the complete live prompt and tool registry
235 // after a prompt-affecting setting changes. Optional for embedders.
236 RefreshPrompt func() error
237
238 // AutoSubmitInitial, when true, auto-submits InitialInput after
239 // StartupPre completes (or immediately when StartupPre is empty).
240 // When false, InitialInput only pre-fills the editor.
241 AutoSubmitInitial bool
242
243 // Auth is required. When the user runs /login, Interactive talks to
244 // AuthManager to open a browser and wait for the callback.
245 AuthManager *auth.Manager
246
247 // LlamaCPPConfig resolves the router URL and optional API key from env or
248 // the credential saved by /login.
249 LlamaCPPConfig func() (baseURL, apiKey string, err error)
250
251 // RefreshLlamaCPPModels synchronizes currently loaded router models into
252 // the catalog before /model opens.
253 RefreshLlamaCPPModels func(context.Context) error
254
255 // BuildAgent is called after a successful login to (re)construct the
256 // agent with the fresh credential. It returns the new agent and
257 // the concrete provider/model in use.
258 BuildAgent func() (*core.Agent, string, string, error)
259
260 // SetKimiCLIFallbackDisabled controls whether zut may fall back to
261 // the official Kimi Code CLI token when zut has no stored Kimi token.
262 SetKimiCLIFallbackDisabled func(disabled bool) error
263
264 // BuildAgentFor rebuilds the agent with an explicit provider/model
265 // override (used by the /model picker when switching providers).
266 // If providerOverride is empty, the current provider is kept.
267 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
268
269 // BuildAgentForRescue rebuilds the agent for the rescue picker that
270 // opens after a recoverable provider failure. Unlike BuildAgentFor,
271 // this builder drops launch-time --api-key and --base-url overrides
272 // because those are usually the reason rescue triggered. Re-resolves
273 // credentials from env vars / auth.json / provider defaults so the
274 // retry has a real chance of succeeding. Falls back to BuildAgentFor
275 // when nil so embedders that don't wire it keep working.
276 BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
277
278 // LoggedInProviders returns the list of provider names that
279 // currently have credentials. Used by /model to filter the
280 // picker to only show reachable models.
281 LoggedInProviders func() []string
282
283 // ZutHome is zut's global state directory, used by authentication,
284 // themes, extensions, and other shared configuration.
285 ZutHome string
286
287 // SessionsRoot is the root passed to core session operations. It differs
288 // from ZutHome for Zutfile agents, whose sessions are isolated by agent
289 // name. Empty falls back to ZutHome for embedders and tests.
290 SessionsRoot string
291
292 // Version is the binary's current version (from main.version).
293 // Used only for display; the update check itself is done outside
294 // this package to avoid an import cycle.
295 Version string
296
297 // UpdateInfoChan is an optional channel that delivers the result
298 // of the github-release update check. Interactive reads at most
299 // one value, drops it if the check reported nothing, and otherwise
300 // surfaces a yellow "update available" banner at the top of the
301 // chat. Nil channel = no banner, no startup cost.
302 UpdateInfoChan <-chan UpdateInfo
303
304 // Sandbox is the shared sandbox pointer. Toggled by /jail and /unjail.
305 Sandbox *tools.Sandbox
306
307 // LoadSession swaps the current session for the one at path. The
308 // callback returns the new agent message slice so the TUI can invalidate.
309 LoadSession func(path string) error
310
311 // ChangeCWD switches the running zut session's working directory
312 // to path. The host closes the current session, rebuilds the
313 // agent so tools / AGENTS.md / sandbox bind to the new cwd, and
314 // opens a fresh session there. Returns an error if path doesn't
315 // exist, isn't a directory, or the host can't rebuild the agent.
316 //
317 // Optional: not wired by every embedder. When nil the hidden /cd
318 // command surfaces a clear error rather than no-oping.
319 ChangeCWD func(path string) error
320
321 // CurrentSessionPath returns the path of the live session file
322 // on disk (the one every AppendMessage writes to). Used by
323 // /session export so the exporter ships the exact bytes on
324 // disk. Returns an empty string when --no-session is set or
325 // no session is open.
326 CurrentSessionPath func() string
327
328 // FlushSession writes any in-memory agent messages to the
329 // session file that haven't been persisted yet. Called by
330 // /session export right before reading the file so the
331 // exported bytes reflect the full current conversation, not
332 // just the rows the agent happened to write synchronously.
333 // The default WriteNewTranscript-at-exit strategy means most
334 // of a running session lives only in memory until the tui
335 // closes; without a flush hook, /session export writes a
336 // file that only has the meta row.
337 FlushSession func()
338
339 // SessionTransition serializes a state-changing session/model
340 // operation with the host's session load. It is optional for embedders;
341 // the CLI uses it to prevent a slow provider rebuild from being replaced
342 // by a concurrent resume (or vice versa).
343 SessionTransition func(func())
344
345 // PersistModel is called whenever the user switches model or provider.
346 // It should update config.json and (if there's an active session)
347 // write a new meta row so resume picks up the same model.
348 PersistModel func(providerName, model string)
349 // OnReasoningChanged updates host-owned runtime defaults after the setting
350 // has been persisted successfully.
351 OnReasoningChanged func(level string)
352
353 // InitialSessionTitle is the persisted title of the session loaded before
354 // the TUI starts. It is shown in the terminal without another model call.
355 InitialSessionTitle string
356 // InitialSessionTitlePending is true for a newly forked branch that only
357 // contains its copied prefix. The copied user messages must not prevent a
358 // new title after the branch's first real prompt.
359 InitialSessionTitlePending bool
360
361 // CurrentSessionTitle returns the title of the session currently owned by
362 // the host. It is used after /sessions and import switches.
363 CurrentSessionTitle func() string
364 // CurrentSessionTitlePending reports whether the loaded session is a new
365 // branch that has not received a post-fork prompt yet.
366 CurrentSessionTitlePending func() bool
367
368 // PersistTitle stores an automatically generated title without adding a
369 // user or assistant message to the transcript. It returns persistence
370 // errors without affecting the main turn.
371 PersistTitle func(title string) error
372 // OnSessionTitleChanged updates host-side in-memory title bookkeeping
373 // after a manual rename has already been written to disk.
374 OnSessionTitleChanged func(title string)
375
376 // CurrentGoal and PersistGoal expose the autonomous goal attached to the
377 // current session. CurrentGoal must return a copy, not the live session
378 // pointer, so failed persistence cannot mutate in-memory state. The callbacks
379 // follow session switches without coupling the TUI to the session file owner.
380 CurrentGoal func() *core.SessionGoal
381 CurrentGoalHistory func() []core.SessionGoal
382 EnsureMission func(objective string) error
383 PersistGoal func(goal *core.SessionGoal) error
384
385 OnAssistant func(m provider.Message)
386
387 // Extensions, if non-nil, lets users invoke extension-registered
388 // slash commands. Commands declared by extensions are looked up
389 // AFTER the built-in catalog so a built-in name always wins.
390 Extensions *extensions.Manager
391
392 // Supervisor, if non-nil, enables the /subagents slash command and the
393 // dashboard dialog. The cli constructs the Supervisor once per
394 // interactive run and tears it down on exit. nil disables the
395 // feature entirely (used by embedders / tests that don't want
396 // subprocesses).
397 Supervisor *subagents.Supervisor
398
399 // ResolveSubagent validates a named markdown profile for direct
400 // /subagents commands. Auto-subagents uses the equivalent callback on its
401 // tool; keeping this optional preserves lightweight embedders.
402 ResolveSubagent func(name string) (*subagents.Profile, error)
403
404 // SkillSnapshot, if non-nil, returns the current list of
405 // discovered SKILL.md files. Re-invoked each time /skills opens
406 // so the picker reflects edits made during the session.
407 SkillSnapshot func() []*skills.Skill
408
409 // ChangelogChan, if non-nil, delivers release-notes for the
410 // current binary version once at startup. Interactive opens a
411 // dismissible overlay when the channel produces a non-empty
412 // body. Receiver fires at most once.
413 ChangelogChan <-chan ChangelogPayload
414
415 // OnChangelogDismiss, if non-nil, is called once the user
416 // closes the changelog overlay. The cli wires this to a
417 // MarkChangelogShown call so the same version doesn't show
418 // again on the next launch.
419 OnChangelogDismiss func()
420
421 // NoYolo is true when --no-yolo was passed. Interactive opens
422 // a confirmation dialog before every tool call and blocks the
423 // tool until the user picks yes / always-this-tool /
424 // always-all / no. When false (default), tools run freely.
425 NoYolo bool
426
427 // ConfirmGate is the session-scoped gate wrapping this
428 // interactive's Confirmer. When non-nil, /yolo can call
429 // AllowAll() on it to disable confirmation for the rest of the
430 // session. When nil (yolo mode), /yolo reports that there's
431 // nothing to disable.
432 ConfirmGate *core.ConfirmGate
433}
434
435// ChangelogPayload mirrors agent.ChangelogInfo without the import
436// cycle. The cli builds one from the http response, the tui opens
437// the overlay when one arrives.
438type ChangelogPayload struct {
439 Version string
440 Body string
441 URL string
442}
443
444type modelRefreshResult struct{ err error }
445
446// Interactive is the TUI chat loop.
447type chatCacheKey struct {
448 cols int
449 agentRev uint64
450 statusOK string
451 statusErr string
452 help string
453 extNotes string
454 extStatuses string
455 extWidgets string
456 reloadErrors string
457 updateAvailable bool
458 updateCurrent string
459 updateLatest string
460 updateURL string
461 welcomeShowVer bool
462 expandAll bool
463 tailLimit int
464 renderedMessageCount int
465 viewCacheRev uint64
466}
467
468// QuickModelShortcut is one configured quick model switch slot.
469type QuickModelShortcut struct {
470 Provider string
471 Model string
472}
473
474type extensionStatus struct {
475 Level string
476 Text string
477}
478
479type extensionWidget struct {
480 Position string
481 Title string
482 Lines []string
483}
484
485// SettingsStore persists user-toggleable settings surfaced by /settings.
486type SettingsStore interface {
487 SetQuickModelShortcut(slot int, providerName, model string) error
488 SetInlineImages(enabled bool) error
489 SetAutoSubagents(enabled bool) error
490 SetJailByDefault(enabled bool) error
491 SetRecursiveFileSuggest(enabled bool) error
492 SetRespectGitignore(enabled bool) error
493 SetCompactMode(enabled bool) error
494 SetTUIInputStyle(style string) error
495 SetTUIStatusPosition(position string) error
496 SetTUIWorkingPosition(position string) error
497 SetTUISubagentPosition(position string) error
498 SetReasoning(level string) error
499 SetTheme(name string) error
500}
501
502type terminalAlertsSettingsStore interface {
503 SetTerminalAlertsEnabled(enabled bool) error
504}
505
506type terminalTitleSettingsStore interface {
507 SetTerminalTitleEnabled(enabled bool) error
508}
509
510type lspSettingsStore interface {
511 SetLSPEnabled(enabled bool) error
512 SetSubagentLSPEnabled(enabled bool) error
513}
514
515type showInstructionsSettingsStore interface {
516 SetShowInstructionsAtStartup(enabled bool) error
517}
518
519type autoCompactThresholdSettingsStore interface {
520 SetAutoCompactThreshold(percent int) error
521}
522
523type fastModeSettingsStore interface {
524 SetFastMode(enabled bool) error
525}
526
527type ponytailSettingsStore interface {
528 SetPonytailEnabled(enabled bool) error
529}
530
531type webSearchSettingsStore interface {
532 SetWebSearchEnabled(enabled bool) error
533}
534
535type compactContinuationOrigin uint8
536
537type compactContinuationRequest struct {
538 origin compactContinuationOrigin
539 force bool
540 lastStop provider.StopReason
541 turnError error
542}
543
544const (
545 compactOriginManual compactContinuationOrigin = iota
546 compactOriginPreTurnThreshold
547 compactOriginAfterTurnThreshold
548 compactOriginRecovery
549)
550
551type compactContinuationReason uint8
552
553const (
554 compactContinuationNone compactContinuationReason = iota
555 compactContinuationStructuralTail
556 compactContinuationForcedLength
557 compactContinuationStatusRescue
558)
559
560const maxStatusRescueContinuations = 2
561
562const compactHandoffVersion = 1
563
564type compactContinuationState struct {
565 reason compactContinuationReason
566 rescueAttempts int
567}
568
569type persistedCompactHandoff struct {
570 Version int `json:"version"`
571 Reason string `json:"reason"`
572 RescueAttempts int `json:"rescue_attempts,omitempty"`
573}
574
575func decodeCompactHandoff(raw json.RawMessage) compactContinuationState {
576 var persisted persistedCompactHandoff
577 if len(raw) == 0 || json.Unmarshal(raw, &persisted) != nil || persisted.Version != compactHandoffVersion {
578 return compactContinuationState{}
579 }
580 switch persisted.Reason {
581 case "structural_tail":
582 if persisted.RescueAttempts == 0 {
583 return compactContinuationState{reason: compactContinuationStructuralTail}
584 }
585 case "forced_length":
586 if persisted.RescueAttempts == 0 {
587 return compactContinuationState{reason: compactContinuationForcedLength}
588 }
589 case "status_rescue":
590 if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {
591 return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}
592 }
593 }
594 return compactContinuationState{}
595}
596
597func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
598 var reason string
599 switch state.reason {
600 case compactContinuationStructuralTail:
601 reason = "structural_tail"
602 case compactContinuationForcedLength:
603 reason = "forced_length"
604 case compactContinuationStatusRescue:
605 reason = "status_rescue"
606 default:
607 return nil
608 }
609 encoded, err := json.Marshal(persistedCompactHandoff{
610 Version: compactHandoffVersion,
611 Reason: reason,
612 RescueAttempts: state.rescueAttempts,
613 })
614 if err != nil {
615 return nil
616 }
617 return encoded
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 reloadStatusSeq uint64
679 extStatuses map[string]map[string]extensionStatus
680 extWidgets map[string]map[string]extensionWidget
681 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
682 liveBlock []string // live streaming/tool progress rendered outside scrollback
683 helpBlock []string // rendered above the chat when /help was typed
684 cumUsage provider.Usage
685 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
686 busy bool
687 ctrlCExit bool
688 activity agentActivity
689 subagentActivityActive bool
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 // runCtx is the top-level context passed to Run(). Follow-up turns
737 // drained from `queued` are started against this context so they
738 // survive past the ctx of the key event that enqueued them.
739 runCtx context.Context
740
741 // pendingPostCompactNote is a status_ok message to surface after
742 // a successful auto-compact pass triggered by a 413 or by the
743 // pre-turn fraction guard. Cleared by runCompact once shown.
744 pendingPostCompactNote string
745
746 // A pre-turn compaction must preserve the complete pending request,
747 // including images. Overflow recovery instead continues the user message
748 // already appended to the agent transcript, avoiding a duplicate message.
749 pendingCompactPrompt string
750 pendingCompactImages []provider.ImageBlock
751 hasPendingCompactPrompt bool
752 continueAfterCompact bool
753
754 // compacting is true for both manual and automatic compaction. Prompts
755 // submitted while it is set stay in the host queue because Compact does
756 // not run the agent loop that drains the agent-owned queue.
757 compacting bool
758
759 // autoCompacting is true while a model-triggered compaction is in
760 // flight. Surfaced in the status bar so the user can tell a
761 // condense pass from a regular assistant turn.
762 autoCompacting bool
763
764 // compactContinuation records the private reason and bounded rescue
765 // budget for the live compaction handoff. It is never persisted or
766 // inferred from rendered transcript rows.
767 compactContinuation compactContinuationState
768
769 // updateInfo is the result of the async update check. Zero value
770 // while the check hasn't completed or nothing is available.
771 updateInfo UpdateInfo
772
773 dialog *loginDialog
774 modelDialog *modelDialog
775 llamaDialog *llamaDialog
776 rescueDialog *rescueDialog
777 sessionDialog *sessionDialog
778 subagentsDialog *subagentsDialog
779 jumpDialog *jumpDialog
780 btwDialog *btwDialog
781 skillsDialog *skillsDialog
782 changelogDialog *changelogDialog
783 confirmDialog *confirmDialog
784 logoutDialog *logoutDialog
785 telegramDialog *telegramDialog
786 settingsDialog *settingsDialog
787 quickModelAssign int
788 telegramBridge *telegram.Bridge
789 sessionOpsDialog *sessionOpsDialog
790 sessionTreeDialog *sessionTreeDialog
791 extPanel *extPanelDialog
792 llamaConfigured bool
793
794 // completionTracker observes auto-subagent turn and process completion.
795 // turnCoordinator seals worker waves at manager-turn boundaries and decides
796 // the next wake without relying on timing or polling.
797 completionTracker *subagents.CompletionTracker
798 turnCoordinator *orchestration.Coordinator
799 coordinatorWorkerIDs map[string][]string
800 coordinatorWorkerSeq uint64
801 completionDeliveryMu sync.Mutex
802 completionDeliveryRunning bool
803 completionDeliveryRequest bool
804 completionDeliveryHolds int
805
806 // subagentsWaitWatcherDone is an optional test hook invoked when a
807 // /subagents wait watcher exits.
808 subagentsWaitWatcherDone func()
809
810 // pendingFork is true when the user ran /session fork: the next
811 // jump-picker selection should branch off that message instead
812 // of scrolling. Flag resets after the action fires or the dialog
813 // is dismissed, so repeated /jump calls don't turn into forks.
814 pendingFork bool
815 suggest *slashSuggester
816 fileSuggest *fileSuggester
817 spin *spinner
818
819 // parkedTurn is the 1-based turn number the viewport is currently
820 // scrolled to by /jump. 0 = not parked, showing the tail as usual.
821 // Rendered as a muted footer at the bottom of the chat so users
822 // don't forget they're looking at history.
823 parkedTurn int
824 parkedTotal int
825
826 // inputHistoryIndex is -1 when not browsing history. When the
827 // editor is empty, Up/Down can walk previous user prompts without
828 // stealing normal vertical cursor movement in non-empty input.
829 inputHistoryIndex int
830
831 // lastCtrlC is when the user last pressed ctrl+c. The first press
832 // clears the editor / cancels a turn / shows a hint; a second press
833 // within ctrlCExitWindow exits. Mirrors the python-repl convention.
834 lastCtrlC time.Time
835
836 // doubleEscape tracks the optional bare-Escape session-tree gesture.
837 // clock is injectable so the gesture can be tested without sleeping.
838 doubleEscape doubleEscapeTracker
839 clock func() time.Time
840
841 // welcomeStart is when the interactive run began. The welcome
842 // banner shows the binary version for welcomeVersionDuration
843 // after this point and reverts to plain text after.
844 welcomeStart time.Time
845
846 // interactiveStarted gates session-title generation so tests and
847 // embedders that call Submit before Run do not issue a hidden request.
848 interactiveStarted bool
849 // titleRealPromptSeen prevents a fresh session from generating more than
850 // one title. It is reset only when a fork or a new cwd starts a new branch.
851 titleRealPromptSeen bool
852 titleGenerationStarted bool
853 firstRealPrompt string
854 titleVersion uint64
855 titleCancel context.CancelFunc
856 sessionTitle string
857
858 // extNotes are one-shot styled lines pushed by extensions via
859 // Notify / Display. They live above the editor (just below the
860 // transcript) until cleared by /clear or another reset.
861 extNotes []string
862
863 // reloadErrors are host-only extension reload failures. They stay in
864 // the scrolling chat until /clear without entering the agent transcript.
865 reloadErrors []string
866
867 // shellRunning is true while a !command is executing. It shares
868 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
869 // escape can start while one is in flight.
870 shellRunning bool
871 // shellLive is the accumulating stdout/stderr of the in-flight
872 // shell escape, updated via BashTool progress for live rendering.
873 shellLive string
874
875 // awaitingStartupPre is true while the zutfile entry.pre auto-submit
876 // is in flight. When it clears, deferredInitialInput is applied.
877 awaitingStartupPre bool
878 deferredInitialInput string
879 autoSubmitDeferred bool
880
881 // sessionLoading is true while a /sessions selection is being read
882 // on a background goroutine. Keeping this off the input goroutine
883 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
884 sessionLoading bool
885
886 // sessionLoads carries per-entry results for the /sessions picker. The
887 // picker owns the load lifecycle; the interactive loop applies results so
888 // rendering never races with background file reads.
889 sessionLoads <-chan sessionLoadEvent
890
891 // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
892 // picker, the main loop is the sole owner of dialog mutation and rendering.
893 sessionTreeLoads <-chan sessionTreeLoadEvent
894
895 // pendingRescuePrompt / pendingRescueImages stash the prompt and
896 // images that should be re-run after the user picks a model in
897 // the rescue dialog. Cleared once applyRescueSelection consumes
898 // them (or when the dialog is dismissed via esc).
899 pendingRescuePrompt string
900 pendingRescueImages []provider.ImageBlock
901
902 clipboardImages []clipboardImageAttachment
903}
904
905// welcomeVersionDuration is how long the welcome banner shows the
906// version suffix before reverting to the plain headline. 1.5s is
907// enough to read at a glance and keeps the splash short.
908const welcomeVersionDuration = 1500 * time.Millisecond
909
910// initialResumeTailLimit caps how many messages from a freshly-resumed
911// transcript we render on the first paint. The full transcript is
912// still in memory; older messages are rendered (and their cached
913// lines kept for the lifetime of the View) as soon as the user
914// scrolls past the rendered tail. Picked to comfortably fill the
915// largest realistic terminal viewport while keeping first paint
916// snappy on multi-thousand-message sessions where markdown / syntax
917// highlighting dominates the redraw cost.
918const initialResumeTailLimit = 80
919
920// resumeTailExpandStep is how many additional messages the tail
921// limit grows by each time the user scrolls past the currently
922// rendered top. Pre-rendering this many messages on a single tick
923// keeps scroll-up smooth without falling back to a one-by-one
924// reveal that would feel jerky.
925const resumeTailExpandStep = 80
926
927type startupPreResult struct {
928 deferred string
929 autoSubmit bool
930}
931
932// NewInteractive constructs an Interactive from cfg.
933func NewInteractive(cfg InteractiveConfig) *Interactive {
934 renderer := tui.NewRenderer(cfg.Terminal)
935 renderer.SetTheme(cfg.Theme)
936 startupAgentName := ""
937 startupContextPaths := []string(nil)
938 startupExtensionNames := []string(nil)
939 startupSkillNames := []string(nil)
940 initialGoalStatus := core.GoalStatus("")
941 if cfg.CurrentGoal != nil {
942 if goal := cfg.CurrentGoal(); goal != nil {
943 initialGoalStatus = goal.Status
944 }
945 }
946 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
947 startupAgentName = cfg.StartupAgentName
948 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
949 startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
950 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
951 }
952 i := &Interactive{
953 cfg: cfg,
954 view: &tui.View{
955 Theme: cfg.Theme,
956 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
957 FlatTools: cfg.FlatTools,
958 CompactUser: cfg.CompactUser,
959 CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
960 StartupAgentName: startupAgentName,
961 StartupContextPaths: startupContextPaths,
962 StartupExtensionNames: startupExtensionNames,
963 StartupSkillNames: startupSkillNames,
964 },
965 // Prompt is the standard half-block accent bar used by chat
966 // speaker labels too, so the input gutter matches the rest
967 // of the UI.
968 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
969 rend: renderer,
970 toolCalls: map[string]*tui.ToolCallView{},
971 toolGate: map[string]int{},
972 dirty: make(chan struct{}, 8),
973 modelRefresh: make(chan modelRefreshResult, 1),
974 startupPreDone: make(chan startupPreResult, 1),
975 dialog: newLoginDialog(),
976 modelDialog: newModelDialog(),
977 llamaDialog: newLlamaDialog(),
978 rescueDialog: newRescueDialog(),
979 sessionDialog: newSessionDialog(),
980 subagentsDialog: newSubagentsDialog(),
981 jumpDialog: newJumpDialog(),
982 btwDialog: newBtwDialog(),
983 skillsDialog: newSkillsDialog(),
984 changelogDialog: newChangelogDialog(),
985 confirmDialog: newConfirmDialog(),
986 logoutDialog: newLogoutDialog(),
987 telegramDialog: newTelegramDialog(),
988 settingsDialog: newSettingsDialog(),
989 sessionOpsDialog: newSessionOpsDialog(),
990 sessionTreeDialog: newSessionTreeDialog(),
991 extPanel: newExtPanelDialog(),
992 extStatuses: map[string]map[string]extensionStatus{},
993 extWidgets: map[string]map[string]extensionWidget{},
994 suggest: newSlashSuggester(),
995 fileSuggest: newFileSuggester(),
996 spin: newSpinner(cfg.Theme),
997 inputHistoryIndex: -1,
998 clock: time.Now,
999 goalStatus: initialGoalStatus,
1000 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
1001 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
1002 }
1003 i.btwDialog.setCloseHook(func() {
1004 i.confirmDialog.CancelChildConfirmations("side chat closed")
1005 })
1006 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
1007 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
1008 if cfg.LlamaCPPConfig != nil {
1009 baseURL, _, err := cfg.LlamaCPPConfig()
1010 i.llamaConfigured = err == nil && baseURL != ""
1011 }
1012 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg, cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled)
1013 if cfg.Agent != nil {
1014 i.agent = cfg.Agent
1015 i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
1016 i.cumUsage = cfg.Agent.Cost()
1017 // Rehydrate the "context used" gauge from the last persisted
1018 // turn. Without this the status bar reads 0.0% after a resume
1019 // until the next turn lands a usage event.
1020 if last := cfg.Agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1021 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1022 }
1023 // Cap the first paint at the tail of the transcript so
1024 // resuming a multi-thousand-message session doesn't block
1025 // on rendering every prior turn before showing anything.
1026 if len(i.view.Messages) > initialResumeTailLimit {
1027 i.view.TailLimit = initialResumeTailLimit
1028 }
1029 }
1030 i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle)
1031 if cfg.InitialSessionTitlePending {
1032 i.sessionTitle = ""
1033 i.titleRealPromptSeen = false
1034 i.titleGenerationStarted = false
1035 } else {
1036 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
1037 i.titleGenerationStarted = i.titleRealPromptSeen
1038 }
1039 i.applyAutoSubagentsTool()
1040 return i
1041}
1042
1043// ExitedViaCtrlC reports whether the last Run returned because the user
1044// deliberately exited with Ctrl+C.
1045func (i *Interactive) ExitedViaCtrlC() bool {
1046 i.mu.Lock()
1047 defer i.mu.Unlock()
1048 return i.ctrlCExit
1049}
1050
1051// markCtrlCExit records that the current Run is ending via Ctrl+C.
1052func (i *Interactive) markCtrlCExit() {
1053 i.mu.Lock()
1054 i.ctrlCExit = true
1055 i.mu.Unlock()
1056}
1057
1058// Run blocks until the user quits.
1059func (i *Interactive) Run(ctx context.Context) error {
1060 i.mu.Lock()
1061 i.runCtx = ctx
1062 i.mu.Unlock()
1063 term := i.cfg.Terminal
1064 restore, err := term.EnterRaw()
1065 if err != nil {
1066 return err
1067 }
1068 defer restore()
1069 i.markInteractiveStarted()
1070 defer i.cancelSessionTitle()
1071 defer i.sessionDialog.Close()
1072 defer func() {
1073 if i.telegramBridge != nil {
1074 i.telegramBridge.Stop()
1075 }
1076 }()
1077
1078 // Enabling mouse reporting steals click-drag selection from the
1079 // host terminal (VS Code, Ghostty, iTerm). The user prefers native
1080 // selection over the wheel-speed boost, so we no longer turn it
1081 // on automatically. Wheel events fall through to the terminal's
1082 // own scrollback handler.
1083 // Keep zut on the terminal's main screen. We intentionally do not
1084 // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
1085 // chat as normal terminal flow/scrollback and redraws only the live
1086 // input/status block on normal typing.
1087 _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
1088 // Tell the terminal our working directory (OSC 7) so "new tab /
1089 // split here" opens in the launch cwd instead of inheriting a stale
1090 // directory from an extension subprocess (see issue #38). Harmless
1091 // on terminals that ignore it.
1092 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
1093 _, _ = term.Write([]byte(seq))
1094 }
1095 defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
1096 i.applyInputCursorColor()
1097
1098 // Streaming pacer: drains buffered text deltas at a steady rate
1099 // so typewriter feel is identical across providers regardless of
1100 // upstream chunk size. Starts here so it lives for the whole
1101 // session and exits with ctx.
1102 go i.runStreamPacer(ctx)
1103
1104 cols, rows := term.Size()
1105 i.rend.Resize(cols, rows)
1106 renderScheduler := newLatestFrameScheduler()
1107 i.renderScheduler.Store(renderScheduler)
1108 go renderScheduler.run(func(req renderRequest) {
1109 c, r := term.Size()
1110 i.rend.Resize(c, r)
1111 if req.theme != nil {
1112 i.rend.SetTheme(*req.theme)
1113 }
1114 if req.clear {
1115 i.rend.Clear()
1116 }
1117 if req.invalidate {
1118 i.rend.Invalidate()
1119 }
1120 i.redraw()
1121 })
1122 defer renderScheduler.stop()
1123 term.OnResize(func() {
1124 // Resize and redraw share the renderer owner. The owner reads the
1125 // latest terminal size just before its next frame.
1126 i.renderRevision.Add(1)
1127 renderScheduler.request(false, false)
1128 })
1129
1130 if i.cfg.StartupPre != "" {
1131 i.deferredInitialInput = i.cfg.InitialInput
1132 i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
1133 i.awaitingStartupPre = true
1134 i.Submit(i.cfg.StartupPre)
1135 } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
1136 i.Submit(i.cfg.InitialInput)
1137 } else {
1138 if i.cfg.InitialInput != "" {
1139 i.ed.SetValue(i.cfg.InitialInput)
1140 }
1141 i.startRestoredCompactHandoff(ctx)
1142 }
1143
1144 // Stamp the welcome time and schedule a one-shot redraw at the
1145 // expiry so the version suffix disappears on its own even if the
1146 // user hasn't typed anything yet.
1147 i.welcomeStart = time.Now()
1148 time.AfterFunc(welcomeVersionDuration, i.invalidate)
1149
1150 // If the agent was constructed with a pre-loaded transcript
1151 // (--continue, --resume, --session) pin the viewport at the
1152 // bottom so the most recent reply (and any prompt the user just
1153 // typed) is fully visible. Earlier behaviour parked the view at
1154 // the last user turn, which could leave the latest message clipped
1155 // off the bottom of the page on long sessions.
1156 if i.agent != nil {
1157 if msgs := i.agent.Messages(); len(msgs) > 0 {
1158 i.scrollToBottom()
1159 }
1160 }
1161
1162 // No credential at startup? Auto-open the login dialog, and mark
1163 // the status line. The user can Esc out of the dialog if they
1164 // want to dismiss it (e.g. to check /help or /exit first).
1165 if i.agent == nil {
1166 i.statusErr = "not logged in. pick a login method below or press esc to dismiss."
1167 i.dialog.Open(i.cfg.ZutHome)
1168 }
1169
1170 // Input goroutine. Buffered generously so a drag-drop that the
1171 // terminal delivers as a burst of single-character key events
1172 // (no bracketed paste) can be drained in one main-loop pass
1173 // instead of triggering a redraw per character.
1174 keys := make(chan tui.Key, 256)
1175 go func() {
1176 reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
1177 for {
1178 k, err := reader.Read()
1179 if err != nil {
1180 return
1181 }
1182 keys <- k
1183 }
1184 }()
1185
1186 // Subscribe to auth events.
1187 var authEvents <-chan auth.Event
1188 if i.cfg.AuthManager != nil {
1189 authEvents = i.cfg.AuthManager.Events()
1190 }
1191
1192 // Animation ticker: drives spinner and dialog-related redraws when
1193 // nothing else changed. 120ms is slow enough that highlighting a huge
1194 // transcript doesn't spin the cpu.
1195 tick := time.NewTicker(120 * time.Millisecond)
1196 defer tick.Stop()
1197
1198 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
1199 // at most once every redrawMinInterval. Huge tool-result dumps can
1200 // fire hundreds of invalidations while the user is typing; without
1201 // this, the input goroutine never gets CPU and keystrokes lag.
1202 const redrawMinInterval = 16 * time.Millisecond
1203 var lastRedraw time.Time
1204 var pendingRedraw bool
1205 var pendingTimer *time.Timer
1206
1207 drainPending := func() {
1208 if pendingTimer != nil {
1209 pendingTimer.Stop()
1210 pendingTimer = nil
1211 }
1212 if pendingRedraw {
1213 pendingRedraw = false
1214 lastRedraw = time.Now()
1215 renderScheduler.request(false, false)
1216 }
1217 }
1218
1219 requestRedraw := func() {
1220 since := time.Since(lastRedraw)
1221 if since >= redrawMinInterval {
1222 // Redrawing right now subsumes any pending redraw, so clear
1223 // the throttle state. Without this, a pending flag stays
1224 // stuck at true and subsequent invalidate() calls within
1225 // redrawMinInterval get dropped — which is exactly how the
1226 // final "turn finished" frame went missing until the user
1227 // nudged the ui by typing or scrolling.
1228 if pendingTimer != nil {
1229 pendingTimer.Stop()
1230 }
1231 pendingRedraw = false
1232 lastRedraw = time.Now()
1233 renderScheduler.request(false, false)
1234 return
1235 }
1236 if pendingRedraw {
1237 return // already scheduled
1238 }
1239 pendingRedraw = true
1240 wait := redrawMinInterval - since
1241 if pendingTimer == nil {
1242 pendingTimer = time.AfterFunc(wait, func() {
1243 // Poke the dirty channel so the main loop wakes and
1244 // drains the pending redraw on its own goroutine. We
1245 // can't call drainPending here directly — it touches
1246 // closure state shared with the main loop.
1247 i.invalidate()
1248 })
1249 } else {
1250 pendingTimer.Reset(wait)
1251 }
1252 }
1253
1254 i.invalidate()
1255
1256 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
1257 changelog := i.cfg.ChangelogChan // single-shot, see case below
1258
1259 for {
1260 select {
1261 case <-ctx.Done():
1262 return ctx.Err()
1263 case k := <-keys:
1264 if done := i.handleKey(ctx, k); done {
1265 return nil
1266 }
1267 // Drain any keystrokes that arrived during this iteration.
1268 // VS Code (and other terminals that don't bracket drops as
1269 // paste) deliver a path one rune at a time — without this
1270 // loop the editor would render between every rune and a
1271 // long path on a heavy transcript would visibly type in.
1272 drain:
1273 for {
1274 select {
1275 case k2 := <-keys:
1276 if done := i.handleKey(ctx, k2); done {
1277 return nil
1278 }
1279 default:
1280 break drain
1281 }
1282 }
1283 i.invalidate()
1284 case ev := <-authEvents:
1285 i.handleAuthEvent(ev)
1286 i.invalidate()
1287 case result := <-i.modelRefresh:
1288 i.mu.Lock()
1289 i.modelRefreshing = false
1290 i.mu.Unlock()
1291 i.openModelPickerAfterRefresh(result.err)
1292 i.invalidate()
1293 case result := <-i.startupPreDone:
1294 i.applyStartupPreResult(result)
1295 i.invalidate()
1296 case info, ok := <-updates:
1297 if ok && info.Available {
1298 i.mu.Lock()
1299 i.updateInfo = info
1300 i.mu.Unlock()
1301 i.invalidate()
1302 }
1303 updates = nil // single-shot; subsequent iterations skip this case
1304 case cl, ok := <-changelog:
1305 if ok && cl.Body != "" {
1306 i.changelogDialog.Open(cl.Version, cl.URL, cl.Body)
1307 i.invalidate()
1308 }
1309 changelog = nil // single-shot
1310 case event, ok := <-i.sessionLoads:
1311 i.mu.Lock()
1312 if !ok {
1313 i.sessionLoads = nil
1314 i.sessionDialog.ApplyLoadClosed()
1315 i.mu.Unlock()
1316 i.invalidate()
1317 break
1318 }
1319 i.sessionDialog.ApplyLoad(event)
1320 i.mu.Unlock()
1321 i.invalidate()
1322 case event, ok := <-i.sessionTreeLoads:
1323 i.mu.Lock()
1324 if !ok {
1325 i.sessionTreeLoads = nil
1326 i.sessionTreeDialog.ApplyLoadClosed()
1327 i.mu.Unlock()
1328 i.invalidate()
1329 break
1330 }
1331 if err := i.sessionTreeDialog.ApplyLoad(event); err != nil {
1332 i.sessionTreeLoads = nil
1333 i.statusErr = "tree: no readable session family"
1334 i.statusOK = ""
1335 }
1336 i.mu.Unlock()
1337 i.invalidate()
1338 case <-i.dirty:
1339 requestRedraw()
1340 case <-tick.C:
1341 // Always drain a pending redraw on the tick. This is the
1342 // safety net that catches the case where the dirty channel
1343 // was saturated when the final "turn finished" invalidate
1344 // fired, or where the throttle scheduled a deferred redraw
1345 // and the AfterFunc-driven invalidate got dropped on a
1346 // full channel.
1347 drainPending()
1348 // Only force a periodic redraw when something is actually
1349 // animating (the main spinner during a busy turn, or the
1350 // btw side-chat spinner while it's awaiting a response).
1351 // Static pickers (model, completed session, jump, etc.) don't
1352 // need the tick and firing it cancels the terminal's cursor
1353 // blink inside dialogs that host their own editor (btw),
1354 // because each frame re-emits hide-cursor + show-cursor.
1355 // A session picker that is still loading is an exception so
1356 // its dialog-local spinner can advance.
1357 //
1358 // The subagent dashboard is also animated: its rows reflect
1359 // background subprocesses whose activity / age change
1360 // without any user input. Without the tick redraw the
1361 // dashboard freezes on the snapshot taken when the user
1362 // last pressed a key. We exclude the dashboard when one
1363 // of its inline editors (spawn task or prompt composer)
1364 // is active so the cursor blink in those editors works
1365 // the same way it does inside btw.
1366 i.mu.Lock()
1367 busy := i.busy
1368 subagentActivityActive := i.subagentActivityActive
1369 sessionLoading := i.sessionDialog.Loading()
1370 i.mu.Unlock()
1371 if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1372 requestRedraw()
1373 }
1374 }
1375 }
1376}
1377
1378func (i *Interactive) requestRendererClear() {
1379 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1380 scheduler.request(true, false)
1381 return
1382 }
1383 if i.rend != nil {
1384 i.rend.Clear()
1385 }
1386}
1387
1388func (i *Interactive) requestRendererInvalidate() {
1389 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1390 scheduler.request(false, true)
1391 return
1392 }
1393 if i.rend != nil {
1394 i.rend.Invalidate()
1395 }
1396}
1397
1398func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1399 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1400 scheduler.requestTheme(theme)
1401 return
1402 }
1403 if i.rend != nil {
1404 i.rend.SetTheme(theme)
1405 }
1406}
1407
1408func (i *Interactive) invalidate() {
1409 i.renderRevision.Add(1)
1410 // Keep ordinary state changes on the main-loop throttle. The scheduler
1411 // owns terminal output, but scheduling it here would bypass
1412 // redrawMinInterval and paint every intermediate tool-event frame.
1413 select {
1414 case i.dirty <- struct{}{}:
1415 default:
1416 }
1417}
1418
1419func (i *Interactive) cachedChatLocked(cols int) []string {
1420 // A busy frame contains mutable streaming/tool state. Keep the stable
1421 // transcript cache, but never return a full-frame cache entry that would
1422 // hide a newly arrived delta or completed result.
1423 if i.busy || i.streamOn || i.streamFlushPending {
1424 return i.buildChatLocked(cols)
1425 }
1426 key := i.chatCacheKeyLocked(cols)
1427 if i.chatCacheValid && i.chatCacheKey == key {
1428 return append([]string(nil), i.chatCache...)
1429 }
1430 chat := i.buildChatLocked(cols)
1431 key = i.chatCacheKeyLocked(cols)
1432 i.chatCache = append(i.chatCache[:0], chat...)
1433 i.chatCacheKey = key
1434 i.chatCacheValid = true
1435 return chat
1436}
1437
1438func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
1439 var rev uint64
1440 if i.agent != nil {
1441 rev = i.agent.Revision()
1442 }
1443 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1444 return chatCacheKey{
1445 cols: cols,
1446 agentRev: rev,
1447 statusOK: i.statusOK,
1448 statusErr: i.statusErr,
1449 help: strings.Join(i.helpBlock, "\n"),
1450 extNotes: strings.Join(i.extNotes, "\n"),
1451 extStatuses: i.extensionStatusesKeyLocked(),
1452 extWidgets: i.extensionWidgetsKeyLocked(),
1453 reloadErrors: strings.Join(i.reloadErrors, "\n"),
1454 updateAvailable: i.updateInfo.Available,
1455 updateCurrent: i.updateInfo.Current,
1456 updateLatest: i.updateInfo.Latest,
1457 updateURL: i.updateInfo.URL,
1458 welcomeShowVer: showVer,
1459 expandAll: i.view.ExpandAll,
1460 tailLimit: i.view.TailLimit,
1461 renderedMessageCount: len(i.view.Messages),
1462 viewCacheRev: i.view.RenderCacheRevision,
1463 }
1464}
1465
1466func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
1467 keys := make([]string, 0, len(groups))
1468 for key := range groups {
1469 keys = append(keys, key)
1470 }
1471 sort.Strings(keys)
1472 return keys
1473}
1474
1475func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
1476 items := groups[outer]
1477 keys := make([]string, 0, len(items))
1478 for key := range items {
1479 keys = append(keys, key)
1480 }
1481 sort.Strings(keys)
1482 return keys
1483}
1484
1485func (i *Interactive) extensionStatusesKeyLocked() string {
1486 var sb strings.Builder
1487 extNames := sortedNestedOuterKeys(i.extStatuses)
1488 for _, extName := range extNames {
1489 keys := sortedNestedInnerKeys(i.extStatuses, extName)
1490 for _, key := range keys {
1491 status := i.extStatuses[extName][key]
1492 fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
1493 }
1494 }
1495 return sb.String()
1496}
1497
1498func (i *Interactive) extensionWidgetsKeyLocked() string {
1499 var sb strings.Builder
1500 extNames := sortedNestedOuterKeys(i.extWidgets)
1501 for _, extName := range extNames {
1502 ids := sortedNestedInnerKeys(i.extWidgets, extName)
1503 for _, id := range ids {
1504 widget := i.extWidgets[extName][id]
1505 fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
1506 for _, line := range widget.Lines {
1507 sb.WriteString(line)
1508 sb.WriteByte('\n')
1509 }
1510 }
1511 }
1512 return sb.String()
1513}
1514
1515const maxExtensionWidgetRows = 12
1516const maxExtensionStatusRows = 6
1517
1518// maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back
1519// above the input, leaving one row for the truncation marker.
1520const maxNarrowExtensionChromeRows = 6
1521
1522func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1523 var out []tui.RightBarWidget
1524 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1525 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1526 widget := i.extWidgets[extName][id]
1527 if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1528 continue
1529 }
1530 out = append(out, tui.RightBarWidget{
1531 Extension: extName,
1532 ID: id,
1533 Title: widget.Title,
1534 Lines: append([]string(nil), widget.Lines...),
1535 })
1536 }
1537 }
1538 return out
1539}
1540
1541func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1542 return i.extensionChromeLinesAtLocked(cols, false)
1543}
1544
1545func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1546 lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1547 if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1548 return lines
1549 }
1550 limit := maxNarrowExtensionChromeRows
1551 trimmed := append([]string(nil), lines[:limit-1]...)
1552 trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1553 return trimmed
1554}
1555
1556func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1557 var out []string
1558 bodyWidth := cols - 2
1559 if bodyWidth < 1 {
1560 bodyWidth = 1
1561 }
1562 widgetRows := 0
1563 widgetTruncated := false
1564 appendWidgetRow := func(line string) bool {
1565 if widgetRows >= maxExtensionWidgetRows-1 {
1566 widgetTruncated = true
1567 return false
1568 }
1569 out = append(out, line)
1570 widgetRows++
1571 return true
1572 }
1573
1574 stopWidgets := false
1575 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1576 if stopWidgets {
1577 break
1578 }
1579 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1580 widget := i.extWidgets[extName][id]
1581 if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1582 continue
1583 }
1584 label := " [" + extName + "]"
1585 if widget.Title != "" {
1586 label += " " + widget.Title
1587 }
1588 if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1589 stopWidgets = true
1590 break
1591 }
1592 for _, line := range widget.Lines {
1593 if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1594 stopWidgets = true
1595 break
1596 }
1597 }
1598 if stopWidgets {
1599 break
1600 }
1601 }
1602 }
1603 if widgetTruncated {
1604 out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1605 }
1606
1607 statusRows := 0
1608 statusTruncated := false
1609 for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1610 for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1611 if statusRows >= maxExtensionStatusRows-1 {
1612 statusTruncated = true
1613 break
1614 }
1615 status := i.extStatuses[extName][key]
1616 color := i.cfg.Theme.Muted
1617 switch status.Level {
1618 case "warn":
1619 color = i.cfg.Theme.Warning
1620 case "error":
1621 color = i.cfg.Theme.Error
1622 case "success":
1623 color = i.cfg.Theme.Tool
1624 }
1625 label := " [" + extName + "] " + status.Text
1626 out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
1627 statusRows++
1628 }
1629 if statusTruncated {
1630 break
1631 }
1632 }
1633 if statusTruncated {
1634 out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1635 }
1636 return out
1637}
1638
1639func (i *Interactive) stableChatRowsLocked(cols int) []string {
1640 key := i.chatCacheKeyLocked(cols)
1641 if i.stableChatCacheValid && i.stableChatCacheKey == key {
1642 return append([]string(nil), i.stableChatCache...)
1643 }
1644
1645 renderView := i.view.CloneForRender()
1646 renderView.Streaming = ""
1647 renderView.StreamingActive = false
1648 renderView.ToolCalls = nil
1649 renderView.Err = ""
1650 if !i.renderOutsideLock {
1651 rows := renderView.Build(cols)
1652 i.stableChatCache = append(i.stableChatCache[:0], rows...)
1653 i.stableChatCacheKey = key
1654 i.stableChatCacheValid = true
1655 return append([]string(nil), rows...)
1656 }
1657 // Markdown, syntax highlighting, and wrapping are the expensive part of
1658 // a frame. Build the immutable snapshot without holding the interactive
1659 // mutex so key processing can continue while a cold transcript renders.
1660 i.mu.Unlock()
1661 rows := renderView.Build(cols)
1662 i.mu.Lock()
1663 if i.view.MessagesRevision == renderView.MessagesRevision &&
1664 i.view.RenderCacheRevision == renderView.RenderCacheRevision {
1665 i.view.AdoptRenderCacheFrom(renderView)
1666 }
1667
1668 i.stableChatCache = append(i.stableChatCache[:0], rows...)
1669 i.stableChatCacheKey = key
1670 i.stableChatCacheValid = true
1671 return append([]string(nil), rows...)
1672}
1673
1674func (i *Interactive) buildChatLocked(cols int) []string {
1675 if i.agent != nil {
1676 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1677 i.view.MessagesRevision = i.agent.Revision()
1678 } else {
1679 i.view.Messages = nil
1680 i.view.MessagesRevision = 0
1681 }
1682 // Pacer flush: while the streaming pacer is still draining the
1683 // buffer (i.e. EvAssistantMessage already fired but more runes
1684 // are queued), the final assistant message is already in
1685 // i.agent.Messages() in full. Painting it in the transcript
1686 // AND the streaming block at the same time shows the user the
1687 // complete text immediately — which defeats the whole pacer.
1688 // Hide the last message until the pacer catches up; once the
1689 // flush-pending latch clears, the message is revealed (the
1690 // streaming block disappears the same frame because streamOn
1691 // flips off, so the transition is seamless).
1692 if i.streamFlushPending && len(i.view.Messages) > 0 {
1693 i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
1694 }
1695 i.view.Streaming = i.streaming.String()
1696 i.view.StreamingActive = i.streamOn
1697 // Guard against the narrow race where EvAssistantMessage has
1698 // just promoted a streaming reply into the transcript but a
1699 // render tick hasn't flipped streamOn off yet. Without the
1700 // guard, the same text would appear twice (once as the
1701 // in-flight streaming block, once as the last transcript
1702 // message). We detect the duplicate strictly: the last
1703 // assistant message's visible text must equal the streaming
1704 // buffer. Just matching on role is too broad — it also hides
1705 // the next round's typewriter streaming after a tool turn,
1706 // because the last transcript message is always assistant
1707 // (the tool-use block) until the follow-up summary lands.
1708 if i.streamOn && i.streaming.Len() > 0 {
1709 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
1710 if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
1711 i.view.StreamingActive = false
1712 }
1713 }
1714 }
1715 // Live tool-call view: only shown while a turn is in flight. Once
1716 // the agent is idle, every tool call has already been folded into
1717 // the transcript (as assistant.ToolCallBlock + a tool-role message),
1718 // so showing v.ToolCalls a second time would duplicate them below
1719 // the final assistant text — which looks like the summary came
1720 // "before" the tools.
1721 i.view.ToolCalls = i.view.ToolCalls[:0]
1722 if i.busy {
1723 for _, id := range i.toolOrder {
1724 // Deterministic ordering: a tool block stays hidden until
1725 // the paced assistant text that preceded it has finished
1726 // typing out. toolOrder is append-only in arrival order,
1727 // so once one tool is still gated, every later tool is too,
1728 // so stop here to avoid showing a tool out of sequence.
1729 if !i.toolGateOpenLocked(id) {
1730 break
1731 }
1732 if tc, ok := i.toolCalls[id]; ok {
1733 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
1734 }
1735 }
1736 }
1737 i.view.Err = i.statusErr
1738 // Live streaming/tool rows are appended to the chat buffer (not
1739 // hoisted into a separate live block above the editor). That keeps
1740 // the renderer's diff view append-only: when a tool finishes the
1741 // rows update in place at the end of the buffer, instead of the
1742 // whole bottom band shrinking and shifting chat lines around.
1743 i.liveBlock = nil
1744 stable := i.stableChatRowsLocked(cols)
1745 chat := append([]string(nil), stable...)
1746 chat = append(chat, i.view.BuildLive(cols)...)
1747
1748 // Welcome banner: shown at the top of the chat area when there is
1749 // no transcript yet. Disappears after the first message is sent.
1750 // The version suffix is shown for welcomeVersionDuration after
1751 // startup, then drops off automatically.
1752 if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
1753 showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1754 chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
1755 }
1756
1757 // Update-available banner: prepended above everything else so it's
1758 // the first thing the user sees when opening a new zut session.
1759 // Once rendered, it stays until the user updates to a newer
1760 // version — we don't persist a "dismissed" flag because this is
1761 // cheap and re-showing it is how most users remember to update.
1762 if i.updateInfo.Available {
1763 banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
1764 chat = append(banner, chat...)
1765 }
1766
1767 // /help block: appended to the transcript so it appears at the
1768 // bottom of the chat area (right above the status bar / editor).
1769 // Prepending it would push long conversations off the top of the
1770 // viewport, which users would miss entirely.
1771 if len(i.helpBlock) > 0 {
1772 chat = append(chat, i.helpBlock...)
1773 }
1774
1775 if i.statusOK != "" {
1776 // Hard-truncate the OK line to the visible width so a long
1777 // session path ("resumed session: /Users/.../sessions/...")
1778 // doesn't overflow past the right edge and look broken on a
1779 // narrow terminal.
1780 line := "✓ " + i.statusOK
1781 if cols > 4 && len(line) > cols {
1782 line = line[:cols-3] + "..."
1783 }
1784 chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
1785 }
1786
1787 // Live shell-escape output (!command / entry.pre) streams into the
1788 // transcript area while the command runs, then is replaced by the
1789 // final user-context message when it finishes.
1790 if i.shellRunning && i.shellLive != "" {
1791 chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
1792 chat = append(chat, "")
1793 }
1794
1795 // Extension notes (notify / display) live just under the
1796 // transcript, above the dialog/editor band. Cleared by /clear.
1797 if len(i.extNotes) > 0 {
1798 chat = append(chat, i.extNotes...)
1799 chat = append(chat, "")
1800 }
1801
1802 // Reload failures remain in the scrolling chat until /clear. They are
1803 // host-only display state, not provider messages or persisted context.
1804 // While the latest failure is still the temporary status error, hide its
1805 // stored copy to avoid painting the same message twice.
1806 reloadErrors := i.reloadErrors
1807 if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1808 reloadErrors = reloadErrors[:n-1]
1809 }
1810 if len(reloadErrors) > 0 {
1811 chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1812 chat = append(chat, "")
1813 }
1814
1815 // Strip trailing blank rows so the chat content sits flush
1816 // against the new "blank above status bar" row added by the
1817 // bottom-region assembly. Build() ends every message with a
1818 // blank separator; without this trim, the final message in
1819 // the transcript would have its own trailing blank plus the
1820 // status block's leading blank, doubling the gap.
1821 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1822 chat = chat[:len(chat)-1]
1823 }
1824 return chat
1825}
1826
1827// sessionsRoot returns the session namespace for this run.
1828func (i *Interactive) sessionsRoot() string {
1829 if i.cfg.SessionsRoot != "" {
1830 return i.cfg.SessionsRoot
1831 }
1832 return i.cfg.ZutHome
1833}
1834
1835// lastCols returns the current terminal width in columns.
1836func (i *Interactive) lastCols() int {
1837 cols, _ := i.cfg.Terminal.Size()
1838 return cols
1839}
1840
1841// chatPage returns the number of chat rows currently visible, used
1842// as the page size for PageUp/PageDown.
1843func (i *Interactive) chatPage() int {
1844 _, rows := i.cfg.Terminal.Size()
1845 p := rows - 6 // rough reservation for status + editor + a dialog line
1846 if p < 4 {
1847 p = 4
1848 }
1849 return p
1850}
1851
1852// scrollBy adjusts the scroll offset. Positive = up (into history).
1853// Clearing the parked-turn label when we're back at the bottom means
1854// the "viewing turn N" footer goes away automatically as soon as you
1855// scroll back to the live tail.
1856func (i *Interactive) scrollBy(delta int) {
1857 i.mu.Lock()
1858 i.scrollOffset += delta
1859 if i.scrollOffset < 0 {
1860 i.scrollOffset = 0
1861 }
1862 if i.scrollOffset == 0 {
1863 i.parkedTurn = 0
1864 i.parkedTotal = 0
1865 }
1866 if i.rend != nil {
1867 // VS Code's terminal is especially prone to leaving stray
1868 // wrapped-character fragments behind during scroll-driven
1869 // viewport changes. Force a full repaint on scroll, but
1870 // avoid a whole-screen clear because that visibly flickers.
1871 i.requestRendererInvalidate()
1872 }
1873 i.mu.Unlock()
1874 i.invalidate()
1875}
1876
1877// anchorScrollOffset keeps the user's reading position pinned when the
1878// chat buffer grows/shrinks or the viewport height changes between two
1879// redraws while they're scrolled up.
1880//
1881// scrollOffset is measured from the bottom of the chat buffer, so the
1882// top visible row is start = chatLen - scrollOffset - chatRows. To hold
1883// `start` constant we adjust the offset by the buffer-length delta minus
1884// the viewport-height delta. The result is clamped to [0, newLen] so a
1885// shrinking buffer can't push it negative.
1886func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1887 adj := (newLen - prevLen) - (newRows - prevRows)
1888 offset += adj
1889 if offset < 0 {
1890 offset = 0
1891 }
1892 if offset > newLen {
1893 offset = newLen
1894 }
1895 return offset
1896}
1897
1898// scrollToBottom pins the view to the latest content.
1899func (i *Interactive) scrollToBottom() {
1900 i.mu.Lock()
1901 i.scrollOffset = 0
1902 i.parkedTurn = 0
1903 i.parkedTotal = 0
1904 // Reset the auto-follow baseline. scrollToBottom is the resume /
1905 // session-swap snap point, where the chat buffer changes length
1906 // wholesale. Without zeroing these, the next render's follow guard
1907 // compares the fresh transcript's length against a stale baseline
1908 // and nudges scrollOffset, which reads as a viewport jump right
1909 // after resume. See commit 43da5e5 for the same fix on new turns.
1910 i.prevChatLen = 0
1911 i.prevChatCols = 0
1912 i.requestRendererInvalidate()
1913 i.mu.Unlock()
1914 i.invalidate()
1915}
1916
1917func (i *Interactive) redraw() {
1918 i.mu.Lock()
1919 renderRevision := i.renderRevision.Load()
1920
1921 cols, rows := i.cfg.Terminal.Size()
1922 mainCols := cols
1923 rightBarWidgets := i.rightBarWidgetsLocked()
1924 rightBarActive := false
1925 var rightBarWidth int
1926 if !i.rightBarHidden && len(rightBarWidgets) > 0 {
1927 if width, rail, ok := tui.RightBarColumns(cols); ok {
1928 mainCols = width
1929 rightBarWidth = rail
1930 rightBarActive = true
1931 }
1932 }
1933 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1934 i.renderOutsideLock = true
1935 chat := i.cachedChatLocked(mainCols)
1936 i.renderOutsideLock = false
1937 if i.renderRevision.Load() != renderRevision {
1938 i.mu.Unlock()
1939 return
1940 }
1941
1942 // Dialogs (login or model picker) render between chat and the editor.
1943 var dialog []string
1944 switch {
1945 case i.dialog.Active():
1946 dialog = i.dialog.Render(i.cfg.Theme, mainCols)
1947 case i.modelDialog.Active():
1948 dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
1949 case i.llamaDialog.Active():
1950 dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
1951 case i.rescueDialog.Active():
1952 dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
1953 case i.sessionDialog.Active():
1954 // Reserve rows for the editor (~3), status line (1-2),
1955 // dialog chrome (header + hint + rule + indicators, ~5),
1956 // and leave the remainder for session rows. Minimum of 3
1957 // rows so even a very small terminal shows something.
1958 _, rows := i.cfg.Terminal.Size()
1959 avail := rows - 12
1960 if avail < 3 {
1961 avail = 3
1962 }
1963 i.sessionDialog.MaxRows = avail
1964 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1965 case i.subagentsDialog.Active():
1966 // The dashboard is composed into the bottom-sticky dialog band.
1967 // Reserve its leading gap, frame padding, one chat row, and the
1968 // renderer margin so a long list or expanded snapshot never pushes
1969 // its top rows out of the terminal viewport.
1970 avail := rows - 4
1971 if avail < 4 {
1972 avail = 4
1973 }
1974 i.subagentsDialog.SetMaxRows(avail)
1975 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1976 case i.jumpDialog.Active():
1977 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
1978 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1979 dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
1980 case i.confirmDialog.Active():
1981 if i.btwDialog.Active() {
1982 // Keep the side-chat transcript and its live tool preview visible
1983 // above confirmation, matching how the main transcript remains
1984 // visible while --no-yolo waits for a decision.
1985 dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
1986 } else {
1987 dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
1988 }
1989 case i.btwDialog.Active():
1990 dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
1991 case i.skillsDialog.Active():
1992 dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
1993 case i.changelogDialog.Active():
1994 dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
1995 case i.logoutDialog.Active():
1996 dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
1997 case i.telegramDialog.Active():
1998 dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
1999 case i.settingsDialog.Active():
2000 dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
2001 case i.sessionOpsDialog.Active():
2002 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
2003 case i.sessionTreeDialog.Active():
2004 // Reserve rows for the editor, status, and tree chrome while using
2005 // the same budget for rendering and PageUp/PageDown movement.
2006 _, rows := i.cfg.Terminal.Size()
2007 avail := rows - 12
2008 if avail < 3 {
2009 avail = 3
2010 }
2011 i.sessionTreeDialog.MaxRows = avail
2012 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
2013 }
2014 if len(dialog) > 0 {
2015 dialog = padDialogFrame(dialog)
2016 }
2017 modalBackdrop := len(dialog) > 0
2018
2019 // Slash-command autocomplete: popup above the status line, only
2020 // when the editor starts with "/" and no dialog is already open.
2021 // Feed extension-registered commands into the suggester first so
2022 // they show up in tab-complete + the popup alongside the built-ins.
2023 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
2024 i.suggest.SetLlamaConfigured(i.llamaConfigured)
2025 if i.cfg.Extensions != nil {
2026 catalog := i.cfg.Extensions.Commands()
2027 extra := make([]slashCommand, 0, len(catalog))
2028 for _, c := range catalog {
2029 // The popup renders extension commands under a dedicated
2030 // "── extensions ───" divider, so the description doesn't
2031 // need to repeat the source. If the description is empty,
2032 // fall back to the extension name so the row isn't blank.
2033 desc := c.Description
2034 if strings.TrimSpace(desc) == "" {
2035 desc = "(" + c.Extension + ")"
2036 }
2037 extra = append(extra, slashCommand{
2038 Name: "/" + c.Name,
2039 Desc: desc,
2040 })
2041 }
2042 i.suggest.SetExtra(extra)
2043 }
2044 var suggest []string
2045 currentInput := i.ed.Value()
2046 if i.suggest.SkillInputStarted(currentInput) {
2047 var list []*skills.Skill
2048 if i.cfg.SkillSnapshot != nil {
2049 list = i.cfg.SkillSnapshot()
2050 }
2051 i.suggest.SetSkills(list)
2052 }
2053 // Slash popup renders even while the agent is busy so the user
2054 // can queue a destructive command (/clear, /compact, /logout,
2055 // /model) or a read-only one (/help, /jump, /sessions, etc.)
2056 // without waiting for the current turn to finish. The dispatcher
2057 // in runSlash already handles the busy case per-command: safe
2058 // ones run immediately, destructive ones cancel the turn first.
2059 i.fileSuggest.SetCWD(i.cfg.CWD)
2060 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
2061 if mainInputFocused && i.suggest.Active(currentInput) {
2062 suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
2063 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
2064 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
2065 }
2066
2067 // Detect overlay close (any dialog or slash/file suggestion popup
2068 // just transitioned from open to closed). Force a full redraw so
2069 // the rows the overlay occupied are guaranteed to be repainted
2070 // from the chat below, instead of the diff path leaving stale
2071 // dialog content behind. Equivalent to the user pressing ctrl+l.
2072 overlayOpen := len(dialog) > 0 || len(suggest) > 0
2073 if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
2074 // An overlay (dialog or slash/file popup) just closed, so the
2075 // bottom band shrinks. On terminals where we can drop
2076 // scrollback, a full Clear is the simplest way to guarantee
2077 // the vacated rows are repainted from the chat below.
2078 //
2079 // On VS Code's terminal closing a dialog leaves the stale
2080 // overlay rows in the retained scrollback (we can't drop them
2081 // with the quiet in-place diff). Run the same full Clear() that
2082 // Ctrl+L uses so the scrollback is purged and the conversation
2083 // is repainted clean, matching what the user expects after
2084 // dismissing a picker. Clear() is keepScrollback-aware and
2085 // emits \x1b[3J there.
2086 i.rend.Clear()
2087 }
2088 i.prevOverlayOpen = overlayOpen
2089 if len(suggest) > 0 {
2090 // One blank row above the popup so it doesn't sit flush
2091 // against the chat / welcome content above.
2092 suggest = append([]string{""}, suggest...)
2093 }
2094
2095 // Busy prefix shown at the far left of the status bar. The spinner owns
2096 // only its frame; the activity label reports the operation in progress.
2097 var busyPrefix string
2098 if i.busy {
2099 label := i.activity.label()
2100 if label == "" {
2101 label = "Preparing request"
2102 }
2103 busyPrefix = fmt.Sprintf("%s %s %s %s",
2104 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
2105 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
2106 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
2107 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
2108 )
2109 }
2110
2111 ctxMax := 0
2112 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
2113 ctxMax = m.ContextWindow
2114 }
2115 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2116 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
2117 workingWithStatus := statusPosition == workingPosition
2118 statusBusyPrefix := ""
2119 if workingWithStatus {
2120 statusBusyPrefix = busyPrefix
2121 }
2122 goalStatus := string(i.goalStatus)
2123 statusLines := tui.StatusBar(tui.StatusBarParams{
2124 Theme: i.cfg.Theme,
2125 Provider: i.cfg.Provider,
2126 Model: i.cfg.Model,
2127 Reasoning: i.cfg.Reasoning,
2128 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
2129 Busy: i.busy,
2130 BusyPrefix: statusBusyPrefix,
2131 CWD: i.cfg.CWD,
2132 Locked: i.cfg.Sandbox.Locked(),
2133 NoYolo: i.cfg.NoYolo,
2134 GoalStatus: goalStatus,
2135 Usage: i.cumUsage,
2136 Subscription: i.cfg.AuthMethod == "oauth",
2137 ContextUsed: i.lastCtxInput,
2138 ContextMax: ctxMax,
2139 AutoCompacting: i.autoCompacting,
2140 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
2141 Cols: mainCols,
2142 })
2143 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2144 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
2145 i.ed.Prompt = ""
2146 } else {
2147 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
2148 }
2149 edLines, curR, curC := i.ed.Render(mainCols)
2150 dashboardActive := i.subagentsDialog.Active()
2151 var allSubagentLines []string
2152 if !dashboardActive {
2153 now := i.clock()
2154 allSubagentLines = renderSubagentActivityLines(
2155 i.cfg.Theme,
2156 i.spin.FrameAt(now),
2157 i.activeSubagentActivitySnapshots(),
2158 mainCols,
2159 now,
2160 )
2161 }
2162 var workingLines []string
2163 if busyPrefix != "" && !workingWithStatus {
2164 workingLines = []string{" " + busyPrefix}
2165 }
2166 inputCursorOffset := 0
2167 inputCursorColOffset := 0
2168 switch inputStyle {
2169 case tui.InputStyleLines:
2170 edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
2171 inputCursorOffset = 1
2172 case tui.InputStyleBlock:
2173 edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
2174 inputCursorOffset = 1
2175 inputCursorColOffset = 2
2176 }
2177
2178 // "Sliding in" chips for messages the user typed while a turn is
2179 // in flight. Shown directly above the status bar so they're close
2180 // to the editor but don't push the chat around.
2181 var queue []string
2182 queued := append([]string(nil), i.queued...)
2183 if i.agent != nil {
2184 queued = append(queued, i.agent.PendingQueuedMessages()...)
2185 }
2186 if len(queued) > 0 {
2187 queue = append(queue, "")
2188 for _, q := range queued {
2189 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
2190 text := truncateLine(q, mainCols-17)
2191 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
2192 }
2193 // Hint row, rendered in the same muted tone as the model
2194 // info on the status bar so it reads as ambient metadata
2195 // rather than a chip. Tells the user how to recover the
2196 // most recent queued message back into the editor.
2197 hint := " Press " + slideBackChordHint() + " to slide back into input"
2198 queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
2199 }
2200
2201 extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
2202
2203 // Bottom-sticky sections (always visible, never scroll). Each
2204 // non-empty subsection (dialog, suggest popup, sliding-in queue)
2205 // is preceded by one blank row so it has air above the chat
2206 // content. The status block and editor get their own dedicated
2207 // blanks so spacing stays consistent whether or not a dialog or
2208 // popup is showing.
2209 composeBottom := func(subagentLines []string) (bottom []string, inputStartRow int) {
2210 bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(subagentLines)+len(edLines)+9)
2211 inputStartRow = -1
2212 if len(dialog) > 0 {
2213 bottom = append(bottom, "")
2214 }
2215 bottom = append(bottom, dialog...)
2216 // The subagent dashboard owns the bottom of the screen while it's
2217 // active: it has its own inline editors for spawn (`n`) and
2218 // prompt (`p`), so the main input would be a confusing second
2219 // caret. The suggest popup, sliding-in queue, status block, and
2220 // main editor are all hidden underneath it. Keystrokes still
2221 // reach handleKey — it routes them to subagentsDialog.HandleKey
2222 // before the editor ever sees them — so the only effect of this
2223 // branch is visual.
2224 if dashboardActive {
2225 return bottom, inputStartRow
2226 }
2227
2228 bottom = append(bottom, suggest...)
2229 bottom = append(bottom, queue...)
2230 lineInput := inputStyle == tui.InputStyleLines
2231 statusBelow := statusPosition == tui.StatusPositionBelowInput
2232 workingBelow := workingPosition == tui.WorkingPositionBelowInput
2233 subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput
2234
2235 var aboveInput []string
2236 aboveInput = append(aboveInput, extensionLines...)
2237 if !statusBelow {
2238 aboveInput = append(aboveInput, statusLines...)
2239 }
2240 if !workingBelow {
2241 aboveInput = append(aboveInput, workingLines...)
2242 }
2243 if !subagentBelow {
2244 aboveInput = append(aboveInput, subagentLines...)
2245 }
2246 var belowInput []string
2247 if subagentBelow {
2248 belowInput = append(belowInput, subagentLines...)
2249 }
2250 if workingBelow {
2251 belowInput = append(belowInput, workingLines...)
2252 }
2253 if statusBelow {
2254 belowInput = append(belowInput, statusLines...)
2255 }
2256
2257 bottom = append(bottom, "")
2258 bottom = append(bottom, aboveInput...)
2259 needsGapAboveInput := len(aboveInput) > 0 && !lineInput
2260 if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
2261 needsGapAboveInput = true
2262 }
2263 if needsGapAboveInput {
2264 bottom = append(bottom, "")
2265 }
2266 inputStartRow = len(bottom)
2267 bottom = append(bottom, edLines...)
2268 if len(belowInput) > 0 {
2269 // Running subagents sit immediately under the prompt by default.
2270 // Other below-input chrome keeps the existing breathing room.
2271 if !lineInput && (!subagentBelow || len(subagentLines) == 0) {
2272 bottom = append(bottom, "")
2273 }
2274 bottom = append(bottom, belowInput...)
2275 }
2276 return bottom, inputStartRow
2277 }
2278
2279 // Preserve one chat row and the renderer-owned bottom margin. This keeps
2280 // a bounded set of live indicators from pushing the editor or its cursor
2281 // out of a fixed right-bar frame on short terminals.
2282 const rendererBottomMarginRows = 1
2283 maxBottomRows := rows - rendererBottomMarginRows - 1
2284 subagentLines := allSubagentLines
2285 bottom, inputStartRow := composeBottom(subagentLines)
2286 if !dashboardActive && len(allSubagentLines) > 0 {
2287 for maxRows := len(allSubagentLines); maxRows >= 0; maxRows-- {
2288 candidate := limitSubagentActivityLines(i.cfg.Theme, allSubagentLines, maxRows, mainCols)
2289 candidateBottom, candidateInputStartRow := composeBottom(candidate)
2290 if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
2291 subagentLines = candidate
2292 bottom = candidateBottom
2293 inputStartRow = candidateInputStartRow
2294 break
2295 }
2296 }
2297 }
2298 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
2299
2300 chatRows := rows - len(bottom)
2301 if chatRows < 1 {
2302 chatRows = 1
2303 }
2304
2305 // Auto-follow guard: when the user has scrolled up (scrollOffset
2306 // > 0) and the agent appends new content below the viewport while
2307 // they're reading, compensate so the visible content stays
2308 // anchored. scrollOffset is measured from the bottom of `chat`,
2309 // so without compensation a growing buffer pushes the window
2310 // downward through the content and the lines the user was
2311 // reading scroll off the top.
2312 //
2313 // Skip compensation when the terminal width changed (a resize
2314 // reflows the whole buffer and the line-count delta no longer
2315 // corresponds to appended content) and when scrollOffset is 0
2316 // (the user is following the tail and wants new content to push
2317 // the view down as usual).
2318 //
2319 // The window the user sees starts at row
2320 // start = len(chat) - scrollOffset - chatRows
2321 // so to keep `start` fixed across a redraw we must offset by both
2322 // the buffer growth (len delta) AND the viewport-height change
2323 // (chatRows delta, e.g. the status band or sliding-in queue
2324 // appearing during a turn). Compensating only for the len delta
2325 // let a shrinking chatRows pull the window toward the tail, which
2326 // read as the viewport jumping to the bottom whenever the agent
2327 // streamed text or a tool call grew the bottom band.
2328 if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
2329 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
2330 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
2331 }
2332 i.prevChatLen = len(chat)
2333 i.prevChatCols = mainCols
2334 i.prevChatRows = chatRows
2335
2336 // Apply scroll offset to the chat slice.
2337 maxOffset := len(chat) - chatRows
2338 if maxOffset < 0 {
2339 maxOffset = 0
2340 }
2341 // Tail-render expansion: if the user has scrolled to (or above)
2342 // the top of the currently rendered tail and there are still
2343 // truncated messages above, widen view.TailLimit and rebuild.
2344 // The chat cache is keyed on tailLimit so the next cachedChatLocked
2345 // will re-issue Build instead of returning the stale slice. We
2346 // rebuild immediately so the same redraw shows the freshly-revealed
2347 // rows; otherwise the user would have to scroll again to see them.
2348 if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
2349 prevLen := len(chat)
2350 i.view.TailLimit += resumeTailExpandStep
2351 if i.view.TailLimit >= len(i.view.Messages) {
2352 i.view.TailLimit = 0 // unbounded
2353 }
2354 i.chatCacheValid = false
2355 chat = i.cachedChatLocked(mainCols)
2356 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
2357 chat = chat[:len(chat)-1]
2358 }
2359 // Newly-revealed rows are older messages prepended at the top.
2360 // scrollOffset counts rows from the bottom, so to keep the
2361 // viewport visually anchored on the same content the user was
2362 // looking at we shift it up by the number of rows added. Keep
2363 // the auto-follow baseline (prevChatLen) in sync with the
2364 // post-expansion length too, so the next render's follow guard
2365 // doesn't see this growth as a synthetic delta and yank the
2366 // viewport again.
2367 if grew := len(chat) - prevLen; grew > 0 {
2368 i.scrollOffset += grew
2369 }
2370 i.prevChatLen = len(chat)
2371 i.prevChatCols = mainCols
2372 maxOffset = len(chat) - chatRows
2373 if maxOffset < 0 {
2374 maxOffset = 0
2375 }
2376 }
2377 if i.scrollOffset > maxOffset {
2378 i.scrollOffset = maxOffset
2379 }
2380 if i.scrollOffset < 0 {
2381 i.scrollOffset = 0
2382 }
2383
2384 var visibleChat []string
2385 if len(chat) <= chatRows {
2386 visibleChat = chat
2387 } else {
2388 end := len(chat) - i.scrollOffset
2389 rawStart := end - chatRows
2390 if rawStart < 0 {
2391 rawStart = 0
2392 }
2393 start := snapViewportStartToImageBlock(chat, rawStart)
2394 // If the snap pulled start upward (an image-block was atomic) while
2395 // the user is scrolling downward, the viewport would sit on the same
2396 // image until the user mashes down past every reserved row. Bump
2397 // scrollOffset past the image so one keypress always clears it.
2398 if start < rawStart && i.scrollOffset < i.prevScrollOffset {
2399 jump := rawStart - start
2400 i.scrollOffset -= jump
2401 if i.scrollOffset < 0 {
2402 i.scrollOffset = 0
2403 }
2404 end = len(chat) - i.scrollOffset
2405 rawStart = end - chatRows
2406 if rawStart < 0 {
2407 rawStart = 0
2408 }
2409 start = snapViewportStartToImageBlock(chat, rawStart)
2410 }
2411 end = start + chatRows
2412 if end > len(chat) {
2413 end = len(chat)
2414 start = end - chatRows
2415 if start < 0 {
2416 start = 0
2417 }
2418 }
2419 visibleChat = chat[start:end]
2420 }
2421 i.prevScrollOffset = i.scrollOffset
2422 visibleChat = clipBottomClippedImages(visibleChat)
2423
2424 // A tiny "scrolled up" indicator in the top-right of the chat pane
2425 // so you know you're not at the bottom. When the viewport was
2426 // parked by /jump we include the turn number so the user remembers
2427 // they're reading history rather than the live conversation.
2428 if i.scrollOffset > 0 && len(visibleChat) > 0 {
2429 var text string
2430 if i.parkedTurn > 0 && i.parkedTotal > 0 {
2431 text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
2432 i.parkedTurn, i.parkedTotal, i.scrollOffset)
2433 } else {
2434 text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
2435 }
2436 note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
2437 visibleChat = append([]string{note}, visibleChat...)
2438 if len(visibleChat) > chatRows {
2439 visibleChat = visibleChat[:chatRows]
2440 }
2441 }
2442
2443 // A dialog is the foreground layer. Dim every other visible surface,
2444 // including the main input beneath it, without changing the dialog's
2445 // own styling. Keep the source slices untouched because chat is cached.
2446 if modalBackdrop {
2447 chat = tui.DimLines(chat)
2448 visibleChat = tui.DimLines(visibleChat)
2449 dimmedBottom := tui.DimLines(bottom)
2450 copy(dimmedBottom[1:1+len(dialog)], dialog)
2451 bottom = dimmedBottom
2452 }
2453
2454 // Default: the real terminal cursor sits on the main editor's
2455 // input position. In main-screen log mode cursor rows are relative
2456 // to the fixed bottom band, not the chat transcript.
2457 // dialogLead is 1 when the bottom region prepends a blank above
2458 // the dialog block (whenever a dialog is showing) so popup-side
2459 // cursor positions still land in the right cell.
2460 dialogLead := 0
2461 if len(dialog) > 0 {
2462 dialogLead = 1
2463 }
2464 cursorRow := inputStartRow + inputCursorOffset + curR
2465 cursorCol := curC + inputCursorColOffset
2466 cursorInDialog := false
2467 if i.btwDialog.Active() {
2468 if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
2469 cursorRow = dialogLead + r
2470 cursorCol = c
2471 cursorInDialog = true
2472 }
2473 }
2474 if i.dialog.Active() {
2475 if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2476 cursorRow = dialogLead + r
2477 cursorCol = c
2478 cursorInDialog = true
2479 }
2480 }
2481 if i.llamaDialog.Active() {
2482 if r, c := i.llamaDialog.CursorPos(); r >= 0 {
2483 cursorRow = dialogLead + r
2484 cursorCol = c
2485 cursorInDialog = true
2486 } else {
2487 cursorRow = -1
2488 cursorCol = 0
2489 }
2490 }
2491 if i.sessionDialog.Active() {
2492 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
2493 cursorRow = dialogLead + r
2494 cursorCol = c
2495 cursorInDialog = true
2496 }
2497 }
2498 if i.sessionTreeDialog.Active() {
2499 if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
2500 cursorRow = dialogLead + r
2501 cursorCol = c
2502 cursorInDialog = true
2503 }
2504 }
2505 if i.subagentsDialog.Active() {
2506 if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
2507 cursorRow = dialogLead + r
2508 cursorCol = c
2509 cursorInDialog = true
2510 } else {
2511 // Dashboard list / transcript view has no caret. Without
2512 // this branch the default cursorRow points at the
2513 // (hidden) main editor row, so the terminal would draw
2514 // a stray block somewhere in the chat region.
2515 cursorRow = -1
2516 cursorCol = 0
2517 }
2518 }
2519 if i.extPanel.Active() {
2520 cursorRow = -1
2521 cursorCol = 0
2522 }
2523 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2524 if i.renderRevision.Load() != renderRevision {
2525 i.mu.Unlock()
2526 return
2527 }
2528 theme := i.cfg.Theme
2529 // State assembly above is synchronized; terminal output is owned by
2530 // this renderer goroutine and must not hold the global state mutex.
2531 i.mu.Unlock()
2532 if rightBarActive {
2533 rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
2534 if modalBackdrop {
2535 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2536 } else {
2537 i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2538 }
2539 } else {
2540 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2541 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2542 }
2543 i.mu.Lock()
2544 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2545 alert := *i.pendingAlert
2546 i.pendingAlert = nil
2547 i.emitAlertLocked(alert)
2548 }
2549 i.mu.Unlock()
2550}
2551
2552func hasImageEscape(line string) bool {
2553 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
2554}
2555
2556// snapViewportStartToImageBlock treats inline images as atomic blocks for
2557// scrolling. Terminal image protocols draw from a single escape row into a
2558// separate graphics layer; the following blank rows are only zut's reserved
2559// footprint. If the viewport starts on one of those blank rows, there is no
2560// correct partial-image state to render. Snap back to the escape row instead
2561// so the image is either shown from its beginning or skipped entirely.
2562func snapViewportStartToImageBlock(chat []string, start int) int {
2563 if start <= 0 || start >= len(chat) {
2564 return start
2565 }
2566 if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
2567 return start
2568 }
2569 for k := start - 1; k >= 0; k-- {
2570 line := chat[k]
2571 if hasImageEscape(line) {
2572 return k
2573 }
2574 if !isBoxBlankLine(line) {
2575 break
2576 }
2577 }
2578 return start
2579}
2580
2581const (
2582 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
2583 autoCompactContinueMetaKey = "auto_compact_continue"
2584 shellEscapeMetaKey = "shell_escape"
2585)
2586
2587func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
2588 if len(msgs) == 0 {
2589 return nil
2590 }
2591 out := make([]provider.Message, 0, len(msgs))
2592 for _, m := range msgs {
2593 if isHiddenTranscriptMessage(m) {
2594 continue
2595 }
2596 out = append(out, m)
2597 }
2598 return out
2599}
2600
2601func isHiddenTranscriptMessage(m provider.Message) bool {
2602 if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
2603 return true
2604 }
2605 if m.Role != provider.RoleUser || len(m.Content) == 0 {
2606 return false
2607 }
2608 tb, ok := m.Content[0].(provider.TextBlock)
2609 if !ok {
2610 return false
2611 }
2612 return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
2613}
2614
2615func clipBottomClippedImages(lines []string) []string {
2616 if len(lines) == 0 {
2617 return lines
2618 }
2619 out := append([]string(nil), lines...)
2620 for i, line := range out {
2621 if !hasImageEscape(line) {
2622 continue
2623 }
2624 // Image blocks render as: image escape, zero or more blank
2625 // reservation rows, then the muted "image - ..." info line,
2626 // then one trailing blank. If the info line isn't visible in
2627 // the current chat slice, the image would paint down into the
2628 // fixed status bar area. Suppress that image for this frame.
2629 //
2630 // When the image lives inside a tool box, the reservation rows
2631 // are wrapped in vertical box edges ("│ ... │"); those rows
2632 // look non-blank under a naive whitespace check but are still
2633 // reservation rows for this scan, so treat them as blank.
2634 foundInfo := false
2635 for j := i + 1; j < len(out); j++ {
2636 if strings.Contains(out[j], "image - ") {
2637 foundInfo = true
2638 break
2639 }
2640 if !isBoxBlankLine(out[j]) {
2641 break
2642 }
2643 }
2644 if !foundInfo {
2645 out[i] = ""
2646 }
2647 }
2648 return out
2649}
2650
2651// isBoxBlankLine reports whether line is visually empty after
2652// stripping ANSI escape sequences, surrounding whitespace, and the
2653// vertical box edges drawn by the tool-box renderer. Used by
2654// clipBottomClippedImages so an image's reservation rows still count
2655// as blank when those rows are wrapped in "│ ... │" inside a tool box.
2656func isBoxBlankLine(line string) bool {
2657 stripped := stripANSIBytes(line)
2658 stripped = strings.TrimSpace(stripped)
2659 stripped = strings.Trim(stripped, "│")
2660 stripped = strings.TrimSpace(stripped)
2661 return stripped == ""
2662}
2663
2664// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
2665// byte) from s without pulling in the regexp package. Mirrors the
2666// internal helper in package tui; the duplicated copy avoids exporting
2667// it just for one caller.
2668func stripANSIBytes(s string) string {
2669 if !strings.Contains(s, "\x1b") {
2670 return s
2671 }
2672 var b strings.Builder
2673 b.Grow(len(s))
2674 i := 0
2675 for i < len(s) {
2676 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
2677 end := i + 2
2678 for end < len(s) {
2679 c := s[end]
2680 end++
2681 if c >= 0x40 && c <= 0x7e {
2682 break
2683 }
2684 }
2685 i = end
2686 continue
2687 }
2688 b.WriteByte(s[i])
2689 i++
2690 }
2691 return b.String()
2692}
2693
2694// truncateLine shortens s so it fits within n display cells, with an
2695// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
2696// novel doesn't blow past the status line.
2697func panelKeyName(k tui.Key) string {
2698 switch k.Kind {
2699 case tui.KeyUp:
2700 return "up"
2701 case tui.KeyDown:
2702 return "down"
2703 case tui.KeyLeft:
2704 return "left"
2705 case tui.KeyRight:
2706 return "right"
2707 case tui.KeyEnter:
2708 return "enter"
2709 case tui.KeyEsc:
2710 return "esc"
2711 case tui.KeyTab:
2712 return "tab"
2713 case tui.KeyBackspace:
2714 return "backspace"
2715 case tui.KeyDelete:
2716 return "delete"
2717 case tui.KeyHome:
2718 return "home"
2719 case tui.KeyEnd:
2720 return "end"
2721 case tui.KeyPageUp:
2722 return "pageup"
2723 case tui.KeyPageDown:
2724 return "pagedown"
2725 case tui.KeyRune:
2726 return "rune"
2727 default:
2728 return "unknown"
2729 }
2730}
2731
2732func panelKeyText(k tui.Key) string {
2733 if k.Kind == tui.KeyRune {
2734 return string(k.Rune)
2735 }
2736 return ""
2737}
2738
2739func truncateLine(s string, n int) string {
2740 if n <= 0 {
2741 return ""
2742 }
2743 // Collapse newlines — chips are single line.
2744 s = strings.ReplaceAll(s, "\n", " ↩ ")
2745 runes := []rune(s)
2746 if len(runes) <= n {
2747 return s
2748 }
2749 if n <= 3 {
2750 return strings.Repeat(".", n)
2751 }
2752 return string(runes[:n-3]) + "..."
2753}
2754
2755// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2756// will exit instead of just clearing input. Long enough to be
2757// deliberate (rules out accidental key chord), short enough that the
2758// hint stays meaningful.
2759const ctrlCExitWindow = 2 * time.Second
2760
2761// armCtrlCExit records the timestamp of the current ctrl+c so the next
2762// one within ctrlCExitWindow exits.
2763func (i *Interactive) armCtrlCExit() {
2764 i.mu.Lock()
2765 i.lastCtrlC = time.Now()
2766 i.mu.Unlock()
2767}
2768
2769// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2770// that another press should now exit.
2771func (i *Interactive) ctrlCExitArmed() bool {
2772 i.mu.Lock()
2773 t := i.lastCtrlC
2774 i.mu.Unlock()
2775 return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2776}
2777
2778// clearFileSuggestQuery strips the filter the user typed after the
2779// last "@", leaving the bare "@" so the picker stays open. Called when
2780// navigating between directory levels (Right/Left): the filter applied
2781// to the level the user was on, not the one being entered, so carrying
2782// it forward would wrongly hide the new directory's contents.
2783func (i *Interactive) clearFileSuggestQuery() {
2784 val := i.ed.Value()
2785 if idx := strings.LastIndex(val, "@"); idx >= 0 {
2786 i.ed.SetValue(val[:idx+1])
2787 }
2788}
2789
2790// setToolExpansion updates long tool result expansion and replays the
2791// transcript so already-emitted rows reflect the new state.
2792func (i *Interactive) setToolExpansion(expanded bool) {
2793 i.mu.Lock()
2794 i.view.ExpandAll = expanded
2795 i.requestRendererClear()
2796 i.mu.Unlock()
2797 i.invalidate()
2798}
2799
2800func (i *Interactive) toggleToolExpansion() {
2801 i.mu.Lock()
2802 expanded := !i.view.ExpandAll
2803 i.mu.Unlock()
2804 i.setToolExpansion(expanded)
2805}
2806
2807func (i *Interactive) toggleBtwToolExpansion() {
2808 i.btwDialog.ToggleToolExpansion()
2809 i.mu.Lock()
2810 i.requestRendererClear()
2811 i.mu.Unlock()
2812 i.invalidate()
2813}
2814
2815// confirmChildActive reports whether an interaction opened from slash input
2816// currently owns the keyboard while a tool confirmation remains pending.
2817func (i *Interactive) confirmChildActive() bool {
2818 i.mu.Lock()
2819 defer i.mu.Unlock()
2820 return i.confirmChildActiveLocked()
2821}
2822
2823func (i *Interactive) confirmChildActiveLocked() bool {
2824 return len(i.helpBlock) > 0 ||
2825 len(i.extNotes) > 0 ||
2826 i.dialog.Active() ||
2827 i.modelDialog.Active() ||
2828 i.llamaDialog.Active() ||
2829 i.rescueDialog.Active() ||
2830 i.sessionDialog.Active() ||
2831 i.subagentsDialog.Active() ||
2832 i.jumpDialog.Active() ||
2833 i.btwDialog.Active() ||
2834 i.skillsDialog.Active() ||
2835 i.changelogDialog.Active() ||
2836 i.logoutDialog.Active() ||
2837 i.telegramDialog.Active() ||
2838 i.settingsDialog.Active() ||
2839 i.sessionOpsDialog.Active() ||
2840 i.sessionTreeDialog.Active() ||
2841 i.extPanel.Active()
2842}
2843
2844// restoreConfirmFocus returns input to confirmation after slash input is
2845// cleared or a child interaction closes. A non-empty editor keeps command
2846// input focused, and an active child continues to own its keys.
2847func (i *Interactive) restoreConfirmFocus() {
2848 if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2849 return
2850 }
2851 if !i.ed.IsEmpty() || i.confirmChildActive() {
2852 return
2853 }
2854 i.confirmDialog.Focus()
2855 i.invalidate()
2856}
2857
2858func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2859 defer i.restoreConfirmFocus()
2860
2861 // A bare Escape is eligible for the session-tree gesture only while the
2862 // main input owns the key. Any other key, modified Escape, or visible
2863 // child interaction resets the pending first tap before normal routing.
2864 if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
2865 i.mu.Lock()
2866 i.doubleEscape.Reset()
2867 i.mu.Unlock()
2868 }
2869
2870 // Dialogs route keys before the main clipboard handler below. Resolve text
2871 // here when a child interaction owns input so every editor and filter sees
2872 // the same KeyPaste event as terminal-native bracketed paste. Main chat is
2873 // left image-first to preserve macOS clipboard image attachments.
2874 if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
2875 resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
2876 if err != nil {
2877 i.mu.Lock()
2878 i.statusErr = "clipboard paste failed: " + err.Error()
2879 i.statusOK = ""
2880 i.mu.Unlock()
2881 return false
2882 }
2883 if pastedText {
2884 k = resolved
2885 i.mu.Lock()
2886 i.statusErr = ""
2887 i.statusOK = ""
2888 i.mu.Unlock()
2889 }
2890 }
2891
2892 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2893 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
2894 // unexpectedly. The hint message also goes stale; clear it.
2895 if k.Kind != tui.KeyCtrlC {
2896 i.mu.Lock()
2897 if !i.lastCtrlC.IsZero() {
2898 i.lastCtrlC = time.Time{}
2899 if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
2900 i.statusOK = ""
2901 }
2902 }
2903 i.mu.Unlock()
2904 }
2905
2906 // Dialogs consume keys while open (except ctrl+c, which always closes them).
2907
2908 // Confirmation owns input by default. Pressing / deliberately moves
2909 // focus to the main editor without answering the pending request, so
2910 // slash commands and the dialogs they open can run first.
2911 if i.confirmDialog.Focused() {
2912 if k.Kind == tui.KeyRune && k.Rune == '/' {
2913 i.confirmDialog.Blur()
2914 i.ed.Insert("/")
2915 i.suggest.Reset()
2916 i.invalidate()
2917 return false
2918 }
2919 if k.Kind == tui.KeyCtrlO {
2920 if i.btwDialog != nil && i.btwDialog.Active() {
2921 i.toggleBtwToolExpansion()
2922 } else {
2923 i.toggleToolExpansion()
2924 }
2925 return false
2926 }
2927 i.confirmDialog.HandleKey(k)
2928 i.invalidate()
2929 return false
2930 }
2931 if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2932 i.ed.Clear()
2933 i.suggest.Reset()
2934 i.fileSuggest.Reset()
2935 i.confirmDialog.Focus()
2936 i.invalidate()
2937 return false
2938 }
2939 if i.dialog.Active() {
2940 if k.Kind == tui.KeyCtrlC {
2941 i.dialog.Close()
2942 if i.cfg.AuthManager != nil {
2943 i.cfg.AuthManager.CancelOAuth()
2944 }
2945 return false
2946 }
2947 act := i.dialog.HandleKey(k)
2948 if act.StartAPIKey {
2949 i.startAPIKeyFlow(act.Provider)
2950 }
2951 if act.StartOAuth {
2952 i.startOAuthFlow(act.Provider)
2953 }
2954 if act.StartManual {
2955 i.startManualOAuthFlow(act.Provider)
2956 }
2957 if act.SubmitCode != "" {
2958 i.submitManualOAuthCode(act.SubmitCode)
2959 }
2960 if act.SaveLlama {
2961 i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2962 }
2963 return false
2964 }
2965 if i.modelDialog.Active() {
2966 if k.Kind == tui.KeyCtrlC {
2967 i.modelDialog.Close()
2968 i.quickModelAssign = 0
2969 return false
2970 }
2971 act := i.modelDialog.HandleKey(k)
2972 if act.Close {
2973 i.quickModelAssign = 0
2974 }
2975 if act.ReasoningChanged {
2976 i.applyReasoningSetting(act.Reasoning)
2977 }
2978 if act.Select {
2979 if i.quickModelAssign > 0 {
2980 i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2981 i.quickModelAssign = 0
2982 } else {
2983 i.applyModelSelection(act.Provider, act.Model)
2984 }
2985 }
2986 return false
2987 }
2988 if i.llamaDialog.Active() {
2989 i.llamaDialog.HandleKey(k)
2990 i.invalidate()
2991 return false
2992 }
2993 if i.rescueDialog.Active() {
2994 if k.Kind == tui.KeyCtrlC {
2995 i.rescueDialog.Close()
2996 i.invalidate()
2997 return false
2998 }
2999 act := i.rescueDialog.HandleKey(k)
3000 if act.Select {
3001 i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
3002 }
3003 i.invalidate()
3004 return false
3005 }
3006 i.mu.Lock()
3007 sessionDialogActive := i.sessionDialog.Active()
3008 i.mu.Unlock()
3009 if sessionDialogActive {
3010 if k.Kind == tui.KeyCtrlC {
3011 i.mu.Lock()
3012 i.sessionDialog.Close()
3013 i.sessionLoads = nil
3014 i.mu.Unlock()
3015 i.invalidate()
3016 return false
3017 }
3018 manualRenamePath := ""
3019 i.mu.Lock()
3020 if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
3021 manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
3022 }
3023 i.mu.Unlock()
3024 manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
3025 if manualRenameCurrent {
3026 i.markSessionTitleSwitching()
3027 }
3028 i.mu.Lock()
3029 act := i.sessionDialog.HandleKey(k)
3030 if act.Select || act.Close {
3031 i.sessionLoads = nil
3032 }
3033 i.mu.Unlock()
3034 if act.Select {
3035 i.applySessionSelection(act.Path)
3036 }
3037 if act.Err != nil {
3038 if manualRenameCurrent {
3039 i.restoreFailedSessionTitle()
3040 }
3041 i.mu.Lock()
3042 i.statusErr = "rename: " + act.Err.Error()
3043 i.statusOK = ""
3044 i.mu.Unlock()
3045 } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
3046 i.setManualSessionTitle(act.RenameTitle)
3047 }
3048 // Always request a redraw after handling a key here: when esc
3049 // closes the picker, the overlay-close detection in the render
3050 // pass needs to run so the tall dialog rows get repainted from
3051 // the chat (otherwise VS Code's retained scrollback leaves a
3052 // duplicate frame on screen).
3053 i.invalidate()
3054 return false
3055 }
3056 if i.subagentsDialog.Active() {
3057 if k.Kind == tui.KeyCtrlC {
3058 i.subagentsDialog.Close()
3059 i.invalidate()
3060 return false
3061 }
3062 _, msg, errMsg := i.subagentsDialog.HandleKey(k)
3063 if msg != "" || errMsg != "" {
3064 i.mu.Lock()
3065 i.statusOK = msg
3066 i.statusErr = errMsg
3067 i.mu.Unlock()
3068 }
3069 i.invalidate()
3070 return false
3071 }
3072 if i.logoutDialog.Active() {
3073 if k.Kind == tui.KeyCtrlC {
3074 i.logoutDialog.Close()
3075 i.invalidate()
3076 return false
3077 }
3078 act := i.logoutDialog.HandleKey(k)
3079 if act.Select {
3080 i.doLogout(act.Target)
3081 }
3082 i.invalidate()
3083 return false
3084 }
3085 if i.telegramDialog.Active() {
3086 if k.Kind == tui.KeyCtrlC {
3087 i.telegramDialog.Close()
3088 i.invalidate()
3089 return false
3090 }
3091 act := i.telegramDialog.HandleKey(k)
3092 if act.Select {
3093 i.doTelegram(act.Action)
3094 }
3095 i.invalidate()
3096 return false
3097 }
3098 if i.settingsDialog.Active() {
3099 if k.Kind == tui.KeyCtrlC {
3100 i.settingsDialog.Close()
3101 i.invalidate()
3102 return false
3103 }
3104 act := i.settingsDialog.HandleKey(k)
3105 if act.ModelShortcutSlot > 0 {
3106 i.openQuickModelPicker(act.ModelShortcutSlot)
3107 }
3108 if act.Toggle {
3109 i.applySettingChange(act)
3110 }
3111 i.invalidate()
3112 return false
3113 }
3114 if i.sessionOpsDialog.Active() {
3115 if k.Kind == tui.KeyCtrlC {
3116 i.sessionOpsDialog.Close()
3117 i.invalidate()
3118 return false
3119 }
3120 act := i.sessionOpsDialog.HandleKey(k)
3121 if act.Select {
3122 i.doSessionOp(act.Action, "")
3123 }
3124 i.invalidate()
3125 return false
3126 }
3127 if i.sessionTreeDialog.Active() {
3128 if k.Kind == tui.KeyCtrlC {
3129 i.sessionTreeDialog.Close()
3130 i.invalidate()
3131 return false
3132 }
3133 act := i.sessionTreeDialog.HandleKey(k)
3134 if act.Select {
3135 if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
3136 i.applySessionTreeTarget(act.Target, act.TurnNo)
3137 } else {
3138 // Keep package embedders that still return the scalar action
3139 // source-compatible while the dialog migrates to Target.
3140 i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
3141 }
3142 }
3143 i.invalidate()
3144 return false
3145 }
3146 if i.extPanel.Active() {
3147 if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
3148 if i.cfg.Extensions != nil {
3149 _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
3150 }
3151 i.extPanel.Close()
3152 i.invalidate()
3153 return false
3154 }
3155 if i.cfg.Extensions != nil {
3156 _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
3157 }
3158 return false
3159 }
3160 if i.jumpDialog.Active() {
3161 if k.Kind == tui.KeyCtrlC {
3162 i.jumpDialog.Close()
3163 i.pendingFork = false
3164 return false
3165 }
3166 act := i.jumpDialog.HandleKey(k)
3167 if act.Select {
3168 if i.pendingFork {
3169 i.applyForkSelection(act.MessageIdx)
3170 } else {
3171 i.applyJumpSelection(act.MessageIdx, act.TurnNo)
3172 }
3173 }
3174 // If the user dismissed the dialog without selecting, also
3175 // clear the pending-fork flag so a later plain /jump isn't
3176 // hijacked.
3177 if act.Close {
3178 i.pendingFork = false
3179 }
3180 return false
3181 }
3182 if i.btwDialog.Active() {
3183 if k.Kind == tui.KeyCtrlC {
3184 i.btwDialog.Close()
3185 i.invalidate()
3186 return false
3187 }
3188 if k.Kind == tui.KeyCtrlO {
3189 i.toggleBtwToolExpansion()
3190 return false
3191 }
3192 i.btwDialog.HandleKey(k, i.invalidate)
3193 return false
3194 }
3195 if i.skillsDialog.Active() {
3196 if k.Kind == tui.KeyCtrlC {
3197 i.skillsDialog.Close()
3198 i.invalidate()
3199 return false
3200 }
3201 i.skillsDialog.HandleKey(k)
3202 i.invalidate()
3203 return false
3204 }
3205 if i.changelogDialog.Active() {
3206 if closed := i.changelogDialog.HandleKey(k); closed {
3207 // User dismissed; let the parent persist the
3208 // LastChangelogShown marker via the close callback.
3209 if i.cfg.OnChangelogDismiss != nil {
3210 i.cfg.OnChangelogDismiss()
3211 }
3212 }
3213 i.invalidate()
3214 return false
3215 }
3216
3217 if slot := quickModelShortcutSlot(k); slot > 0 {
3218 i.applyQuickModelShortcut(slot)
3219 return false
3220 }
3221
3222 // The first eligible bare Escape is deliberately left to the existing
3223 // Escape path (which clears idle input or cancels a turn). A second tap
3224 // within the gesture window only gets first refusal while the cheap,
3225 // local eligibility rules still hold. If a turn started, a message was
3226 // queued, or the input became ineligible between taps, reset the detector
3227 // and fall through so Escape retains its normal cancellation behavior.
3228 // Once those rules still hold, the full tree gate owns the second tap:
3229 // family/read failures are fail-closed but are nevertheless consumed so
3230 // they cannot fall through into ordinary editor handling.
3231 if isUnmodifiedEscape(k) {
3232 now := i.sessionTreeEscapeNow()
3233 i.mu.Lock()
3234 consumed := i.doubleEscape.Consume(now)
3235 i.mu.Unlock()
3236 if consumed {
3237 if i.canArmSessionTreeEscape() {
3238 i.openSessionTree()
3239 return false
3240 }
3241 i.mu.Lock()
3242 i.doubleEscape.Reset()
3243 i.mu.Unlock()
3244 } else if i.canArmSessionTreeEscape() {
3245 i.mu.Lock()
3246 i.doubleEscape.Arm(now)
3247 i.mu.Unlock()
3248 }
3249 }
3250
3251 // Global keys.
3252 switch k.Kind {
3253 case tui.KeyCtrlC:
3254 i.mu.Lock()
3255 loadingSession := i.sessionLoading
3256 i.mu.Unlock()
3257 if loadingSession {
3258 i.markCtrlCExit()
3259 return true
3260 }
3261 // While busy: do NOT cancel the turn. ctrl+c during a
3262 // running turn is almost always reflex muscle memory
3263 // ("be quiet" in a shell) rather than a deliberate
3264 // decision to kill a multi-minute model call that's
3265 // already cost tokens. Use esc to interrupt a turn; use
3266 // a deliberate double-ctrl+c to exit zut entirely. First
3267 // press arms the exit hint, second press within
3268 // ctrlCExitWindow quits.
3269 if i.busy {
3270 if i.ctrlCExitArmed() {
3271 i.markCtrlCExit()
3272 return true
3273 }
3274 i.mu.Lock()
3275 i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
3276 i.statusErr = ""
3277 i.mu.Unlock()
3278 i.armCtrlCExit()
3279 return false
3280 }
3281 // Idle: first press clears the editor (and any queued
3282 // follow-up messages); a second press within ctrlCExitWindow
3283 // exits. With both an empty editor and no queue the first
3284 // press still just arms — require a deliberate double-tap.
3285 ag := i.agent
3286 pending := 0
3287 if ag != nil {
3288 pending = ag.QueuedMessageCount()
3289 }
3290 hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
3291 if hadInput {
3292 i.ed.Clear()
3293 i.clipboardImages = nil
3294 i.suggest.Reset()
3295 if ag != nil {
3296 ag.DrainQueuedMessages()
3297 }
3298 i.mu.Lock()
3299 i.queued = nil
3300 i.statusOK = "input cleared"
3301 i.statusErr = ""
3302 i.mu.Unlock()
3303 i.armCtrlCExit()
3304 return false
3305 }
3306 if i.ctrlCExitArmed() {
3307 i.markCtrlCExit()
3308 return true
3309 }
3310 i.mu.Lock()
3311 i.statusOK = "press ctrl+c again to exit"
3312 i.statusErr = ""
3313 i.mu.Unlock()
3314 i.armCtrlCExit()
3315 return false
3316 case tui.KeyEsc:
3317 // Esc interrupts a running turn — but only when nothing
3318 // else on screen wants to consume the key first. The slash
3319 // popup has its own Esc behaviour (close + clear editor),
3320 // and transient overlays like the /help block and extension
3321 // notes should dismiss on Esc before we even consider the
3322 // turn. Without these guards, a casual Esc press after
3323 // running /help on a busy turn rips the turn away.
3324 if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
3325 break
3326 }
3327 i.mu.Lock()
3328 hadHelp := len(i.helpBlock) > 0
3329 hadNotes := len(i.extNotes) > 0
3330 if hadHelp {
3331 i.helpBlock = nil
3332 }
3333 if hadNotes {
3334 i.extNotes = nil
3335 }
3336 i.mu.Unlock()
3337 if hadHelp || hadNotes {
3338 i.invalidate()
3339 return false
3340 }
3341 i.mu.Lock()
3342 busyCancel := i.busy && i.cancelTurn != nil
3343 cancelTurn := i.cancelTurn
3344 var handoff json.RawMessage
3345 var persistHandoff bool
3346 if busyCancel {
3347 handoff, persistHandoff = i.resetCompactContinuationLocked()
3348 }
3349 i.mu.Unlock()
3350 if busyCancel {
3351 i.updateActiveGoal(core.GoalPaused, "interrupted by user")
3352 cancelTurn()
3353 // If a confirm dialog is pending, refuse it so the agent
3354 // goroutine unblocks and the context cancellation can
3355 // actually take effect.
3356 i.confirmDialog.CancelAll("turn cancelled")
3357 if persistHandoff {
3358 i.persistCompactHandoff(handoff)
3359 }
3360 return false
3361 }
3362 case tui.KeyCtrlD:
3363 if i.ed.IsEmpty() && !i.busy {
3364 return true
3365 }
3366 case tui.KeyCtrlB:
3367 i.mu.Lock()
3368 i.rightBarHidden = !i.rightBarHidden
3369 i.mu.Unlock()
3370 i.requestRendererInvalidate()
3371 i.invalidate()
3372 return false
3373 case tui.KeyCtrlL:
3374 i.requestRendererClear()
3375 i.invalidate()
3376 return false
3377 case tui.KeyPasteClipboard:
3378 i.pasteClipboard(ctx)
3379 return false
3380 case tui.KeyCtrlO:
3381 i.toggleToolExpansion()
3382 return false
3383 case tui.KeyPageUp:
3384 i.scrollBy(+i.chatPage())
3385 return false
3386 case tui.KeyPageDown:
3387 i.scrollBy(-i.chatPage())
3388 return false
3389 case tui.KeyUp:
3390 // Alt/Option+Up: pop the most recently queued ("sliding in")
3391 // message back into the editor so the user can edit and
3392 // resend it. Repeated presses keep peeling messages off the
3393 // tail of the queue; each press *replaces* the editor
3394 // contents (we don't append/push). When the queue is empty
3395 // the keypress falls through to the normal scroll behavior.
3396 if k.Alt {
3397 i.mu.Lock()
3398 var text string
3399 if i.agent != nil {
3400 text, _ = i.agent.PopQueuedMessage()
3401 }
3402 if text == "" {
3403 if n := len(i.queued); n > 0 {
3404 text = i.queued[n-1]
3405 i.queued = i.queued[:n-1]
3406 }
3407 }
3408 i.mu.Unlock()
3409 if text != "" {
3410 i.ed.SetValue(text)
3411 i.inputHistoryIndex = -1
3412 i.invalidate()
3413 return false
3414 }
3415 }
3416 // In multi-line / wrapped input, Up first moves inside the editor.
3417 // At the editor's top edge, plain Up can browse input history when
3418 // history browsing is safe/active; otherwise it falls back to chat
3419 // scrolling, preserving the old single-line scroll behavior.
3420 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3421 if i.ed.MoveVertical(-1) {
3422 i.invalidate()
3423 return false
3424 }
3425 if !k.Alt && i.handleInputHistoryKey(k) {
3426 return false
3427 }
3428 i.scrollBy(+3)
3429 return false
3430 }
3431 case tui.KeyDown:
3432 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3433 if i.ed.MoveVertical(+1) {
3434 i.invalidate()
3435 return false
3436 }
3437 if !k.Alt && i.handleInputHistoryKey(k) {
3438 return false
3439 }
3440 if i.scrollOffset > 0 {
3441 i.scrollBy(-3)
3442 }
3443 return false
3444 }
3445 }
3446
3447 // Note: we intentionally do NOT gate the editor on i.busy here.
3448 // Typing while the agent is working is supported — submitted
3449 // messages are queued and delivered as follow-up turns when the
3450 // current turn ends. See the submit handler below.
3451
3452 if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
3453 i.ed.HandleKey(k)
3454 return false
3455 }
3456
3457 // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
3458 if i.suggest.Active(i.ed.Value()) {
3459 switch k.Kind {
3460 case tui.KeyUp:
3461 i.suggest.Up()
3462 return false
3463 case tui.KeyDown:
3464 i.suggest.Down()
3465 return false
3466 case tui.KeyPageUp:
3467 i.suggest.PageUp()
3468 return false
3469 case tui.KeyPageDown:
3470 i.suggest.PageDown()
3471 return false
3472 case tui.KeyTab:
3473 if name := i.suggest.Selection(i.ed.Value()); name != "" {
3474 i.ed.SetValue(name)
3475 i.suggest.Reset()
3476 }
3477 return false
3478 case tui.KeyEnter:
3479 // Enter on an ambiguous or partial slash prefix: complete to the
3480 // currently highlighted command and run it. That way typing
3481 // "/lo" + enter picks whichever of /login or /logout is selected
3482 // in the popup instead of submitting "/lo" as unknown. Also
3483 // clear the editor so the command doesn't linger after the
3484 // dialog opens/closes.
3485 if name := i.suggest.Selection(i.ed.Value()); name != "" {
3486 i.ed.Clear()
3487 i.suggest.Reset()
3488 return i.runSlash(ctx, name)
3489 }
3490 case tui.KeyEsc:
3491 i.ed.Clear()
3492 i.suggest.Reset()
3493 return false
3494 }
3495 }
3496
3497 // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
3498 if i.fileSuggest.Active(i.ed.Value()) {
3499 switch k.Kind {
3500 case tui.KeyUp:
3501 i.fileSuggest.Up()
3502 return false
3503 case tui.KeyDown:
3504 i.fileSuggest.Down()
3505 return false
3506 case tui.KeyRight:
3507 // Open selected directory. The filter the user typed picked
3508 // that directory at the current level; once we descend it no
3509 // longer applies to the directory's contents, so clear it.
3510 // Otherwise typing "@eda" then right would re-filter inside
3511 // eda/ by "eda" and show nothing.
3512 if i.fileSuggest.Right() {
3513 i.clearFileSuggestQuery()
3514 }
3515 return false
3516 case tui.KeyLeft:
3517 // Go back to parent directory. Clear the filter for the same
3518 // reason as Right: it was scoped to the level we just left.
3519 if i.fileSuggest.Left() {
3520 i.clearFileSuggestQuery()
3521 }
3522 return false
3523 case tui.KeyEnter:
3524 if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
3525 var chip string
3526 if entry.isDir {
3527 chip = "[dir:" + entry.rel + "/]"
3528 } else {
3529 chip = "[file:" + entry.rel + "]"
3530 }
3531 val := i.ed.Value()
3532 if idx := strings.LastIndex(val, "@"); idx >= 0 {
3533 val = val[:idx]
3534 }
3535 i.ed.SetValue(val + chip + " ")
3536 i.fileSuggest.Reset()
3537 }
3538 return false
3539 case tui.KeyEsc:
3540 val := i.ed.Value()
3541 if idx := strings.LastIndex(val, "@"); idx >= 0 {
3542 i.ed.SetValue(val[:idx])
3543 }
3544 i.fileSuggest.Reset()
3545 return false
3546 }
3547 }
3548
3549 // Tab-complete a path token in the editor when no popup is open.
3550 // Recognises tokens that look like paths (start with ~, /, ./, ../
3551 // or contain a slash); shell-style completion expands ~, lists the
3552 // parent dir, and completes the basename to the longest common
3553 // prefix. Single match: full replace and trailing / for dirs.
3554 // No match: no-op. Plain bare words (no slash, no tilde) fall
3555 // through so Tab keeps its current no-op behaviour outside paths.
3556 if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3557 if i.tryPathTabComplete() {
3558 return false
3559 }
3560 }
3561
3562 if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3563 i.inputHistoryIndex = -1
3564 }
3565
3566 if k.Kind == tui.KeyEsc {
3567 i.clipboardImages = nil
3568 }
3569 if submit := i.ed.HandleKey(k); submit {
3570 // SubmitValue() expands any [pasted text #N +L lines]
3571 // placeholders back into their bodies; the raw Value()
3572 // is only what the user sees on screen.
3573 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
3574 // Expand [file:name] and [dir:name/] chips to full paths.
3575 text = expandFileChips(text, i.cfg.CWD)
3576 text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
3577 if text == "" && len(images) == 0 {
3578 return false
3579 }
3580 clearSubmittedInput := func() {
3581 i.clipboardImages = nil
3582 i.ed.Clear()
3583 i.inputHistoryIndex = -1
3584 i.suggest.Reset()
3585 i.fileSuggest.Reset()
3586 }
3587
3588 // Shell escapes and slash commands are text-only. An image-bearing
3589 // draft remains a normal provider prompt instead of silently dropping
3590 // its attachments.
3591 if len(images) == 0 {
3592 if cmd, ok := shellEscapeCommand(text); ok {
3593 clearSubmittedInput()
3594 i.startShellEscape(ctx, cmd)
3595 return false
3596 }
3597 }
3598
3599 if len(images) == 0 && looksLikeSlashCommand(text) {
3600 clearSubmittedInput()
3601 head := text
3602 rest := ""
3603 if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3604 head = text[:idx]
3605 rest = strings.TrimSpace(text[idx:])
3606 }
3607 if !isKnownSlashCommand(text) {
3608 // Try extensions before giving up. Extensions register
3609 // commands by bare name (no leading slash); strip it here.
3610 extName := strings.TrimPrefix(head, "/")
3611 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
3612 go i.invokeExtensionCommand(ctx, extName, rest)
3613 return false
3614 }
3615 i.mu.Lock()
3616 i.statusErr = "unknown command " + head + " — type /help to see the list"
3617 i.statusOK = ""
3618 i.mu.Unlock()
3619 return false
3620 }
3621 // Slash commands run regardless of busy state. Commands that
3622 // would mutate the transcript or replace the agent (/clear,
3623 // /compact, /logout, /login, /model, /fork, /session fork)
3624 // cancel the active turn
3625 // first and wait for the goroutine to wind down so they don't
3626 // race with a streaming response. Safe commands (/help,
3627 // /jump, /sessions, /jail, /unjail, /exit) run immediately
3628 // without disturbing the active turn.
3629 if slashCommandCancelsTurn(text) {
3630 i.cancelAndWaitForIdle()
3631 }
3632 return i.runSlash(ctx, text)
3633 }
3634
3635 if i.agent == nil {
3636 i.mu.Lock()
3637 i.statusErr = "not logged in. type /login first."
3638 i.mu.Unlock()
3639 return false
3640 }
3641 // Mirror the user's typed prompt into the paired Telegram
3642 // chat (when the bridge is active) so the Telegram thread
3643 // stays a complete record of the session, not just the half
3644 // that originated on the phone. On a goroutine so the
3645 // network write doesn't delay the local turn.
3646 if i.telegramBridge != nil && i.telegramBridge.Active() {
3647 go i.telegramBridge.OnUserTyped(text)
3648 }
3649 i.maybeStartSessionTitle(ctx, text)
3650 // If a turn is already in flight, queue this prompt inside the
3651 // agent loop so it is delivered at the next safe model-call
3652 // boundary instead of waiting for the whole run to finish.
3653 i.mu.Lock()
3654 busy := i.busy
3655 compacting := i.compacting
3656 ag := i.agent
3657 i.mu.Unlock()
3658 if busy {
3659 if len(images) > 0 {
3660 i.mu.Lock()
3661 i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
3662 i.mu.Unlock()
3663 i.invalidate()
3664 return false
3665 }
3666 clearSubmittedInput()
3667 var handoff json.RawMessage
3668 var persistHandoff bool
3669 if ag != nil && !compacting {
3670 i.mu.Lock()
3671 handoff, persistHandoff = i.resetCompactContinuationLocked()
3672 ag.QueueMessage(text)
3673 i.mu.Unlock()
3674 } else {
3675 i.mu.Lock()
3676 handoff, persistHandoff = i.resetCompactContinuationLocked()
3677 i.queued = append(i.queued, text)
3678 i.mu.Unlock()
3679 }
3680 if persistHandoff {
3681 i.persistCompactHandoff(handoff)
3682 }
3683 i.invalidate()
3684 return false
3685 }
3686 clearSubmittedInput()
3687 i.startTurnWithImages(ctx, text, images)
3688 }
3689 return false
3690}
3691
3692func (i *Interactive) pasteClipboard(ctx context.Context) {
3693 image, ok, _ := tui.ReadClipboardImage(ctx)
3694 if ok {
3695 i.mu.Lock()
3696 marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
3697 i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
3698 Marker: marker,
3699 Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
3700 })
3701 i.ed.Insert(marker + " ")
3702 i.statusOK = ""
3703 i.statusErr = ""
3704 i.mu.Unlock()
3705 return
3706 }
3707 key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
3708 i.mu.Lock()
3709 defer i.mu.Unlock()
3710 if err != nil {
3711 i.statusErr = "clipboard paste failed: " + err.Error()
3712 i.statusOK = ""
3713 return
3714 }
3715 if pastedText {
3716 i.ed.HandleKey(key)
3717 i.statusErr = ""
3718 i.statusOK = ""
3719 return
3720 }
3721 i.statusErr = "clipboard does not contain text or an image"
3722 i.statusOK = ""
3723}
3724
3725func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
3726 if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3727 return false
3728 }
3729 // Do not steal normal vertical cursor movement. History browsing can only
3730 // start from an empty editor; once active, Up/Down keep walking
3731 // the ring so repeated presses work even though the editor now
3732 // contains the selected historical prompt.
3733 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
3734 return false
3735 }
3736 hist := i.inputHistory()
3737 if len(hist) == 0 {
3738 return false
3739 }
3740
3741 if i.inputHistoryIndex < 0 {
3742 // Start just after the newest item so Up lands on the most
3743 // recent user prompt. A lone Down from an empty editor is not
3744 // history navigation; let the caller fall through to normal UI
3745 // behavior instead.
3746 if k.Kind != tui.KeyUp {
3747 return false
3748 }
3749 i.inputHistoryIndex = len(hist)
3750 }
3751
3752 switch k.Kind {
3753 case tui.KeyUp:
3754 if i.inputHistoryIndex > 0 {
3755 i.inputHistoryIndex--
3756 }
3757 case tui.KeyDown:
3758 if i.inputHistoryIndex < len(hist) {
3759 i.inputHistoryIndex++
3760 }
3761 }
3762
3763 if i.inputHistoryIndex >= len(hist) {
3764 i.ed.Clear()
3765 } else {
3766 i.ed.SetValue(hist[i.inputHistoryIndex])
3767 }
3768 return true
3769}
3770
3771func (i *Interactive) inputHistory() []string {
3772 if i.agent == nil {
3773 return nil
3774 }
3775 msgs := i.agent.Messages()
3776 hist := make([]string, 0, len(msgs))
3777 for _, m := range msgs {
3778 if !isForkableUserMessage(m) {
3779 continue
3780 }
3781 text := userMessageText(m)
3782 if strings.TrimSpace(text) == "" {
3783 continue
3784 }
3785 hist = append(hist, text)
3786 }
3787 return hist
3788}
3789
3790func userMessageText(m provider.Message) string {
3791 var sb strings.Builder
3792 for _, c := range m.Content {
3793 if tb, ok := c.(provider.TextBlock); ok {
3794 if sb.Len() > 0 {
3795 sb.WriteByte('\n')
3796 }
3797 sb.WriteString(tb.Text)
3798 }
3799 }
3800 return sb.String()
3801}
3802
3803// invokeExtensionCommand fires an extension-registered slash command
3804// in a background goroutine, awaits the response, and applies the
3805// requested action (prompt / insert / display / noop). Errors and
3806// timeouts surface as a status_err line.
3807
3808func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
3809 resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
3810 if err != nil {
3811 i.mu.Lock()
3812 i.statusErr = "extension /" + name + ": " + err.Error()
3813 i.statusOK = ""
3814 i.mu.Unlock()
3815 i.invalidate()
3816 return
3817 }
3818 if resp.Error != "" {
3819 i.mu.Lock()
3820 i.statusErr = "extension /" + name + ": " + resp.Error
3821 i.statusOK = ""
3822 i.mu.Unlock()
3823 i.invalidate()
3824 return
3825 }
3826 switch resp.Action {
3827 case "open_panel":
3828 if resp.OpenPanel != nil {
3829 extName := name
3830 if i.cfg.Extensions != nil {
3831 if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3832 extName = owner
3833 }
3834 }
3835 i.OpenPanel(extName, *resp.OpenPanel)
3836 }
3837 case "prompt":
3838 if strings.TrimSpace(resp.Prompt) == "" {
3839 return
3840 }
3841 i.startTurn(i.runCtx, resp.Prompt)
3842 case "insert":
3843 i.ed.Insert(resp.Insert)
3844 i.invalidate()
3845 case "display":
3846 i.appendExtensionNote(name, resp.Display, "info")
3847 case "noop", "":
3848 // nothing
3849 default:
3850 i.mu.Lock()
3851 i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3852 i.mu.Unlock()
3853 i.invalidate()
3854 }
3855}
3856
3857// appendExtensionNote renders an extension-originated note in the
3858// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3859// "success" (tool/ok green).
3860func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3861 if msg == "" {
3862 return
3863 }
3864 i.mu.Lock()
3865 defer i.mu.Unlock()
3866 color := i.cfg.Theme.Muted
3867 switch level {
3868 case "warn":
3869 color = i.cfg.Theme.Warning
3870 case "error":
3871 color = i.cfg.Theme.Error
3872 case "success":
3873 color = i.cfg.Theme.Tool
3874 }
3875 prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
3876 for _, line := range strings.Split(msg, "\n") {
3877 i.statusOK = "" // clear any stale ok
3878 i.statusErr = ""
3879 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
3880 }
3881 // Extension slash commands complete asynchronously. If their result is
3882 // displayed while confirmation is waiting, keep Esc assigned to the note
3883 // until it is dismissed rather than letting it answer the tool call.
3884 i.confirmDialog.Blur()
3885}
3886
3887// HostHooks implementation for the extension manager. The manager
3888// holds an interface, not a concrete *Interactive, so these methods
3889// are the only thing the manager sees.
3890
3891// Notify is the manager's NotifyFromExt entry point.
3892func (i *Interactive) Notify(extName, level, message string) {
3893 i.appendExtensionNote(extName, message, level)
3894 i.invalidate()
3895}
3896
3897// Alert is the manager's AlertFromExt entry point. Alerts are emitted
3898// through the same terminal writer as redraws while holding the TUI mutex,
3899// so a BEL cannot be interleaved with a frame update.
3900func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
3901 i.mu.Lock()
3902 i.emitAlertLocked(alert)
3903 i.mu.Unlock()
3904}
3905
3906func terminalAlertsEnabled(enabled *bool) bool {
3907 return enabled == nil || *enabled
3908}
3909
3910// emitAlertLocked applies the shared terminal-alert policy. The caller must
3911// hold i.mu because terminal writes share the renderer's output boundary.
3912func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
3913 if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
3914 return
3915 }
3916 _ = tui.WriteBell(i.cfg.Terminal)
3917}
3918
3919// scheduleMainAlert defers a main-session alert until the next redraw
3920// commits the final frame. Keeping this deferred even when no paced text is
3921// pending avoids racing the pacer's final state transition.
3922func (i *Interactive) scheduleMainAlert(reason string) {
3923 if reason == "" {
3924 return
3925 }
3926 i.mu.Lock()
3927 i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3928 i.mu.Unlock()
3929 i.invalidate()
3930}
3931
3932// ClearNotes removes every note line owned by extName from the
3933// bottom-sticky ext-notes block. Extensions use this to retract a
3934// transient status line (e.g. an approval prompt) once it no longer
3935// applies, instead of leaving it stacked forever. Notes from other
3936// extensions and internal notes (auto-compact) are left untouched.
3937func (i *Interactive) ClearNotes(extName string) {
3938 marker := "[" + extName + "] "
3939 i.mu.Lock()
3940 if len(i.extNotes) == 0 {
3941 i.mu.Unlock()
3942 return
3943 }
3944 kept := i.extNotes[:0:0]
3945 changed := false
3946 for _, line := range i.extNotes {
3947 if strings.Contains(line, marker) {
3948 changed = true
3949 continue
3950 }
3951 kept = append(kept, line)
3952 }
3953 if changed {
3954 i.extNotes = kept
3955 }
3956 i.mu.Unlock()
3957 if changed {
3958 i.invalidate()
3959 }
3960}
3961
3962// Submit feeds text through the agent loop as if the user had typed it.
3963func (i *Interactive) Submit(text string) {
3964 if cmd, ok := shellEscapeCommand(text); ok {
3965 i.startShellEscape(i.runCtx, cmd)
3966 return
3967 }
3968 parent := i.runCtx
3969 if parent == nil {
3970 parent = context.Background()
3971 }
3972 i.mu.Lock()
3973 awaitingStartupPre := i.awaitingStartupPre
3974 i.mu.Unlock()
3975 if !awaitingStartupPre {
3976 i.maybeStartSessionTitle(parent, text)
3977 }
3978 i.startTurn(parent, text)
3979}
3980
3981// completeStartupPre applies deferred InitialInput after entry.pre finishes.
3982// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
3983// it only pre-fills the editor (CLI-supplied prompts).
3984func (i *Interactive) completeStartupPre() {
3985 i.mu.Lock()
3986 if !i.awaitingStartupPre {
3987 i.mu.Unlock()
3988 return
3989 }
3990 i.awaitingStartupPre = false
3991 deferred := i.deferredInitialInput
3992 auto := i.autoSubmitDeferred
3993 i.deferredInitialInput = ""
3994 i.autoSubmitDeferred = false
3995 onDone := i.cfg.OnStartupPreDone
3996 i.mu.Unlock()
3997 if onDone != nil {
3998 onDone()
3999 }
4000 i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
4001 i.invalidate()
4002}
4003
4004// applyStartupPreResult runs on the TUI event loop so the editor remains
4005// single-threaded. Input entered while resources were reloading wins over the
4006// deferred prefill rather than being overwritten.
4007func (i *Interactive) applyStartupPreResult(result startupPreResult) {
4008 if result.deferred == "" {
4009 return
4010 }
4011 if result.autoSubmit {
4012 i.Submit(result.deferred)
4013 return
4014 }
4015 if i.ed.IsEmpty() {
4016 i.ed.SetValue(result.deferred)
4017 }
4018}
4019
4020// ApplySessionAgent swaps the live agent after a validated session resume.
4021// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
4022// context; the session loader is changing the transcript/provider only.
4023func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
4024 i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
4025}
4026
4027func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
4028 if ag == nil {
4029 return
4030 }
4031 i.agentMu.Lock()
4032 defer i.agentMu.Unlock()
4033 i.mu.Lock()
4034 i.prepareReplacementAgentLocked(ag)
4035 i.compactContinuation = decodeCompactHandoff(compactHandoff)
4036 i.agent = ag
4037 i.cfg.Provider = providerName
4038 i.cfg.Model = model
4039 i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
4040 i.cumUsage = ag.Cost()
4041 last := ag.LastTurnUsage()
4042 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
4043 if len(i.view.Messages) > initialResumeTailLimit {
4044 i.view.TailLimit = initialResumeTailLimit
4045 } else {
4046 i.view.TailLimit = 0
4047 }
4048 i.view.InvalidateRenderCache()
4049 i.toolCalls = map[string]*tui.ToolCallView{}
4050 i.toolOrder = nil
4051 i.toolGate = map[string]int{}
4052 i.helpBlock = nil
4053 i.extNotes = nil
4054 i.parkedTurn = 0
4055 i.parkedTotal = 0
4056 i.mu.Unlock()
4057 i.invalidate()
4058}
4059
4060// SetSubagentSessionScope changes the live-worker filter and then requests a
4061// redraw. Session changes must use this boundary after their host/session
4062// state commits so input-adjacent activity cannot remain scoped to the prior
4063// session until another UI event arrives.
4064func (i *Interactive) SetSubagentSessionScope(sessionID string) {
4065 if i.cfg.Supervisor == nil {
4066 return
4067 }
4068 i.cfg.Supervisor.SetActiveSession(sessionID)
4069 i.invalidate()
4070}
4071
4072// ApplyChangedCWD is called by hosts after a successful /cd hook that do
4073// not provide startup context metadata.
4074func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
4075 i.applyChangedCWD(ag, provider, model, cwd, nil)
4076}
4077
4078// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
4079// startup context into the running TUI after a successful /cd hook.
4080func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
4081 i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
4082}
4083
4084func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
4085 home, _ := os.UserHomeDir()
4086 profiles, _ := subagents.Discover(cwd, home)
4087 subagentsAddendum := subagents.SystemPromptAddendum(profiles)
4088
4089 i.agentMu.Lock()
4090 defer i.agentMu.Unlock()
4091 i.mu.Lock()
4092 i.prepareReplacementAgentLocked(ag)
4093 i.resetCompactContinuationLocked()
4094 i.agent = ag
4095 i.cfg.CWD = cwd
4096 i.cfg.SubagentsSystemAddendum = subagentsAddendum
4097 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
4098 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
4099 i.view.StartupContextPaths = nil
4100 i.view.InvalidateRenderCache()
4101 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
4102 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
4103 }
4104 // Re-report the working directory to the terminal so "new tab here"
4105 // tracks the /cd change (OSC 7, see issue #38).
4106 if i.cfg.Terminal != nil {
4107 if seq := tui.ReportCWD(cwd); seq != "" {
4108 _, _ = i.cfg.Terminal.Write([]byte(seq))
4109 }
4110 }
4111 i.cfg.Provider = provider
4112 i.cfg.Model = model
4113 titleCancel := i.titleCancel
4114 i.titleCancel = nil
4115 i.titleVersion++
4116 i.sessionTitle = ""
4117 i.titleRealPromptSeen = false
4118 i.titleGenerationStarted = false
4119 i.writeTerminalTitleLocked("")
4120 i.toolCalls = map[string]*tui.ToolCallView{}
4121 i.toolOrder = nil
4122 i.toolGate = map[string]int{}
4123 i.helpBlock = nil
4124 i.parkedTurn = 0
4125 i.statusErr = ""
4126 i.mu.Unlock()
4127 if titleCancel != nil {
4128 titleCancel()
4129 }
4130 i.fileSuggest.Reset()
4131 i.fileSuggest.SetCWD(cwd)
4132 i.invalidate()
4133}
4134
4135// SubmitSlash runs text as a slash command in the TUI as if the user
4136// had typed it. text must start with '/' — callers that hand it
4137// plain prose silently get a no-op so a misbehaving extension can't
4138// run a stray prompt through this path. Read-only commands run in
4139// place; commands that would mutate the transcript or replace the
4140// agent cancel the active turn first via the same path the editor
4141// uses for typed slash commands.
4142func (i *Interactive) SubmitSlash(text string) {
4143 i.mu.Lock()
4144 i.doubleEscape.Reset()
4145 i.mu.Unlock()
4146 text = strings.TrimSpace(text)
4147 if !strings.HasPrefix(text, "/") {
4148 return
4149 }
4150 if slashCommandCancelsTurn(text) {
4151 i.cancelAndWaitForIdle()
4152 }
4153 i.runSlash(i.runCtx, text)
4154 i.invalidate()
4155}
4156
4157// SubmitOrQueue runs text immediately if the agent is idle, or
4158// appends it to the pending queue if a turn is already in flight.
4159// Used by the telegram bridge (and by the editor submit path) so
4160// both input sources share the same "queue behind an active turn"
4161// semantics. Images are ignored for now — only the text prompt is
4162// forwarded — because the queued-prompt path is text-only; a
4163// follow-up can expand the queue entry to carry images.
4164func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
4165 i.submitOrQueue(text, images, true)
4166}
4167
4168func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
4169 if cmd, ok := shellEscapeCommand(text); ok {
4170 i.startShellEscape(i.runCtx, cmd)
4171 return
4172 }
4173 i.mu.Lock()
4174 if i.agent == nil {
4175 i.statusErr = "not logged in. type /login first."
4176 i.mu.Unlock()
4177 i.invalidate()
4178 return
4179 }
4180 i.mu.Unlock()
4181 if userInput && i.cfg.EnsureMission != nil {
4182 if err := i.cfg.EnsureMission(text); err != nil {
4183 i.ReportError(fmt.Errorf("persist mission: %w", err))
4184 return
4185 }
4186 }
4187 // User input is an orchestration event, not merely a queue append. When
4188 // the manager is dormant behind a sealed worker wave, the coordinator makes
4189 // this the next manager turn and carries any completed worker summary with it.
4190 if userInput && i.coordinatorAcceptsUserInput() {
4191 if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 {
4192 i.executeCoordinatorActions(actions)
4193 return
4194 }
4195 }
4196 i.maybeStartSessionTitle(i.runCtx, text)
4197 i.mu.Lock()
4198 if i.busy {
4199 // Queue text only; images are dropped for queued prompts. Keep the
4200 // interactive mutex held through Agent.QueueMessage so turn teardown
4201 // cannot inspect the agent queue between the busy check and enqueue.
4202 // Compaction uses the host queue because it has no active agent loop
4203 // to drain Agent.QueueMessage entries.
4204 var handoff json.RawMessage
4205 var persistHandoff bool
4206 if i.agent != nil && !i.compacting {
4207 handoff, persistHandoff = i.resetCompactContinuationLocked()
4208 i.agent.QueueMessage(text)
4209 } else {
4210 handoff, persistHandoff = i.resetCompactContinuationLocked()
4211 i.queued = append(i.queued, text)
4212 }
4213 i.mu.Unlock()
4214 if persistHandoff {
4215 i.persistCompactHandoff(handoff)
4216 }
4217 i.invalidate()
4218 return
4219 }
4220 i.mu.Unlock()
4221 i.startTurnWithImages(i.runCtx, text, images)
4222}
4223
4224// CancelTurn aborts the active turn if one is running. Used by the
4225// telegram bridge when the paired user sends /stop.
4226// ChangelogVersion returns the version string of the changelog
4227// currently shown (or last shown). Used by the dismiss callback
4228// to store the correct version for dev builds.
4229func (i *Interactive) ChangelogVersion() string {
4230 if i.changelogDialog != nil {
4231 return i.changelogDialog.version
4232 }
4233 return ""
4234}
4235
4236func (i *Interactive) CancelTurn() {
4237 i.mu.Lock()
4238 cancel := i.cancelTurn
4239 i.mu.Unlock()
4240 if cancel != nil {
4241 i.cancelCoordinator()
4242
4243 i.mu.Lock()
4244 handoff, persistHandoff := i.resetCompactContinuationLocked()
4245 i.mu.Unlock()
4246 cancel()
4247 i.confirmDialog.CancelAll("turn cancelled")
4248 if persistHandoff {
4249 i.persistCompactHandoff(handoff)
4250 }
4251 }
4252}
4253
4254// Insert places text at the cursor in the editor.
4255func (i *Interactive) Insert(text string) {
4256 i.ed.Insert(text)
4257 i.invalidate()
4258}
4259
4260// Display appends a styled note from extName to the chat without a
4261// model call.
4262func (i *Interactive) Display(extName, text string) {
4263 i.appendExtensionNote(extName, text, "info")
4264 i.invalidate()
4265}
4266
4267// ReportError surfaces a host-side error in the interactive status area.
4268func (i *Interactive) ReportError(err error) {
4269 if err == nil {
4270 return
4271 }
4272 i.mu.Lock()
4273 i.statusOK = ""
4274 i.statusErr = err.Error()
4275 i.mu.Unlock()
4276 i.invalidate()
4277}
4278
4279// SetStatus replaces one persistent status item owned by an extension.
4280func (i *Interactive) SetStatus(extName, key, level, text string) {
4281 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
4282 return
4283 }
4284 i.mu.Lock()
4285 if i.extStatuses == nil {
4286 i.extStatuses = map[string]map[string]extensionStatus{}
4287 }
4288 if strings.TrimSpace(text) == "" {
4289 if items := i.extStatuses[extName]; items != nil {
4290 delete(items, key)
4291 if len(items) == 0 {
4292 delete(i.extStatuses, extName)
4293 }
4294 }
4295 } else {
4296 if i.extStatuses[extName] == nil {
4297 i.extStatuses[extName] = map[string]extensionStatus{}
4298 }
4299 i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
4300 }
4301 i.mu.Unlock()
4302 i.invalidate()
4303}
4304
4305// SetWidget replaces one persistent widget owned by an extension.
4306func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
4307 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
4308 return
4309 }
4310 position = extproto.NormalizeWidgetPosition(position)
4311 i.mu.Lock()
4312 if i.extWidgets == nil {
4313 i.extWidgets = map[string]map[string]extensionWidget{}
4314 }
4315 if i.extWidgets[extName] == nil {
4316 i.extWidgets[extName] = map[string]extensionWidget{}
4317 }
4318 i.extWidgets[extName][id] = extensionWidget{
4319 Position: position,
4320 Title: title,
4321 Lines: append([]string(nil), lines...),
4322 }
4323 i.mu.Unlock()
4324 i.invalidate()
4325}
4326
4327// ClearWidget removes one persistent widget owned by an extension.
4328func (i *Interactive) ClearWidget(extName, id string) {
4329 i.mu.Lock()
4330 if items := i.extWidgets[extName]; items != nil {
4331 delete(items, id)
4332 if len(items) == 0 {
4333 delete(i.extWidgets, extName)
4334 }
4335 }
4336 i.mu.Unlock()
4337 i.invalidate()
4338}
4339
4340// ClearExtensionChrome removes every persistent UI item owned by an
4341// extension that has exited. This keeps crashes, disable/reload operations,
4342// and startup failures from leaving stale widgets beside the transcript.
4343func (i *Interactive) ClearExtensionChrome(extName string) {
4344 if strings.TrimSpace(extName) == "" {
4345 return
4346 }
4347 marker := "[" + extName + "] "
4348 i.mu.Lock()
4349 changed := false
4350 if _, ok := i.extStatuses[extName]; ok {
4351 delete(i.extStatuses, extName)
4352 changed = true
4353 }
4354 if _, ok := i.extWidgets[extName]; ok {
4355 delete(i.extWidgets, extName)
4356 changed = true
4357 }
4358 if len(i.extNotes) > 0 {
4359 kept := i.extNotes[:0:0]
4360 for _, line := range i.extNotes {
4361 if strings.Contains(line, marker) {
4362 changed = true
4363 continue
4364 }
4365 kept = append(kept, line)
4366 }
4367 i.extNotes = kept
4368 }
4369 if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
4370 i.extPanel.Close()
4371 changed = true
4372 }
4373 i.mu.Unlock()
4374 if changed {
4375 i.invalidate()
4376 }
4377}
4378
4379func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
4380 i.mu.Lock()
4381 defer i.mu.Unlock()
4382 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
4383 i.confirmDialog.Blur()
4384 if i.cfg.Extensions != nil {
4385 cols, rows := i.cfg.Terminal.Size()
4386 _ = cols
4387 _ = rows
4388 }
4389 i.invalidate()
4390}
4391
4392func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
4393 i.mu.Lock()
4394 defer i.mu.Unlock()
4395 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
4396 i.extPanel.Update(title, lines, footer)
4397 i.invalidate()
4398 }
4399}
4400
4401func (i *Interactive) ClosePanel(extName, panelID string) {
4402 i.mu.Lock()
4403 defer i.mu.Unlock()
4404 if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
4405 i.extPanel.Close()
4406 i.invalidate()
4407 }
4408}
4409
4410func effectiveImageProtocol(override *bool) tui.ImageProtocol {
4411 detected := tui.DetectImageProtocol()
4412 if override == nil {
4413 return detected
4414 }
4415 if !*override {
4416 return tui.ImageProtocolNone
4417 }
4418 return detected
4419}
4420
4421func imageProtocolName(p tui.ImageProtocol) string {
4422 switch p {
4423 case tui.ImageProtocolKitty:
4424 return "kitty/ghostty"
4425 case tui.ImageProtocolITerm2:
4426 return "iTerm2"
4427 default:
4428 return "none"
4429 }
4430}
4431
4432func onOff(v bool) string {
4433 if v {
4434 return "enabled"
4435 }
4436 return "disabled"
4437}
4438
4439const modalBackdropDimPercent = 50
4440
4441func (i *Interactive) applyInputCursorColor() {
4442 if i == nil || i.cfg.Terminal == nil {
4443 return
4444 }
4445 color := tui.Color256(15)
4446 if i.cursorDimmed {
4447 color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
4448 }
4449 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
4450}
4451
4452// setInputCursorDimmed updates the terminal-owned cursor only when its layer
4453// changes. Reapplying cursor controls on every redraw can reset its blink
4454// timing before the terminal completes a normal blink cycle.
4455func (i *Interactive) setInputCursorDimmed(dimmed bool) {
4456 if i == nil || i.cursorDimmed == dimmed {
4457 return
4458 }
4459 i.cursorDimmed = dimmed
4460 i.applyInputCursorColor()
4461}
4462
4463func reasoningSettingOptions(levels []string) []settingsOption {
4464 descriptions := map[string]string{
4465 "": "no reasoning",
4466 "minimum": "very brief (~1k tokens)",
4467 "low": "light (~2k tokens)",
4468 "medium": "moderate (~8k tokens)",
4469 "high": "deep (~16k tokens)",
4470 "xhigh": "extra-high effort",
4471 "max": "unconstrained effort",
4472 }
4473 options := make([]settingsOption, 0, len(levels))
4474 for _, level := range levels {
4475 label := level
4476 if label == "" {
4477 label = "off"
4478 }
4479 options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
4480 }
4481 return options
4482}
4483
4484func (i *Interactive) reasoningSettingItem() settingsItem {
4485 levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
4486 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
4487 desc := "reasoning depth for reasoning-capable models"
4488 hint := ""
4489 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
4490 levels = provider.AvailableReasoningLevels(m)
4491 reasoning = provider.ClampReasoningForModel(m, reasoning)
4492 if !m.Reasoning {
4493 hint = "current model does not support reasoning"
4494 desc += "; current model does not support reasoning"
4495 } else if len(levels) == 1 {
4496 hint = "current model has no adjustable reasoning levels"
4497 desc += "; current model has no adjustable reasoning levels"
4498 } else {
4499 desc += "; only levels supported by the current model are shown"
4500 }
4501 }
4502 options := reasoningSettingOptions(levels)
4503 choice := 0
4504 for idx, opt := range options {
4505 if opt.value == reasoning {
4506 choice = idx
4507 break
4508 }
4509 }
4510
4511 return settingsItem{
4512 key: "reasoning",
4513 label: "reasoning level",
4514 desc: desc,
4515 options: options,
4516 choice: choice,
4517 hint: hint,
4518 }
4519}
4520
4521func (i *Interactive) openReasoningDialog() {
4522 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
4523}
4524
4525func (i *Interactive) openSettingsDialog() {
4526 detected := tui.DetectImageProtocol()
4527 imgEnabled := detected != tui.ImageProtocolNone
4528 if i.cfg.InlineImagesEnabled != nil {
4529 imgEnabled = *i.cfg.InlineImagesEnabled
4530 }
4531 imgDisabled := detected == tui.ImageProtocolNone
4532 imgHint := ""
4533 if imgDisabled {
4534 imgEnabled = false
4535 imgHint = "this terminal does not support inline images"
4536 } else {
4537 imgHint = "terminal supports " + imageProtocolName(detected)
4538 }
4539
4540 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
4541 terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
4542 autoSubagents := false
4543 if i.cfg.AutoSubagentsEnabled != nil {
4544 autoSubagents = *i.cfg.AutoSubagentsEnabled
4545 }
4546 autoSubagentsDisabled := !i.autoSubagentsAvailable()
4547 autoSubagentsHint := i.autoSubagentsUnavailableHint()
4548 if autoSubagentsDisabled {
4549 autoSubagents = false
4550 }
4551
4552 ponytailEnabled := i.ponytailEnabled()
4553 ponytailDisabled := !i.ponytailSettingsAvailable()
4554 ponytailHint := ""
4555 if ponytailDisabled {
4556 ponytailHint = "requires persistent settings and live prompt refresh support"
4557 }
4558
4559 webSearchEnabled := i.webSearchEffectiveEnabled()
4560 webSearchDisabled := !i.webSearchSettingsAvailable()
4561 webSearchHint := i.webSearchUnavailableHint()
4562
4563 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
4564 fastModeHint := "OpenAI service tier"
4565 if !provider.SupportsFastMode(i.cfg.Provider) {
4566 fastModeHint = "only supported for OpenAI providers"
4567 }
4568 lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4569 subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
4570
4571 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
4572 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
4573 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
4574 compactMode := i.compactModeEnabled()
4575 showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
4576 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
4577 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
4578 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4579 subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition)
4580 quickItems := i.quickModelSettingItems()
4581
4582 autoCompactOptions := []settingsOption{
4583 {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
4584 {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
4585 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
4586 {value: "85", label: "85%", desc: "default balance between history and headroom"},
4587 {value: "90", label: "90%", desc: "retain more history before compacting"},
4588 {value: "95", label: "95%", desc: "retain most history before compacting"},
4589 }
4590 autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4591 autoCompactChoice := 0
4592 for idx, opt := range autoCompactOptions {
4593 if opt.value == strconv.Itoa(autoCompactThreshold) {
4594 autoCompactChoice = idx
4595 break
4596 }
4597 }
4598
4599 reasoningItem := i.reasoningSettingItem()
4600
4601 themeName := i.cfg.ThemeName
4602 if themeName == "" {
4603 themeName = "auto"
4604 }
4605 if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
4606 themeName = "auto"
4607 i.cfg.ThemeName = ""
4608 if i.cfg.SettingsStore != nil {
4609 _ = i.cfg.SettingsStore.SetTheme("auto")
4610 }
4611 i.applyThemeNow("auto")
4612 }
4613 themeOptions := []settingsOption{}
4614 themeChoice := 0
4615 availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
4616 if i.cfg.ExtensionThemes != nil {
4617 availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
4618 }
4619 for idx, opt := range availableThemes {
4620 themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
4621 if opt.Value == themeName {
4622 themeChoice = idx
4623 }
4624 }
4625
4626 inputStyleOptions := []settingsOption{
4627 {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
4628 {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
4629 {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
4630 }
4631 inputStyleChoice := 0
4632 for idx, opt := range inputStyleOptions {
4633 if opt.value == inputStyle {
4634 inputStyleChoice = idx
4635 break
4636 }
4637 }
4638 statusPositionOptions := []settingsOption{
4639 {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
4640 {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
4641 }
4642 statusPositionChoice := 0
4643 for idx, opt := range statusPositionOptions {
4644 if opt.value == statusPosition {
4645 statusPositionChoice = idx
4646 break
4647 }
4648 }
4649
4650 workingPositionOptions := []settingsOption{
4651 {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
4652 {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
4653 }
4654 workingPositionChoice := 0
4655 for idx, opt := range workingPositionOptions {
4656 if opt.value == workingPosition {
4657 workingPositionChoice = idx
4658 break
4659 }
4660 }
4661
4662 subagentPositionOptions := []settingsOption{
4663 {value: tui.SubagentPositionAboveInput, label: "above input", desc: "show running subagents above the input"},
4664 {value: tui.SubagentPositionBelowInput, label: "below input", desc: "show running subagents immediately below the input"},
4665 }
4666 subagentPositionChoice := 0
4667 for idx, opt := range subagentPositionOptions {
4668 if opt.value == subagentPosition {
4669 subagentPositionChoice = idx
4670 break
4671 }
4672 }
4673
4674 items := []settingsItem{
4675 {
4676 key: "inline_images_enabled",
4677 label: "render images when supported",
4678 desc: "draw screenshots inline instead of showing a text placeholder",
4679 value: imgEnabled,
4680 disabled: imgDisabled,
4681 hint: imgHint,
4682 },
4683 {
4684 key: "terminal_alerts_enabled",
4685 label: "terminal alerts",
4686 desc: "ring the terminal when the main session needs attention or an extension raises an alert",
4687 value: terminalAlerts,
4688 },
4689 {
4690 key: "terminal_title_enabled",
4691 label: "AI terminal titles",
4692 desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
4693 value: terminalTitles,
4694 },
4695 {
4696 key: "auto_subagents_enabled",
4697 label: "Subagent Orchestrator",
4698 desc: "automatically delegate coding work to sub-agents; tools stay available when disabled",
4699 value: autoSubagents,
4700 disabled: autoSubagentsDisabled,
4701 hint: autoSubagentsHint,
4702 },
4703 {
4704 key: "ponytail_enabled",
4705 label: "ponytail coding mode",
4706 desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4707 value: ponytailEnabled,
4708 disabled: ponytailDisabled,
4709 hint: ponytailHint,
4710 },
4711 {
4712 key: "web_search_enabled",
4713 label: "web search",
4714 desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend",
4715 value: webSearchEnabled,
4716 disabled: webSearchDisabled,
4717 hint: webSearchHint,
4718 },
4719 {
4720 key: "fast_mode",
4721 label: "fast mode",
4722 desc: "request the provider's fast tier where supported; unsupported providers return an error",
4723 value: fastMode,
4724 hint: fastModeHint,
4725 },
4726 {
4727 key: "lsp_enabled",
4728 label: "lsp in main session",
4729 desc: "enable the lsp tool and code diagnostics for the main agent",
4730 value: lspEnabled,
4731 },
4732 {
4733 key: "subagent_lsp_enabled",
4734 label: "lsp in sub-agents",
4735 desc: "allow newly spawned background sub-agents to use the lsp tool",
4736 value: subagentLSPEnabled,
4737 },
4738 {
4739 key: "auto_compact_threshold",
4740 label: "auto-compact threshold",
4741 desc: "choose how full the model context can get before zut condenses conversation history",
4742 options: autoCompactOptions,
4743 choice: autoCompactChoice,
4744 },
4745 {
4746 key: "jail_by_default",
4747 label: "jail new sessions by default",
4748 desc: "confine tools to the session working directory unless /unjail is used",
4749 value: jailByDefault,
4750 },
4751 {
4752 key: "recursive_file_suggest",
4753 label: "recursive @-file search",
4754 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
4755 value: recursiveFiles,
4756 },
4757 {
4758 key: "respect_gitignore",
4759 label: "hide gitignored files in @-picker",
4760 desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
4761 value: respectGitignore,
4762 },
4763 {
4764 key: "compact_mode",
4765 label: "compact transcript rendering",
4766 desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
4767 value: compactMode,
4768 },
4769 {
4770 key: "show_instructions_at_startup",
4771 label: "show loaded resources at startup",
4772 desc: "list loaded context files, extensions, and user-installed skills above the transcript",
4773 value: showInstructions,
4774 },
4775 {
4776 key: "tui_settings",
4777 label: "tui settings",
4778 desc: "choose input chrome in its order around the prompt",
4779 children: []settingsItem{
4780 {
4781 key: "tui_status_position",
4782 label: "status position",
4783 desc: "place model, usage, and working directory above or below the input",
4784 options: statusPositionOptions,
4785 choice: statusPositionChoice,
4786 },
4787 {
4788 key: "tui_working_position",
4789 label: "working spinner position",
4790 desc: "place the working spinner above or below the input",
4791 options: workingPositionOptions,
4792 choice: workingPositionChoice,
4793 },
4794 {
4795 key: "tui_input_style",
4796 label: "input style",
4797 desc: "choose between the plain prompt, lines, and a block input area",
4798 options: inputStyleOptions,
4799 choice: inputStyleChoice,
4800 },
4801 {
4802 key: "tui_subagent_position",
4803 label: "running subagent position",
4804 desc: "place live subagent activity above or below the input",
4805 options: subagentPositionOptions,
4806 choice: subagentPositionChoice,
4807 },
4808 },
4809 },
4810 reasoningItem,
4811 {
4812 key: "theme",
4813 label: "color theme",
4814 desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
4815 options: themeOptions,
4816 choice: themeChoice,
4817 },
4818 }
4819 if len(quickItems) > 0 {
4820 items = append(items, settingsItem{
4821 key: "quick_models",
4822 label: "model shortcuts",
4823 desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
4824 children: quickItems,
4825 })
4826 }
4827 i.settingsDialog.Open(items)
4828}
4829
4830func (i *Interactive) applySettingChange(act settingsAction) {
4831 switch {
4832 case strings.HasPrefix(act.Key, "quick_model_"):
4833 i.applyQuickModelSetting(act.Key, act.StringValue)
4834 case act.Key == "reasoning":
4835 i.applyReasoningSetting(act.StringValue)
4836 case act.Key == "theme":
4837 i.applyThemeSetting(act.StringValue)
4838 case act.Key == "auto_compact_threshold":
4839 i.applyAutoCompactThresholdSetting(act.StringValue)
4840 case act.Key == "tui_input_style":
4841 i.applyTUIInputStyleSetting(act.StringValue)
4842 case act.Key == "tui_status_position":
4843 i.applyTUIStatusPositionSetting(act.StringValue)
4844 case act.Key == "tui_working_position":
4845 i.applyTUIWorkingPositionSetting(act.StringValue)
4846 case act.Key == "tui_subagent_position":
4847 i.applyTUISubagentPositionSetting(act.StringValue)
4848 default:
4849 i.applySettingToggle(act.Key, act.Value)
4850 }
4851}
4852
4853func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
4854 threshold, err := strconv.Atoi(value)
4855 if err != nil {
4856 return
4857 }
4858 threshold = NormalizeAutoCompactThreshold(&threshold)
4859 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
4860 if err := store.SetAutoCompactThreshold(threshold); err != nil {
4861 i.mu.Lock()
4862 i.statusErr = "settings: " + err.Error()
4863 i.mu.Unlock()
4864 return
4865 }
4866 }
4867 i.mu.Lock()
4868 i.cfg.AutoCompactThreshold = &threshold
4869 if threshold == 0 {
4870 i.statusOK = "auto-compact threshold off"
4871 } else {
4872 i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
4873 }
4874 i.statusErr = ""
4875 i.mu.Unlock()
4876 i.invalidate()
4877}
4878
4879func (i *Interactive) quickModelSettingItems() []settingsItem {
4880 if len(i.cfg.QuickModelShortcuts) < 9 {
4881 next := make([]QuickModelShortcut, 9)
4882 copy(next, i.cfg.QuickModelShortcuts)
4883 i.cfg.QuickModelShortcuts = next
4884 }
4885 items := make([]settingsItem, 0, 9)
4886 for slot := 1; slot <= 9; slot++ {
4887 items = append(items, i.quickModelSettingItem(slot))
4888 }
4889 return items
4890}
4891
4892func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
4893 current := QuickModelShortcut{}
4894 if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
4895 current = i.cfg.QuickModelShortcuts[slot-1]
4896 }
4897 hint := "not assigned"
4898 if current.Provider != "" && current.Model != "" {
4899 hint = current.Provider + " / " + current.Model
4900 }
4901 return settingsItem{
4902 key: "quick_model_" + strconv.Itoa(slot),
4903 label: "model " + strconv.Itoa(slot),
4904 desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
4905 picker: true,
4906 hint: hint,
4907 }
4908}
4909
4910func (i *Interactive) compactModeEnabled() bool {
4911 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
4912}
4913
4914func quickModelShortcutSlot(k tui.Key) int {
4915 if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
4916 return 0
4917 }
4918 if runtime.GOOS == "darwin" {
4919 if !k.Super && !k.Ctrl {
4920 return 0
4921 }
4922 } else if !k.Ctrl {
4923 return 0
4924 }
4925 return int(k.Rune - '0')
4926}
4927
4928func quickModelShortcutPrefix() string {
4929 return "Ctrl"
4930}
4931
4932func quickModelShortcutLabel(slot int) string {
4933 return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
4934}
4935
4936func (i *Interactive) openQuickModelPicker(slot int) {
4937 if slot < 1 || slot > 9 {
4938 return
4939 }
4940 i.quickModelAssign = slot
4941 current := i.cfg.Model
4942 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
4943 current = i.cfg.QuickModelShortcuts[slot-1].Model
4944 }
4945 var loggedIn []string
4946 if i.cfg.LoggedInProviders != nil {
4947 loggedIn = i.cfg.LoggedInProviders()
4948 }
4949 i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
4950}
4951
4952func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
4953 i.setQuickModelShortcut(slot, providerName, model)
4954}
4955
4956func (i *Interactive) applyQuickModelShortcut(slot int) {
4957 if slot < 1 || slot > 9 {
4958 return
4959 }
4960 if i.busy {
4961 i.mu.Lock()
4962 i.statusErr = "cannot switch model while a turn is running"
4963 i.statusOK = ""
4964 i.mu.Unlock()
4965 i.invalidate()
4966 return
4967 }
4968 if len(i.cfg.QuickModelShortcuts) < slot {
4969 i.mu.Lock()
4970 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4971 i.statusOK = ""
4972 i.mu.Unlock()
4973 i.invalidate()
4974 return
4975 }
4976 shortcut := i.cfg.QuickModelShortcuts[slot-1]
4977 if shortcut.Provider == "" || shortcut.Model == "" {
4978 i.mu.Lock()
4979 i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4980 i.statusOK = ""
4981 i.mu.Unlock()
4982 i.invalidate()
4983 return
4984 }
4985 i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
4986 i.invalidate()
4987}
4988
4989func (i *Interactive) applyQuickModelSetting(key, value string) {
4990 slotText := strings.TrimPrefix(key, "quick_model_")
4991 slot, err := strconv.Atoi(slotText)
4992 if err != nil || slot < 1 || slot > 9 {
4993 return
4994 }
4995 providerName, model := "", ""
4996 if value != "" {
4997 parts := strings.SplitN(value, "\t", 2)
4998 if len(parts) == 2 {
4999 providerName, model = parts[0], parts[1]
5000 }
5001 }
5002 i.setQuickModelShortcut(slot, providerName, model)
5003}
5004
5005func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
5006 if len(i.cfg.QuickModelShortcuts) < slot {
5007 next := make([]QuickModelShortcut, slot)
5008 copy(next, i.cfg.QuickModelShortcuts)
5009 i.cfg.QuickModelShortcuts = next
5010 }
5011 i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
5012 if i.cfg.SettingsStore != nil {
5013 if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
5014 i.mu.Lock()
5015 i.statusErr = "settings: " + err.Error()
5016 i.mu.Unlock()
5017 return
5018 }
5019 }
5020 i.mu.Lock()
5021 if model == "" {
5022 i.statusOK = quickModelShortcutLabel(slot) + " cleared"
5023 } else {
5024 i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
5025 }
5026 i.statusErr = ""
5027 i.mu.Unlock()
5028 i.refreshQuickModelSettingsItem(slot)
5029 i.invalidate()
5030}
5031
5032func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
5033 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
5034 return
5035 }
5036 key := "quick_model_" + strconv.Itoa(slot)
5037 for idx, it := range i.settingsDialog.items {
5038 if it.key == key {
5039 i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
5040 return
5041 }
5042 }
5043}
5044
5045func (i *Interactive) resetSettingsToggle(key string, value bool) {
5046 if i.settingsDialog == nil {
5047 return
5048 }
5049 for idx := range i.settingsDialog.items {
5050 if i.settingsDialog.items[idx].key == key {
5051 i.settingsDialog.items[idx].value = value
5052 return
5053 }
5054 }
5055}
5056
5057func (i *Interactive) ponytailEnabled() bool {
5058 return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
5059}
5060
5061func (i *Interactive) ponytailSettingsAvailable() bool {
5062 _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
5063 return persistent && i.cfg.RefreshPrompt != nil
5064}
5065
5066func (i *Interactive) webSearchEnabled() bool {
5067 return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
5068}
5069
5070func (i *Interactive) webSearchToolAllowed() bool {
5071 return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
5072}
5073
5074func (i *Interactive) webSearchEffectiveEnabled() bool {
5075 return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
5076}
5077
5078func (i *Interactive) webSearchSettingsAvailable() bool {
5079 _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
5080 return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
5081}
5082
5083func (i *Interactive) webSearchUnavailableHint() string {
5084 if !i.webSearchToolAllowed() {
5085 return "unavailable in this session due to the launch-time tool capability ceiling"
5086 }
5087 if i.cfg.WebSearchInvocationOverride {
5088 return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
5089 }
5090 if !i.webSearchSettingsAvailable() {
5091 return "requires persistent settings and live tool refresh support"
5092 }
5093 return ""
5094}
5095
5096func (i *Interactive) setWebSearchAvailable(available bool) {
5097 // Advance before publishing the new gate/child ceiling. A resolver that
5098 // captured the prior generation can no longer commit after this transition.
5099 // Callers may hold agentMu and i.mu; the callback must complete without
5100 // calling back into Interactive or requiring either lock.
5101 i.webSearchPolicyGeneration.Add(1)
5102 if i.cfg.SetWebSearchAvailable != nil {
5103 i.cfg.SetWebSearchAvailable(available)
5104 }
5105}
5106
5107func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
5108 previous := i.webSearchEnabled()
5109 store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
5110 if !i.webSearchSettingsAvailable() || !persistent {
5111 i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
5112 i.mu.Lock()
5113 i.statusOK = ""
5114 i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
5115 i.mu.Unlock()
5116 return
5117 }
5118 if err := store.SetWebSearchEnabled(value); err != nil {
5119 i.resetSettingsToggle(key, previous)
5120 i.mu.Lock()
5121 i.statusOK = ""
5122 i.statusErr = "settings: " + err.Error()
5123 i.mu.Unlock()
5124 return
5125 }
5126 val := value
5127 i.cfg.WebSearchEnabled = &val
5128 // Revoke and advance the policy generation for both directions. Enabling
5129 // remains fail-closed until the freshly resolved registry commits, while a
5130 // stale resolve from before this persisted transition is rejected.
5131 i.stripWebSearchTool()
5132 if err := i.cfg.RefreshTools(); err != nil {
5133 // Refresh failure leaves the old registry's state unknown. Revoke both
5134 // live execution and generic-child inheritance before rollback.
5135 i.stripWebSearchTool()
5136 errMsg := "settings: web search tool refresh: " + err.Error()
5137 if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
5138 failClosed := false
5139 i.cfg.WebSearchEnabled = &failClosed
5140 i.resetSettingsToggle(key, false)
5141 errMsg += "; rollback persistence: " + rollbackErr.Error()
5142 } else {
5143 previousVal := previous
5144 i.cfg.WebSearchEnabled = &previousVal
5145 i.resetSettingsToggle(key, previous)
5146 if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
5147 errMsg += "; rollback refresh: " + refreshErr.Error()
5148 i.stripWebSearchTool()
5149 }
5150 }
5151 i.mu.Lock()
5152 i.statusOK = ""
5153 i.statusErr = errMsg
5154 i.mu.Unlock()
5155 return
5156 }
5157 i.mu.Lock()
5158 i.statusOK = "web search " + onOff(value)
5159 i.statusErr = ""
5160 i.mu.Unlock()
5161}
5162
5163// stripWebSearchTool removes the callable web_search entry from the live
5164// registry without asking the resolver to run again. It is the fail-closed
5165// fallback when persistence rollback fails after a refresh failure.
5166func (i *Interactive) stripWebSearchTool() {
5167 // Revoke first so a stale tool snapshotted by core cannot execute while
5168 // the advertised registry is being replaced.
5169 i.setWebSearchAvailable(false)
5170 i.agentMu.Lock()
5171 defer i.agentMu.Unlock()
5172 i.mu.Lock()
5173 defer i.mu.Unlock()
5174 if i.agent == nil {
5175 return
5176 }
5177 tools := i.agent.ToolsSnapshot()
5178 if _, ok := tools["web_search"]; !ok {
5179 return
5180 }
5181 delete(tools, "web_search")
5182 i.agent.SetTools(tools)
5183}
5184
5185func (i *Interactive) applySettingToggle(key string, value bool) {
5186 // Every setting toggle forces a full repaint at the end — same
5187 // effect as the user pressing Ctrl+L — so any per-setting visual
5188 // change (image rendering, status copy, future toggles) lands
5189 // immediately instead of waiting for the next diff frame.
5190 defer func() {
5191 i.requestRendererClear()
5192 i.invalidate()
5193 }()
5194 switch key {
5195 case "inline_images_enabled":
5196 val := value
5197 i.cfg.InlineImagesEnabled = &val
5198 if i.cfg.SettingsStore != nil {
5199 if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
5200 i.mu.Lock()
5201 i.statusErr = "settings: " + err.Error()
5202 i.mu.Unlock()
5203 return
5204 }
5205 }
5206 i.mu.Lock()
5207 i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
5208 i.view.InvalidateRenderCache()
5209 i.statusOK = "inline image rendering " + onOff(value)
5210 i.statusErr = ""
5211 i.mu.Unlock()
5212 case "terminal_alerts_enabled":
5213 val := value
5214 i.mu.Lock()
5215 i.cfg.TerminalAlertsEnabled = &val
5216 i.mu.Unlock()
5217 if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
5218 if err := store.SetTerminalAlertsEnabled(value); err != nil {
5219 i.mu.Lock()
5220 i.statusErr = "settings: " + err.Error()
5221 i.mu.Unlock()
5222 return
5223 }
5224 }
5225 i.mu.Lock()
5226 i.statusOK = "terminal alerts " + onOff(value)
5227 i.statusErr = ""
5228 i.mu.Unlock()
5229 case "terminal_title_enabled":
5230 val := value
5231 var cancel context.CancelFunc
5232 i.mu.Lock()
5233 i.cfg.TerminalTitleEnabled = &val
5234 if value {
5235 i.writeTerminalTitleLocked(i.sessionTitle)
5236 } else {
5237 cancel = i.titleCancel
5238 i.titleCancel = nil
5239 i.titleVersion++
5240 i.writeTerminalTitleLocked("")
5241 }
5242 i.mu.Unlock()
5243 if cancel != nil {
5244 cancel()
5245 }
5246 if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
5247 if err := store.SetTerminalTitleEnabled(value); err != nil {
5248 i.mu.Lock()
5249 i.statusErr = "settings: " + err.Error()
5250 i.mu.Unlock()
5251 return
5252 }
5253 }
5254 i.mu.Lock()
5255 i.statusOK = "AI terminal titles " + onOff(value)
5256 i.statusErr = ""
5257 i.mu.Unlock()
5258 case "web_search_enabled":
5259 apply := func() { i.applyWebSearchSettingToggle(key, value) }
5260 if i.cfg.SessionTransition != nil {
5261 i.cfg.SessionTransition(apply)
5262 } else {
5263 apply()
5264 }
5265 case "ponytail_enabled":
5266 previous := i.ponytailEnabled()
5267 store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
5268 if !available || i.cfg.RefreshPrompt == nil {
5269 i.resetSettingsToggle(key, previous)
5270 i.mu.Lock()
5271 i.statusOK = ""
5272 i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
5273 i.mu.Unlock()
5274 return
5275 }
5276 if err := store.SetPonytailEnabled(value); err != nil {
5277 i.resetSettingsToggle(key, previous)
5278 i.mu.Lock()
5279 i.statusOK = ""
5280 i.statusErr = "settings: " + err.Error()
5281 i.mu.Unlock()
5282 return
5283 }
5284 val := value
5285 i.cfg.PonytailEnabled = &val
5286 if err := i.cfg.RefreshPrompt(); err != nil {
5287 errMsg := "settings: ponytail prompt refresh: " + err.Error()
5288 if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
5289 // The first write succeeded, so keep the in-memory setting at
5290 // the value that remains durable instead of claiming that the
5291 // rollback took effect.
5292 i.resetSettingsToggle(key, value)
5293 errMsg += "; rollback persistence: " + rollbackErr.Error()
5294 if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
5295 errMsg += "; durable-state refresh: " + reconcileErr.Error()
5296 }
5297 } else {
5298 previousVal := previous
5299 i.cfg.PonytailEnabled = &previousVal
5300 i.resetSettingsToggle(key, previous)
5301 if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
5302 errMsg += "; rollback refresh: " + refreshErr.Error()
5303 }
5304 }
5305 i.mu.Lock()
5306 i.statusOK = ""
5307 i.statusErr = errMsg
5308 i.mu.Unlock()
5309 return
5310 }
5311 i.mu.Lock()
5312 i.statusOK = "ponytail coding mode " + onOff(value)
5313 i.statusErr = ""
5314 i.mu.Unlock()
5315 case "auto_subagents_enabled":
5316 if value && !i.autoSubagentsAvailable() {
5317 i.mu.Lock()
5318 i.statusOK = ""
5319 i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
5320 i.mu.Unlock()
5321 return
5322 }
5323 i.mu.Lock()
5324 previousFlag := i.cfg.AutoSubagentsEnabled
5325 previous := previousFlag != nil && *previousFlag
5326 val := value
5327 i.cfg.AutoSubagentsEnabled = &val
5328 i.mu.Unlock()
5329 if i.cfg.SettingsStore != nil {
5330 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
5331 i.mu.Lock()
5332 i.cfg.AutoSubagentsEnabled = previousFlag
5333 i.statusOK = ""
5334 i.statusErr = "settings: " + err.Error()
5335 i.mu.Unlock()
5336 i.resetSettingsToggle(key, previous)
5337 return
5338 }
5339 }
5340 // The subagent tools remain available for explicit user requests.
5341 // Swap only the system-prompt guidance between strict orchestration and
5342 // on-demand delegation.
5343 i.applyAutoSubagentsTool()
5344 // Also swap the system-prompt addendum so the model knows whether it
5345 // should orchestrate proactively or delegate only on user request.
5346 i.applyAutoSubagentsSystemPrompt(value)
5347 i.mu.Lock()
5348 i.statusOK = "subagent orchestrator " + onOff(value)
5349 i.statusErr = ""
5350 i.mu.Unlock()
5351 case "fast_mode":
5352 previous := i.cfg.FastMode != nil && *i.cfg.FastMode
5353 if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
5354 i.resetSettingsToggle(key, previous)
5355 i.mu.Lock()
5356 i.statusErr = "settings: " + err.Error()
5357 i.mu.Unlock()
5358 return
5359 }
5360 if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
5361 if err := store.SetFastMode(value); err != nil {
5362 i.resetSettingsToggle(key, previous)
5363 i.mu.Lock()
5364 i.statusErr = "settings: " + err.Error()
5365 i.mu.Unlock()
5366 return
5367 }
5368 }
5369 val := value
5370 i.cfg.FastMode = &val
5371 if i.agent != nil {
5372 i.agent.SetFastMode(value)
5373 }
5374 if i.cfg.Supervisor != nil {
5375 i.cfg.Supervisor.SetFastMode(value)
5376 }
5377 i.mu.Lock()
5378 i.statusOK = "fast mode " + onOff(value)
5379 i.statusErr = ""
5380 i.mu.Unlock()
5381 case "lsp_enabled":
5382 previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
5383 store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
5384 if hasStore {
5385 if err := store.SetLSPEnabled(value); err != nil {
5386 i.mu.Lock()
5387 i.statusErr = "settings: " + err.Error()
5388 i.mu.Unlock()
5389 return
5390 }
5391 }
5392 val := value
5393 i.cfg.LSPEnabled = &val
5394 if i.cfg.RefreshTools != nil {
5395 if err := i.cfg.RefreshTools(); err != nil {
5396 errMsg := "settings: refresh tools: " + err.Error()
5397 rollbackErr := error(nil)
5398 if hasStore {
5399 rollbackErr = store.SetLSPEnabled(previous)
5400 }
5401 if rollbackErr != nil {
5402 // The first write succeeded, so keep the in-memory setting at
5403 // the value that remains durable instead of claiming that the
5404 // rollback took effect.
5405 errMsg += "; rollback persistence: " + rollbackErr.Error()
5406 if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
5407 errMsg += "; durable-state refresh: " + reconcileErr.Error()
5408 }
5409 } else {
5410 previousVal := previous
5411 i.cfg.LSPEnabled = &previousVal
5412 if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
5413 errMsg += "; rollback refresh: " + refreshErr.Error()
5414 }
5415 }
5416 i.mu.Lock()
5417 i.statusOK = ""
5418 i.statusErr = errMsg
5419 i.mu.Unlock()
5420 return
5421 }
5422 }
5423 i.mu.Lock()
5424 i.statusOK = "lsp in main session " + onOff(value)
5425 i.statusErr = ""
5426 i.mu.Unlock()
5427 case "subagent_lsp_enabled":
5428 if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
5429 if err := store.SetSubagentLSPEnabled(value); err != nil {
5430 i.mu.Lock()
5431 i.statusErr = "settings: " + err.Error()
5432 i.mu.Unlock()
5433 return
5434 }
5435 }
5436 val := value
5437 i.cfg.SubagentLSPEnabled = &val
5438 i.mu.Lock()
5439 i.statusOK = "lsp in sub-agents " + onOff(value)
5440 i.statusErr = ""
5441 i.mu.Unlock()
5442 case "jail_by_default":
5443 val := value
5444 i.cfg.JailByDefault = &val
5445 if i.cfg.SettingsStore != nil {
5446 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
5447 i.mu.Lock()
5448 i.statusErr = "settings: " + err.Error()
5449 i.mu.Unlock()
5450 return
5451 }
5452 }
5453 if i.cfg.Sandbox != nil {
5454 if value {
5455 i.cfg.Sandbox.Lock()
5456 } else {
5457 i.cfg.Sandbox.Unlock()
5458 }
5459 }
5460 i.mu.Lock()
5461 i.statusOK = "jail by default " + onOff(value)
5462 i.statusErr = ""
5463 i.mu.Unlock()
5464 case "recursive_file_suggest":
5465 val := value
5466 i.cfg.RecursiveFileSuggest = &val
5467 if i.cfg.SettingsStore != nil {
5468 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
5469 i.mu.Lock()
5470 i.statusErr = "settings: " + err.Error()
5471 i.mu.Unlock()
5472 return
5473 }
5474 }
5475 // Flip the live picker so the next @ reflects the new mode
5476 // without restarting zut. SetRecursive drops its cache.
5477 i.fileSuggest.SetRecursive(value)
5478 i.mu.Lock()
5479 i.statusOK = "recursive @-file search " + onOff(value)
5480 i.statusErr = ""
5481 i.mu.Unlock()
5482 case "respect_gitignore":
5483 val := value
5484 i.cfg.RespectGitignore = &val
5485 if i.cfg.SettingsStore != nil {
5486 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
5487 i.mu.Lock()
5488 i.statusErr = "settings: " + err.Error()
5489 i.mu.Unlock()
5490 return
5491 }
5492 }
5493 i.fileSuggest.SetRespectGitignore(value)
5494 i.mu.Lock()
5495 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
5496 i.statusErr = ""
5497 i.mu.Unlock()
5498 case "compact_mode":
5499 val := value
5500 i.cfg.CompactMode = &val
5501 if i.cfg.SettingsStore != nil {
5502 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
5503 i.mu.Lock()
5504 i.statusErr = "settings: " + err.Error()
5505 i.mu.Unlock()
5506 return
5507 }
5508 }
5509 i.mu.Lock()
5510 i.view.CompactMode = value
5511 i.view.InvalidateRenderCache()
5512 i.statusOK = "compact transcript rendering " + onOff(value)
5513 i.statusErr = ""
5514 i.mu.Unlock()
5515 case "show_instructions_at_startup":
5516 val := value
5517 i.cfg.ShowInstructionsAtStartup = &val
5518 if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
5519 if err := store.SetShowInstructionsAtStartup(value); err != nil {
5520 i.mu.Lock()
5521 i.statusErr = "settings: " + err.Error()
5522 i.mu.Unlock()
5523 return
5524 }
5525 }
5526 i.mu.Lock()
5527 i.view.StartupAgentName = ""
5528 i.view.StartupContextPaths = nil
5529 i.view.StartupExtensionNames = nil
5530 i.view.StartupSkillNames = nil
5531 i.view.InvalidateRenderCache()
5532 if value {
5533 i.view.StartupAgentName = i.cfg.StartupAgentName
5534 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
5535 i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
5536 i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
5537 }
5538 i.statusOK = "show loaded resources at startup " + onOff(value)
5539 i.statusErr = ""
5540 i.mu.Unlock()
5541 }
5542}
5543
5544func (i *Interactive) applyTUIInputStyleSetting(style string) {
5545 defer func() {
5546 i.requestRendererClear()
5547 i.invalidate()
5548 }()
5549 style = tui.NormalizeInputStyle(style)
5550 i.cfg.TUIInputStyle = style
5551 i.applyInputCursorColor()
5552 if i.cfg.SettingsStore != nil {
5553 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
5554 i.mu.Lock()
5555 i.statusErr = "settings: " + err.Error()
5556 i.mu.Unlock()
5557 return
5558 }
5559 }
5560 i.mu.Lock()
5561 i.statusOK = "input style " + style
5562 i.statusErr = ""
5563 i.mu.Unlock()
5564}
5565
5566func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5567 defer func() {
5568 i.requestRendererClear()
5569 i.invalidate()
5570 }()
5571 position = tui.NormalizeStatusPosition(position)
5572 i.cfg.TUIStatusPosition = position
5573 if i.cfg.SettingsStore != nil {
5574 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
5575 i.mu.Lock()
5576 i.statusErr = "settings: " + err.Error()
5577 i.mu.Unlock()
5578 return
5579 }
5580 }
5581 i.mu.Lock()
5582 label := "above input"
5583 if position == tui.StatusPositionBelowInput {
5584 label = "below input"
5585 }
5586 i.statusOK = "status position " + label
5587 i.statusErr = ""
5588 i.mu.Unlock()
5589}
5590
5591func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5592 defer func() {
5593 i.requestRendererClear()
5594 i.invalidate()
5595 }()
5596 position = tui.NormalizeWorkingPosition(position)
5597 i.cfg.TUIWorkingPosition = position
5598 if i.cfg.SettingsStore != nil {
5599 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
5600 i.mu.Lock()
5601 i.statusErr = "settings: " + err.Error()
5602 i.mu.Unlock()
5603 return
5604 }
5605 }
5606 i.mu.Lock()
5607 label := "above input"
5608 if position == tui.WorkingPositionBelowInput {
5609 label = "below input"
5610 }
5611 i.statusOK = "working spinner position " + label
5612 i.statusErr = ""
5613 i.mu.Unlock()
5614}
5615
5616func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5617 defer func() {
5618 i.requestRendererClear()
5619 i.invalidate()
5620 }()
5621 position = tui.NormalizeSubagentPosition(position)
5622 i.cfg.TUISubagentPosition = position
5623 if i.cfg.SettingsStore != nil {
5624 if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5625 i.mu.Lock()
5626 i.statusErr = "settings: " + err.Error()
5627 i.mu.Unlock()
5628 return
5629 }
5630 }
5631 i.mu.Lock()
5632 label := "below input"
5633 if position == tui.SubagentPositionAboveInput {
5634 label = "above input"
5635 }
5636 i.statusOK = "running subagent position " + label
5637 i.statusErr = ""
5638 i.mu.Unlock()
5639}
5640
5641func (i *Interactive) applyThemeSetting(name string) {
5642 if i.cfg.SettingsStore != nil {
5643 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
5644 i.mu.Lock()
5645 i.statusErr = "settings: " + err.Error()
5646 i.mu.Unlock()
5647 return
5648 }
5649 }
5650 i.cfg.ThemeName = name
5651 if name == "auto" {
5652 i.cfg.ThemeName = ""
5653 }
5654 i.applyThemeNow(name)
5655}
5656
5657func (i *Interactive) applyThemeNow(name string) {
5658 if name == "" {
5659 name = "auto"
5660 }
5661 detected := tui.Dark
5662 if tui.IsLightTheme(i.cfg.Theme) {
5663 detected = tui.Light
5664 }
5665 // Keep the startup terminal snapshot available when a user switches
5666 // themes live; inherited mode must not issue OSC queries while the TUI
5667 // already owns raw stdin.
5668 detected.Terminal = i.cfg.Theme.Terminal
5669 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
5670 if err != nil {
5671 if i.cfg.SettingsStore != nil {
5672 _ = i.cfg.SettingsStore.SetTheme("auto")
5673 }
5674 i.cfg.ThemeName = ""
5675 th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
5676 i.mu.Lock()
5677 i.statusErr = "theme missing; reset to default"
5678 i.mu.Unlock()
5679 } else {
5680 i.mu.Lock()
5681 label := applied
5682 if label == "" {
5683 label = "auto"
5684 }
5685 i.statusOK = "theme " + label
5686 i.statusErr = ""
5687 i.mu.Unlock()
5688 }
5689 i.mu.Lock()
5690 i.cfg.Theme = th
5691 i.view.Theme = th
5692 i.view.InvalidateRenderCache()
5693 i.mu.Unlock()
5694 i.ed.Prompt = th.AccentBar(th.Accent)
5695 i.applyInputCursorColor()
5696 i.spin.Configure(th)
5697 i.requestRendererTheme(th)
5698 i.requestRendererClear()
5699 i.invalidate()
5700}
5701
5702func (i *Interactive) applyReasoningSetting(level string) {
5703 defer func() {
5704 i.requestRendererClear()
5705 i.invalidate()
5706 }()
5707 level = provider.NormalizeReasoning(level)
5708 i.cfg.Reasoning = level
5709 if i.cfg.SettingsStore != nil {
5710 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
5711 i.mu.Lock()
5712 i.statusErr = "settings: " + err.Error()
5713 i.mu.Unlock()
5714 return
5715 }
5716 }
5717 if i.cfg.OnReasoningChanged != nil {
5718 i.cfg.OnReasoningChanged(level)
5719 }
5720 i.mu.Lock()
5721 if i.agent != nil {
5722 i.agent.Reasoning = level
5723 }
5724 label := level
5725 if label == "" {
5726 label = "off"
5727 }
5728 i.statusOK = "reasoning level " + label
5729 i.statusErr = ""
5730 i.mu.Unlock()
5731}
5732
5733// buildStudyPrompt returns the canned prompt the /study command
5734// submits to the agent.
5735//
5736// With no argument, /study targets the current directory — the
5737// historical behaviour. With an argument, /study targets that path
5738// instead; either a directory ("read every file in here") or a
5739// single file ("read this file"). The argument can be:
5740//
5741// - a relative path (resolved against cwd)
5742// - an absolute path
5743// - an @-picker chip, which has already been expanded to an
5744// absolute path by expandFileChips before runSlash sees it
5745//
5746// The path is stat'd to pick the right wording ("directory" vs
5747// "file"). If the path doesn't exist, we still build a sensible
5748// prompt rather than erroring — the agent will surface the
5749// missing-file failure itself when it tries to read it, which is
5750// more useful than a refusal here.
5751func buildStudyPrompt(arg, cwd string) string {
5752 arg = strings.TrimSpace(arg)
5753 if arg == "" {
5754 return "Read and understand everything in the current directory."
5755 }
5756 abs := arg
5757 if !filepath.IsAbs(abs) {
5758 abs = filepath.Join(cwd, abs)
5759 }
5760 display := arg
5761 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
5762 display = rel
5763 }
5764 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
5765 return "Read and understand the file " + display + "."
5766 }
5767 return "Read and understand everything in the directory " + display + "."
5768}
5769
5770// tryPathTabCompleteEditor looks at ed's current value, finds the
5771// path-like token immediately before the cursor (the cursor is always
5772// at the end of the buffer after a keystroke, so "before the cursor"
5773// is the trailing non-whitespace run), and rewrites it to its shell-
5774// style completion against the filesystem.
5775//
5776// Returns true when it consumed the Tab keystroke (token recognised,
5777// completion attempted — even if no candidates matched, the keystroke
5778// is still consumed so it doesn't insert a literal tab character).
5779// Returns false when the token doesn't look like a path; callers then
5780// let Tab fall through to its normal no-op.
5781//
5782// Recognised path shapes:
5783// - ~ or ~/foo expanded via os.UserHomeDir()
5784// - /abs/path or /abs/path/foo absolute
5785// - ./foo, ../foo, foo/bar relative to cwd
5786//
5787// A bare word like "hello" is not treated as a path so plain text
5788// keeps Tab as a literal no-op.
5789//
5790// Free function (not a method) so the same logic runs against the
5791// editor instances owned by btwDialog and subagentsDialog without each
5792// dialog needing its own copy.
5793func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
5794 if ed == nil {
5795 return false
5796 }
5797 val := ed.Value()
5798 // Find the trailing run of non-whitespace.
5799 start := len(val)
5800 for start > 0 {
5801 r := val[start-1]
5802 if r == ' ' || r == '\t' || r == '\n' {
5803 break
5804 }
5805 start--
5806 }
5807 token := val[start:]
5808 if token == "" {
5809 return false
5810 }
5811 if !looksLikePathToken(token) {
5812 return false
5813 }
5814
5815 // Resolve the absolute parent directory + base prefix to match.
5816 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
5817 if !ok {
5818 return true
5819 }
5820 entries, err := os.ReadDir(parentAbs)
5821 if err != nil {
5822 return true
5823 }
5824 var names []string
5825 var isDir []bool
5826 for _, e := range entries {
5827 name := e.Name()
5828 if !strings.HasPrefix(name, basePrefix) {
5829 continue
5830 }
5831 // Hide dotfiles unless the user explicitly typed a leading dot,
5832 // mirroring bash's default behaviour.
5833 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
5834 continue
5835 }
5836 names = append(names, name)
5837 isDir = append(isDir, e.IsDir())
5838 }
5839 if len(names) == 0 {
5840 return true
5841 }
5842
5843 var completed string
5844 var completedIsDir bool
5845 if len(names) == 1 {
5846 completed = names[0]
5847 completedIsDir = isDir[0]
5848 } else {
5849 completed = longestCommonPrefix(names)
5850 if completed == basePrefix {
5851 // Already at the deepest unambiguous prefix; nothing to add.
5852 return true
5853 }
5854 }
5855
5856 // Build the replacement token in the same display form the user
5857 // typed (preserve ~ vs absolute vs relative).
5858 newToken := displayParent + completed
5859 if len(names) == 1 && completedIsDir {
5860 newToken += "/"
5861 }
5862
5863 ed.SetValue(val[:start] + newToken)
5864 return true
5865}
5866
5867// tryPathTabComplete is the Interactive-bound convenience wrapper.
5868// It calls the free helper against the main editor and invalidates
5869// the frame on a successful rewrite.
5870func (i *Interactive) tryPathTabComplete() bool {
5871 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
5872 i.invalidate()
5873 return true
5874 }
5875 return false
5876}
5877
5878// looksLikePathToken reports whether tok is shaped like a filesystem
5879// path. Paths must either start with ~, /, ./, ../ or contain a /.
5880// Plain words are excluded so Tab on "hello" stays a no-op.
5881func looksLikePathToken(tok string) bool {
5882 if tok == "" {
5883 return false
5884 }
5885 if tok[0] == '~' || tok[0] == '/' {
5886 return true
5887 }
5888 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
5889 return true
5890 }
5891 return strings.Contains(tok, "/")
5892}
5893
5894// resolvePathTabToken splits tok into (absolute parent dir, basename
5895// prefix to match, display-form parent the user typed). ok is false
5896// when the parent dir can't be resolved (e.g. ~ with no $HOME).
5897func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
5898 // Detect ~ expansion.
5899 expanded := tok
5900 homePrefix := ""
5901 if tok == "~" {
5902 home, err := os.UserHomeDir()
5903 if err != nil || home == "" {
5904 return "", "", "", false
5905 }
5906 // "~" alone: complete in $HOME. parent = home, base = "".
5907 return home, "", "~/", true
5908 }
5909 if strings.HasPrefix(tok, "~/") {
5910 home, err := os.UserHomeDir()
5911 if err != nil || home == "" {
5912 return "", "", "", false
5913 }
5914 expanded = home + tok[1:]
5915 homePrefix = "~"
5916 }
5917
5918 dir, base := splitDirBase(expanded)
5919 if !filepath.IsAbs(dir) {
5920 dir = filepath.Join(cwd, dir)
5921 }
5922
5923 // Reconstruct the display form the user typed for the parent,
5924 // keeping ~ when they used it. The base is dropped — the caller
5925 // substitutes the completed name.
5926 display := tok[:len(tok)-len(base)]
5927 if homePrefix != "" && !strings.HasPrefix(display, "~") {
5928 display = homePrefix + display[len(homePrefix):]
5929 }
5930 return dir, base, display, true
5931}
5932
5933// splitDirBase is like filepath.Split but preserves the trailing
5934// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
5935// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
5936// the trailing separator when non-empty so callers can rebuild paths
5937// by concatenation.
5938func splitDirBase(p string) (dir, base string) {
5939 if p == "" {
5940 return ".", ""
5941 }
5942 i := strings.LastIndex(p, "/")
5943 if i < 0 {
5944 return ".", p
5945 }
5946 return p[:i+1], p[i+1:]
5947}
5948
5949func longestCommonPrefix(ss []string) string {
5950 if len(ss) == 0 {
5951 return ""
5952 }
5953 prefix := ss[0]
5954 for _, s := range ss[1:] {
5955 n := 0
5956 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
5957 n++
5958 }
5959 prefix = prefix[:n]
5960 if prefix == "" {
5961 return ""
5962 }
5963 }
5964 return prefix
5965}
5966
5967func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5968 parts := strings.Fields(cmd)
5969 if len(parts) == 0 {
5970 return false
5971 }
5972
5973 if isSkillCommand(cmd) {
5974 var available []*skills.Skill
5975 if i.cfg.SkillSnapshot != nil {
5976 available = i.cfg.SkillSnapshot()
5977 }
5978 prompt, _, err := expandSkillCommand(cmd, available)
5979 if err != nil {
5980 i.mu.Lock()
5981 i.statusErr = err.Error()
5982 i.statusOK = ""
5983 i.mu.Unlock()
5984 return false
5985 }
5986 i.submitOrQueuePrompt(ctx, prompt)
5987 return false
5988 }
5989
5990 switch strings.ToLower(parts[0]) {
5991 case "/goal":
5992 i.runGoalCommand(ctx, cmd, parts)
5993 case "/exit":
5994 return true
5995 case "/clear":
5996 if i.agent != nil {
5997 i.agent.SetMessages(nil)
5998 }
5999 i.mu.Lock()
6000 i.toolCalls = map[string]*tui.ToolCallView{}
6001 i.toolOrder = nil
6002 i.toolGate = map[string]int{}
6003 i.statusErr = ""
6004 i.statusOK = ""
6005 i.helpBlock = nil
6006 i.parkedTurn = 0
6007 i.parkedTotal = 0
6008 i.scrollOffset = 0
6009 i.extNotes = nil
6010 i.reloadErrors = nil
6011 handoff, persistHandoff := i.resetCompactContinuationLocked()
6012 i.view.InvalidateRenderCache()
6013 i.mu.Unlock()
6014 if persistHandoff {
6015 i.persistCompactHandoff(handoff)
6016 }
6017 case "/help":
6018 i.mu.Lock()
6019 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
6020 i.statusErr = ""
6021 i.statusOK = ""
6022 // Pin the viewport to the newest content so the help block,
6023 // which we just appended to the end of the transcript, is
6024 // what the user actually sees.
6025 i.scrollOffset = 0
6026 i.mu.Unlock()
6027 case "/login":
6028 i.dialog.Open(i.cfg.ZutHome)
6029 case "/logout":
6030 if len(parts) >= 2 {
6031 // Explicit target: /logout anthropic | openai | all
6032 i.doLogout(parts[1])
6033 break
6034 }
6035 // No arg: open the picker over whichever providers are
6036 // currently logged in. If nothing's logged in, bail with a
6037 // status line.
6038 i.openLogoutDialog()
6039 case "/model":
6040 if len(parts) >= 2 {
6041 i.applyModelSelection("", parts[1])
6042 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
6043 i.mu.Lock()
6044 i.modelRefreshing = true
6045 i.statusOK = "Refreshing models"
6046 i.statusErr = ""
6047 i.mu.Unlock()
6048 go func() {
6049 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
6050 defer cancel()
6051 i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
6052 }()
6053 } else {
6054 i.openModelPickerAfterRefresh(nil)
6055 }
6056 case "/llama":
6057 if i.cfg.LlamaCPPConfig == nil {
6058 i.mu.Lock()
6059 i.statusErr = "llama.cpp configuration is unavailable"
6060 i.statusOK = ""
6061 i.mu.Unlock()
6062 break
6063 }
6064 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
6065 if configErr == nil {
6066 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
6067 }
6068 if configErr != nil {
6069 i.mu.Lock()
6070 i.statusErr = configErr.Error()
6071 i.statusOK = ""
6072 i.mu.Unlock()
6073 }
6074 case "/reasoning":
6075 i.openReasoningDialog()
6076 case "/fast":
6077 enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
6078 i.applySettingToggle("fast_mode", enabled)
6079 case "/orchestrator":
6080 enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
6081 i.applySettingToggle("auto_subagents_enabled", enabled)
6082 case "/settings":
6083 i.openSettingsDialog()
6084 case "/sessions":
6085 i.mu.Lock()
6086 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
6087 i.mu.Unlock()
6088 case "/fork":
6089 i.doSessionFork()
6090 case "/jump":
6091 i.openJumpDialog(parts[1:])
6092 case "/btw":
6093 i.openBtwDialog(parts[1:])
6094 case "/skills":
6095 i.openSkillsDialog()
6096 case "/compact":
6097 i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
6098 case "/study":
6099 // Canned prompt that tells the agent to read every file
6100 // in some target so its later turns have the whole thing
6101 // in context. With no argument, the target is the current
6102 // directory. With an argument, the target is whatever the
6103 // user passed — typed by hand, drag-dropped, or selected
6104 // via the @ file picker (which is why we accept both files
6105 // and directories; the @-picker chips for both have already
6106 // been expanded to absolute paths by expandFileChips above).
6107 // Dispatched through the normal queue-or-start path so it
6108 // behaves identically to typing the prompt by hand.
6109 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
6110 i.mu.Lock()
6111 busy := i.busy
6112 compacting := i.compacting
6113 ag := i.agent
6114 i.mu.Unlock()
6115 if busy {
6116 var handoff json.RawMessage
6117 var persistHandoff bool
6118 if ag != nil && !compacting {
6119 i.mu.Lock()
6120 handoff, persistHandoff = i.resetCompactContinuationLocked()
6121 ag.QueueMessage(studyPrompt)
6122 i.mu.Unlock()
6123 } else {
6124 i.mu.Lock()
6125 handoff, persistHandoff = i.resetCompactContinuationLocked()
6126 i.queued = append(i.queued, studyPrompt)
6127 i.mu.Unlock()
6128 }
6129 if persistHandoff {
6130 i.persistCompactHandoff(handoff)
6131 }
6132 i.invalidate()
6133 break
6134 }
6135 i.startTurn(ctx, studyPrompt)
6136 case "/cd":
6137 // Hidden command: switch the running session's cwd. Not in
6138 // slash_suggest, not in /help. Used by the workspaces
6139 // extension's panel-key Enter handler so picking a row
6140 // jumps zut into that directory without relaunching.
6141 //
6142 // Recovers the raw argument (path) from the original cmd
6143 // string rather than parts, so paths with spaces survive.
6144 // The host's ChangeCWD hook handles validation, session
6145 // close + reopen, agent rebuild, sandbox re-rooting, and
6146 // re-jail-if-jailed semantics.
6147 if i.cfg.ChangeCWD == nil {
6148 i.mu.Lock()
6149 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
6150 i.mu.Unlock()
6151 break
6152 }
6153 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
6154 if path == "" {
6155 i.mu.Lock()
6156 i.statusErr = "/cd: missing path"
6157 i.mu.Unlock()
6158 break
6159 }
6160 if err := i.cfg.ChangeCWD(path); err != nil {
6161 i.mu.Lock()
6162 i.statusErr = "/cd: " + err.Error()
6163 i.statusOK = ""
6164 i.mu.Unlock()
6165 break
6166 }
6167 // ChangeCWD has already updated i.cfg.CWD and swapped the
6168 // agent + session. Reset transient TUI state so the new
6169 // session opens clean.
6170 i.mu.Lock()
6171 i.toolCalls = map[string]*tui.ToolCallView{}
6172 i.toolOrder = nil
6173 i.toolGate = map[string]int{}
6174 i.helpBlock = nil
6175 i.parkedTurn = 0
6176 i.statusOK = "cwd " + i.cfg.CWD
6177 i.statusErr = ""
6178 i.mu.Unlock()
6179 i.fileSuggest.Reset()
6180 i.fileSuggest.SetCWD(i.cfg.CWD)
6181 i.invalidate()
6182 case "/jail":
6183 if i.cfg.Sandbox == nil {
6184 i.mu.Lock()
6185 i.statusErr = "sandbox not available in this build"
6186 i.mu.Unlock()
6187 break
6188 }
6189 i.cfg.Sandbox.Lock()
6190 i.mu.Lock()
6191 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
6192 i.statusErr = ""
6193 i.mu.Unlock()
6194 case "/unjail":
6195 if i.cfg.Sandbox == nil {
6196 i.mu.Lock()
6197 i.statusErr = "sandbox not available in this build"
6198 i.mu.Unlock()
6199 break
6200 }
6201 i.cfg.Sandbox.Unlock()
6202 i.mu.Lock()
6203 i.statusOK = "unjailed"
6204 i.statusErr = ""
6205 i.mu.Unlock()
6206 case "/reload-ext":
6207 i.runReloadExt(ctx)
6208 case "/telegram", "/tg":
6209 if len(parts) >= 2 {
6210 i.doTelegram(parts[1])
6211 break
6212 }
6213 i.openTelegramDialog()
6214 case "/session":
6215 if len(parts) >= 2 {
6216 action := parts[1]
6217 arg := ""
6218 if len(parts) >= 3 {
6219 arg = strings.Join(parts[2:], " ")
6220 }
6221 i.doSessionOp(action, arg)
6222 break
6223 }
6224 i.openSessionOpsDialog()
6225 case "/subagents":
6226 i.runSubagents(ctx, parts[1:])
6227 default:
6228 // Last-resort fallback: try the extension manager. Built-in
6229 // cases above always win; this branch only fires for slash
6230 // commands the extension manager registered. Same routing as
6231 // the editor's submit-handler dispatch path so the autocomplete
6232 // "enter on highlighted suggestion" flow also works.
6233 extName := strings.TrimPrefix(parts[0], "/")
6234 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
6235 rest := ""
6236 if len(parts) > 1 {
6237 rest = strings.Join(parts[1:], " ")
6238 }
6239 go i.invokeExtensionCommand(ctx, extName, rest)
6240 return false
6241 }
6242 i.mu.Lock()
6243 i.statusErr = "unknown command: " + parts[0]
6244 i.mu.Unlock()
6245 }
6246 return false
6247}
6248
6249func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
6250 var loggedIn []string
6251 if i.cfg.LoggedInProviders != nil {
6252 loggedIn = i.cfg.LoggedInProviders()
6253 }
6254 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
6255 i.mu.Lock()
6256 if refreshErr != nil {
6257 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
6258 i.statusOK = ""
6259 } else if i.statusOK == "Refreshing models" {
6260 i.statusOK = ""
6261 }
6262 i.mu.Unlock()
6263}
6264
6265// openLogoutDialog shows the provider picker for `/logout` with no
6266// argument. Only providers the user is currently logged into are
6267// listed, plus an "all" entry when more than one is present. If
6268// nothing's logged in, writes a status line instead of opening an
6269// empty dialog.
6270func (i *Interactive) openLogoutDialog() {
6271 if i.cfg.AuthManager == nil {
6272 i.mu.Lock()
6273 i.statusErr = "no auth manager configured"
6274 i.mu.Unlock()
6275 i.invalidate()
6276 return
6277 }
6278 store := i.cfg.AuthManager.Store()
6279 if store == nil {
6280 i.mu.Lock()
6281 i.statusErr = "auth store is not available"
6282 i.mu.Unlock()
6283 i.invalidate()
6284 return
6285 }
6286 creds, err := store.Load()
6287 if err != nil {
6288 i.mu.Lock()
6289 i.statusErr = "read auth store: " + err.Error()
6290 i.mu.Unlock()
6291 i.invalidate()
6292 return
6293 }
6294
6295 var items []logoutItem
6296 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
6297 if creds.Has(p) {
6298 method := creds.Method(p)
6299 if method == "oauth" {
6300 method = "subscription"
6301 }
6302 items = append(items, logoutItem{
6303 label: providerLabel(p),
6304 target: p,
6305 method: method,
6306 })
6307 }
6308 }
6309 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
6310 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
6311 }
6312 if creds.OpenAI.OAuth != nil {
6313 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
6314 }
6315 for p, c := range creds.AdditionalAPIKeyCreds {
6316 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
6317 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
6318 }
6319 }
6320 if len(items) == 0 {
6321 i.mu.Lock()
6322 i.statusOK = "no credentials stored; already logged out"
6323 i.statusErr = ""
6324 i.mu.Unlock()
6325 i.invalidate()
6326 return
6327 }
6328 if len(items) > 1 {
6329 items = append(items, logoutItem{label: "all", target: "all"})
6330 }
6331
6332 i.logoutDialog.Open(items)
6333 i.invalidate()
6334}
6335
6336// doLogout clears credentials for the given provider (or all providers)
6337// from auth.json. If the active agent was using those credentials, it
6338// is torn down so the user is forced through /login before their next
6339// prompt.
6340//
6341// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
6342func (i *Interactive) doLogout(target string) {
6343 if i.cfg.AuthManager == nil {
6344 i.mu.Lock()
6345 i.statusErr = "no auth manager configured"
6346 i.mu.Unlock()
6347 return
6348 }
6349 store := i.cfg.AuthManager.Store()
6350 if store == nil {
6351 i.mu.Lock()
6352 i.statusErr = "auth store is not available"
6353 i.mu.Unlock()
6354 return
6355 }
6356
6357 var providers []string
6358 switch target {
6359 case "", "all":
6360 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
6361 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
6362 providers = []string{target}
6363 default:
6364 known := false
6365 for _, p := range auth.APIKeyProviders() {
6366 if target == p {
6367 known = true
6368 break
6369 }
6370 }
6371 if !known {
6372 i.mu.Lock()
6373 i.statusErr = "unknown provider: " + target
6374 i.mu.Unlock()
6375 return
6376 }
6377 providers = []string{target}
6378 }
6379
6380 var errs []string
6381 clearedCurrent := false
6382 for _, p := range providers {
6383 var err error
6384 switch p {
6385 case "openai":
6386 err = store.ClearAPIKey("openai")
6387 case "openai-codex":
6388 err = store.ClearOAuth("openai")
6389 default:
6390 err = store.Clear(p)
6391 }
6392 if err != nil {
6393 errs = append(errs, p+": "+err.Error())
6394 continue
6395 }
6396 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6397 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
6398 errs = append(errs, p+": "+err.Error())
6399 continue
6400 }
6401 }
6402 if p == i.cfg.Provider {
6403 clearedCurrent = true
6404 }
6405 }
6406
6407 llamaConfigured := i.llamaConfigured
6408 if i.cfg.LlamaCPPConfig != nil {
6409 baseURL, _, err := i.cfg.LlamaCPPConfig()
6410 llamaConfigured = err == nil && baseURL != ""
6411 }
6412
6413 i.mu.Lock()
6414 i.llamaConfigured = llamaConfigured
6415 if len(errs) > 0 {
6416 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
6417 i.mu.Unlock()
6418 return
6419 }
6420 i.statusErr = ""
6421 var handoff json.RawMessage
6422 var persistHandoff bool
6423 if clearedCurrent {
6424 // The running agent was using a credential we just wiped. Drop
6425 // it so prompts can't go out with the stale client, and hint at
6426 // /login. Close any LSP processes owned by its tools first.
6427 if i.agent != nil {
6428 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6429 }
6430 i.agent = nil
6431 handoff, persistHandoff = i.resetCompactContinuationLocked()
6432 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
6433 } else {
6434 i.statusOK = "logged out of " + strings.Join(providers, ", ")
6435 }
6436 i.mu.Unlock()
6437 if persistHandoff {
6438 i.persistCompactHandoff(handoff)
6439 }
6440}
6441
6442func providerSetupInfo(provider string) (string, []string, bool) {
6443 const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
6444 switch provider {
6445 case "amazon-bedrock":
6446 return "Amazon Bedrock setup", []string{
6447 "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
6448 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
6449 "",
6450 "For Bedrock API keys, set:",
6451 " AWS_BEARER_TOKEN_BEDROCK=...",
6452 " AWS_REGION=us-east-1",
6453 "",
6454 "Docs:",
6455 " " + docsURL,
6456 }, true
6457 case "google-vertex":
6458 return "Google Vertex AI setup", []string{
6459 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
6460 "Set a Google API key, application-default credentials, or a service account.",
6461 "",
6462 "Common environment:",
6463 " GOOGLE_CLOUD_API_KEY=...",
6464 " GOOGLE_CLOUD_PROJECT=...",
6465 " GOOGLE_CLOUD_LOCATION=us-central1",
6466 "",
6467 "Docs:",
6468 " " + docsURL,
6469 }, true
6470 case "cloudflare-workers-ai":
6471 return "Cloudflare Workers AI setup", []string{
6472 "Cloudflare Workers AI needs both an API token and an account ID.",
6473 "",
6474 "Set:",
6475 " CLOUDFLARE_API_KEY=...",
6476 " CLOUDFLARE_ACCOUNT_ID=...",
6477 "",
6478 "Docs:",
6479 " " + docsURL,
6480 }, true
6481 case "cloudflare-ai-gateway":
6482 return "Cloudflare AI Gateway setup", []string{
6483 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
6484 "",
6485 "Set:",
6486 " CLOUDFLARE_API_KEY=...",
6487 " CLOUDFLARE_ACCOUNT_ID=...",
6488 " CLOUDFLARE_GATEWAY_ID=...",
6489 "",
6490 "Docs:",
6491 " " + docsURL,
6492 }, true
6493 case "azure-openai-responses":
6494 return "Azure OpenAI Responses setup", []string{
6495 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
6496 "",
6497 "Set:",
6498 " AZURE_OPENAI_API_KEY=...",
6499 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
6500 " AZURE_OPENAI_API_VERSION=2024-02-01",
6501 "",
6502 "Docs:",
6503 " " + docsURL,
6504 }, true
6505 default:
6506 return "", nil, false
6507 }
6508}
6509
6510func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
6511 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
6512 i.dialog.ShowResult(false, "auth store is unavailable")
6513 return
6514 }
6515 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
6516 i.dialog.ShowResult(false, err.Error())
6517 return
6518 }
6519 i.mu.Lock()
6520 i.llamaConfigured = true
6521 i.statusErr = ""
6522 i.statusOK = "configured llama.cpp router"
6523 i.mu.Unlock()
6524 i.dialog.ShowResult(true, "")
6525}
6526
6527func (i *Interactive) startAPIKeyFlow(provider string) {
6528 if title, lines, ok := providerSetupInfo(provider); ok {
6529 i.dialog.ShowInfo(title, lines)
6530 return
6531 }
6532 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6533 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6534 }
6535 url, err := i.cfg.AuthManager.StartAPIKey(provider)
6536 if err != nil {
6537 i.dialog.ShowResult(false, err.Error())
6538 return
6539 }
6540 i.dialog.ShowWaiting(url)
6541}
6542
6543func (i *Interactive) startOAuthFlow(provider string) {
6544 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6545 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6546 }
6547 // Device-code providers already support headless login and must only
6548 // start one polling flow.
6549 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
6550 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
6551 if err != nil {
6552 i.dialog.ShowResult(false, err.Error())
6553 return
6554 }
6555 i.dialog.ShowWaiting(loginURL)
6556 return
6557 }
6558 // Always run the manual/copy-code flow in parallel with the local
6559 // callback server so headless environments (docker, SSH) can paste
6560 // the authorization code directly without first pressing 'p'.
6561 _, err := i.cfg.AuthManager.StartOAuth(provider)
6562 if err != nil {
6563 i.dialog.ShowResult(false, err.Error())
6564 return
6565 }
6566 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
6567 if mErr == nil {
6568 i.dialog.ShowWaiting(manualURL)
6569 } else {
6570 i.dialog.ShowResult(false, mErr.Error())
6571 }
6572}
6573
6574func (i *Interactive) startManualOAuthFlow(provider string) {
6575 if i.cfg.AuthManager == nil {
6576 return
6577 }
6578 i.cfg.AuthManager.CancelOAuth()
6579 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
6580 if err != nil {
6581 i.dialog.ShowResult(false, err.Error())
6582 return
6583 }
6584 i.dialog.url = url
6585 i.invalidate()
6586}
6587
6588func (i *Interactive) submitManualOAuthCode(code string) {
6589 if i.cfg.AuthManager == nil {
6590 return
6591 }
6592 go func() {
6593 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
6594 i.dialog.ShowResult(false, err.Error())
6595 i.invalidate()
6596 }
6597 }()
6598}
6599
6600// applyModelSelection switches the active model (and provider, if the
6601// new model belongs to a different one). It rebuilds the underlying
6602// client when needed so the provider wire-protocol matches.
6603// cancelAndWaitForIdle cancels the active turn (if any) and blocks
6604// briefly until the turn goroutine has updated i.busy = false. Used
6605// before destructive slash commands so transcript-mutating work
6606// (/clear, /compact, /logout, /login completion, cross-provider
6607// /model swap) doesn't race with the still-running stream.
6608//
6609// The wait is bounded; if the turn doesn't release within the timeout
6610// we proceed anyway. Worst case is a brief overlap that the agent's
6611// own mutex protects against.
6612func (i *Interactive) cancelAndWaitForIdle() {
6613 i.mu.Lock()
6614 busy := i.busy
6615 cancel := i.cancelTurn
6616 i.mu.Unlock()
6617 if !busy {
6618 return
6619 }
6620 if cancel != nil {
6621 i.mu.Lock()
6622 handoff, persistHandoff := i.resetCompactContinuationLocked()
6623 i.mu.Unlock()
6624 cancel()
6625 if persistHandoff {
6626 i.persistCompactHandoff(handoff)
6627 }
6628 }
6629 // ConfirmToolCall waits on its response channel rather than the turn
6630 // context, so cancellation must also resolve every pending prompt.
6631 i.confirmDialog.CancelAll("turn cancelled")
6632 deadline := time.Now().Add(2 * time.Second)
6633 for time.Now().Before(deadline) {
6634 i.mu.Lock()
6635 done := !i.busy
6636 i.mu.Unlock()
6637 if done {
6638 return
6639 }
6640 time.Sleep(10 * time.Millisecond)
6641 }
6642}
6643
6644// openBtwDialog opens the side-chat overlay with a frozen snapshot
6645// of the current main session. The optional argument is auto-
6646// submitted as the first question, so '/btw does X work?' fires the
6647// model call immediately instead of just opening an empty dialog.
6648func (i *Interactive) openBtwDialog(args []string) {
6649 i.agentMu.Lock()
6650 defer i.agentMu.Unlock()
6651 i.mu.Lock()
6652 ag := i.agent
6653 system := ""
6654 if ag != nil {
6655 system, _ = ag.PromptConfig()
6656 }
6657 theme := i.cfg.Theme
6658 model := i.cfg.Model
6659 cwd := i.cfg.CWD
6660 compact := i.compactModeEnabled()
6661 flatTools := i.cfg.FlatTools
6662 lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
6663 i.mu.Unlock()
6664 if ag == nil {
6665 i.mu.Lock()
6666 i.statusErr = "not logged in. type /login first."
6667 i.mu.Unlock()
6668 return
6669 }
6670 seed := strings.TrimSpace(strings.Join(args, " "))
6671 i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6672 i.invalidate()
6673}
6674
6675// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
6676// or queues it behind the active turn using the normal text-only queue.
6677func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6678 i.mu.Lock()
6679 if i.agent == nil {
6680 i.statusErr = "not logged in. type /login first."
6681 i.statusOK = ""
6682 i.mu.Unlock()
6683 i.invalidate()
6684 return
6685 }
6686 if i.busy {
6687 // Keep the mutex held while enqueueing so the turn-completion
6688 // goroutine cannot publish idle and inspect an empty queue between
6689 // this check and the append.
6690 var handoff json.RawMessage
6691 var persistHandoff bool
6692 if i.compacting {
6693 handoff, persistHandoff = i.resetCompactContinuationLocked()
6694 i.queued = append(i.queued, prompt)
6695 } else {
6696 handoff, persistHandoff = i.resetCompactContinuationLocked()
6697 i.agent.QueueMessage(prompt)
6698 }
6699 i.mu.Unlock()
6700 if persistHandoff {
6701 i.persistCompactHandoff(handoff)
6702 }
6703 i.invalidate()
6704 return
6705 }
6706 i.mu.Unlock()
6707 i.startTurn(ctx, prompt)
6708}
6709
6710// openSkillsDialog opens the skill inspector. The picker reflects
6711// whatever SkillSnapshot returns at call time, so edits to a
6712// SKILL.md made during a session show up on the next /skills.
6713func (i *Interactive) openSkillsDialog() {
6714 var list []*skills.Skill
6715 if i.cfg.SkillSnapshot != nil {
6716 list = i.cfg.SkillSnapshot()
6717 }
6718 i.skillsDialog.Open(list)
6719 i.invalidate()
6720}
6721
6722// openJumpDialog builds a /jump picker from the current transcript.
6723// If the user typed "/jump foo" with a filter and it matches exactly
6724// one turn, jump there directly without showing the dialog.
6725func (i *Interactive) openJumpDialog(args []string) {
6726 if i.view == nil || len(i.view.Messages) == 0 {
6727 i.mu.Lock()
6728 i.statusErr = "nothing to jump to \u2014 the session is empty"
6729 i.mu.Unlock()
6730 return
6731 }
6732 filter := strings.TrimSpace(strings.Join(args, " "))
6733 i.jumpDialog.Open(i.view.Messages, filter)
6734 // Shortcut: with a filter argument that matches exactly one turn,
6735 // jump immediately and skip the picker.
6736 if filter != "" {
6737 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6738 t := tgts[0]
6739 i.jumpDialog.Close()
6740 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6741 }
6742 }
6743}
6744
6745// applyJumpSelection scrolls the chat viewport so the user message at
6746// msgIdx is visible at (or near) the top of the chat area. Uses the
6747// anchor slice returned by view.BuildWithAnchors so the mapping from
6748// message index to row is exact, regardless of variable-height tool
6749// blocks above the target.
6750func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6751 cols := i.lastCols()
6752 chat, anchors := i.view.BuildWithAnchors(cols)
6753 var row int
6754 found := false
6755 for _, a := range anchors {
6756 if a.MessageIdx == msgIdx {
6757 row = a.Row
6758 found = true
6759 break
6760 }
6761 }
6762 if !found {
6763 i.mu.Lock()
6764 i.statusErr = "could not resolve jump target"
6765 i.mu.Unlock()
6766 return
6767 }
6768
6769 chatLen := len(chat)
6770 page := i.chatPage()
6771 if page < 1 {
6772 page = 1
6773 }
6774 // scrollOffset is measured from the bottom of the chat slice, so
6775 // to place `row` at the top of the viewport we want:
6776 // chatLen - scrollOffset - page == row
6777 // Solve for scrollOffset and clamp to [0, chatLen-page].
6778 offset := chatLen - (row + page)
6779 if offset < 0 {
6780 offset = 0
6781 }
6782 maxOffset := chatLen - page
6783 if maxOffset < 0 {
6784 maxOffset = 0
6785 }
6786 if offset > maxOffset {
6787 offset = maxOffset
6788 }
6789
6790 i.mu.Lock()
6791 i.scrollOffset = offset
6792 i.parkedTurn = turnNo
6793 i.parkedTotal = totalTurnsLocked(i.view.Messages)
6794 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6795 i.statusErr = ""
6796 i.mu.Unlock()
6797}
6798
6799// totalTurnsLocked counts user messages in the transcript. Caller is
6800// assumed to hold i.mu (the name is a mild reminder; this function
6801// itself doesn't touch shared state beyond the slice it's handed).
6802func totalTurnsLocked(msgs []provider.Message) int {
6803 n := 0
6804 for _, m := range msgs {
6805 if m.Role == provider.RoleUser {
6806 n++
6807 }
6808 }
6809 return n
6810}
6811
6812// applySessionSelection loads the given session via the cli-provided
6813// callback and snaps the viewport to the bottom (the latest message)
6814// so the user lands at the live tail of the resumed conversation.
6815func (i *Interactive) applySessionSelection(path string) {
6816 if i.cfg.LoadSession == nil {
6817 i.mu.Lock()
6818 i.statusErr = "session loading is not wired in this build"
6819 i.mu.Unlock()
6820 return
6821 }
6822 i.mu.Lock()
6823 if i.sessionLoading {
6824 i.statusErr = "already resuming a session"
6825 i.mu.Unlock()
6826 i.invalidate()
6827 return
6828 }
6829 i.sessionLoading = true
6830 i.statusOK = "resuming session: " + path
6831 i.statusErr = ""
6832 i.mu.Unlock()
6833 i.invalidate()
6834 i.markSessionTitleSwitching()
6835
6836 go func() {
6837 err := i.cfg.LoadSession(path)
6838 if err != nil {
6839 i.restoreFailedSessionTitle()
6840 i.mu.Lock()
6841 i.sessionLoading = false
6842 i.statusErr = err.Error()
6843 i.statusOK = ""
6844 i.mu.Unlock()
6845 i.invalidate()
6846 return
6847 }
6848 i.restoreLoadedSessionTitle()
6849 state := decodeCompactHandoff(i.currentCompactHandoff())
6850 i.mu.Lock()
6851 i.compactContinuation = state
6852 i.sessionLoading = false
6853 i.statusOK = "resumed session: " + path
6854 i.statusErr = ""
6855 i.parkedTurn = 0
6856 i.parkedTotal = 0
6857 i.scrollOffset = 0
6858 // Fresh transcript swapped in: drop the auto-follow baseline so
6859 // the next render's follow guard doesn't see the wholesale
6860 // length change as a delta and jump the viewport.
6861 i.prevChatLen = 0
6862 i.prevChatCols = 0
6863 i.extNotes = nil
6864 i.view.InvalidateRenderCache()
6865 if i.agent != nil {
6866 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
6867 i.cumUsage = i.agent.Cost()
6868 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6869 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6870 } else {
6871 i.lastCtxInput = 0
6872 }
6873 // Snap to the tail again — the swap brought in a fresh
6874 // transcript whose markdown / chroma cost we don't want
6875 // blocking the redraw.
6876 if len(i.view.Messages) > initialResumeTailLimit {
6877 i.view.TailLimit = initialResumeTailLimit
6878 } else {
6879 i.view.TailLimit = 0
6880 }
6881 }
6882 i.mu.Unlock()
6883 i.invalidate()
6884 if state.reason != compactContinuationNone {
6885 i.startRestoredCompactHandoff(i.runCtx)
6886 }
6887 }()
6888}
6889
6890func (i *Interactive) applyModelSelection(prov, model string) {
6891 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6892}
6893
6894// applyRescueModelSelection is like applyModelSelection but routes
6895// through BuildAgentForRescue so launch-time --api-key / --base-url
6896// overrides are dropped before the new agent is built. Falls back to
6897// the regular builder when the host doesn't wire a rescue builder.
6898func (i *Interactive) applyRescueModelSelection(prov, model string) {
6899 builder := i.cfg.BuildAgentForRescue
6900 if builder == nil {
6901 builder = i.cfg.BuildAgentFor
6902 }
6903 i.swapModel(prov, model, builder, true)
6904}
6905
6906// swapModel applies a /model selection (or a rescue selection) using
6907// the supplied builder. rescue=true tags the success message so the
6908// user can see that launch-time overrides were ignored.
6909func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6910 var replaced bool
6911 swap := func() {
6912 replaced = i.swapModelUnserialized(prov, model, builder, rescue)
6913 }
6914 if i.cfg.SessionTransition != nil {
6915 i.cfg.SessionTransition(swap)
6916 } else {
6917 swap()
6918 }
6919 if replaced {
6920 i.resetCompactHandoff()
6921 }
6922}
6923
6924func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
6925 if model == "" {
6926 return false
6927 }
6928 m, err := provider.FindModel(prov, model)
6929 if err != nil {
6930 i.mu.Lock()
6931 i.statusErr = err.Error()
6932 i.mu.Unlock()
6933 return false
6934 }
6935 // Same provider AND not a rescue retry: just swap the model on
6936 // the existing agent. Mixed-API providers dispatch from model metadata,
6937 // so the client remains reusable. Rescue retries always rebuild so a stale
6938 // auth header / base URL can't carry over.
6939 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6940 i.mu.Lock()
6941 i.cfg.Model = m.ID
6942 i.agent.Model = m.ID
6943 i.statusOK = "model: " + m.ID
6944 i.statusErr = ""
6945 i.mu.Unlock()
6946 if i.cfg.PersistModel != nil {
6947 i.cfg.PersistModel(i.cfg.Provider, m.ID)
6948 }
6949 return false
6950 }
6951 if builder == nil {
6952 i.mu.Lock()
6953 i.statusErr = "cannot switch provider: no builder configured"
6954 i.mu.Unlock()
6955 return false
6956 }
6957 // Snapshot the current transcript and cumulative usage BEFORE we
6958 // build the replacement agent so we can hand them off. Without
6959 // this the user perceives the entire session as wiped on a
6960 // cross-provider /model swap.
6961 var carryMsgs []provider.Message
6962 var carryCost provider.Usage
6963 if i.agent != nil {
6964 carryMsgs = i.agent.Messages()
6965 carryCost = i.agent.Cost()
6966 }
6967
6968 ag, p, md, err := builder(m.Provider, m.ID)
6969 if err != nil {
6970 i.mu.Lock()
6971 i.statusErr = err.Error()
6972 i.mu.Unlock()
6973 return false
6974 }
6975
6976 // Replay the transcript and seed the cost on the freshly-built
6977 // agent. Messages travel cleanly between providers because they
6978 // use the same provider.Message shape; tool-call ids are local
6979 // to a turn so cross-provider continuation never confuses the
6980 // new model (it just sees the assistant's reply, no orphan
6981 // tool_use blocks because /model swaps are gated to idle state).
6982 if len(carryMsgs) > 0 {
6983 ag.SetMessages(carryMsgs)
6984 }
6985 ag.SeedCost(carryCost)
6986
6987 if i.agent != nil && i.agent != ag {
6988 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6989 }
6990 i.agentMu.Lock()
6991 i.mu.Lock()
6992 i.prepareReplacementAgentLocked(ag)
6993 i.agent = ag
6994 i.cfg.Provider = p
6995 i.cfg.Model = md
6996 if rescue {
6997 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6998 } else {
6999 i.statusOK = "switched to " + p + " / " + md
7000 }
7001 i.statusErr = ""
7002 // Render cache keys are width+content based, so the new agent's
7003 // identical messages will reuse the existing entries. Nothing
7004 // to invalidate.
7005 i.mu.Unlock()
7006 i.agentMu.Unlock()
7007 // The new agent was built off the base tool registry, so any
7008 // dynamically-registered tools need to be reattached. The apply
7009 // helpers are no-ops when their feature is inactive, so the
7010 // cross-provider path still works on a vanilla setup.
7011 i.applyAutoSubagentsTool()
7012 i.applyTelegramTools(i.telegramBridge != nil)
7013 if i.cfg.PersistModel != nil {
7014 i.cfg.PersistModel(p, md)
7015 }
7016 return true
7017}
7018
7019func (i *Interactive) handleAuthEvent(ev auth.Event) {
7020 switch ev.Kind {
7021 case "started":
7022 i.dialog.ShowWaiting(ev.URL)
7023 case "browser_open":
7024 // no-op
7025 case "error":
7026 i.dialog.ShowResult(false, ev.Message)
7027 case "success":
7028 // Keep the credential-driven resolve and its final replacement in the
7029 // same transition as settings/session changes. Otherwise a build that
7030 // started under the old web-search policy could commit afterward.
7031 var buildErr error
7032 var clearHandoff bool
7033 buildAndCommitLogin := func() {
7034 ag, prov, model, err := i.cfg.BuildAgent()
7035 if err != nil {
7036 buildErr = err
7037 return
7038 }
7039 i.agentMu.Lock()
7040 i.mu.Lock()
7041 oldAgent := i.agent
7042 i.prepareReplacementAgentLocked(ag)
7043 _, clearHandoff = i.resetCompactContinuationLocked()
7044 i.agent = ag
7045 i.cfg.Provider = prov
7046 i.cfg.Model = model
7047 i.statusErr = ""
7048 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
7049 i.mu.Unlock()
7050 i.agentMu.Unlock()
7051 if oldAgent != nil {
7052 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
7053 }
7054 i.applyAutoSubagentsTool()
7055 i.applyTelegramTools(i.telegramBridge != nil)
7056 // Authentication can change the provider used by the live
7057 // agent. Persist it on the active session just like /model.
7058 if i.cfg.PersistModel != nil {
7059 i.cfg.PersistModel(prov, model)
7060 }
7061 }
7062 if i.cfg.SessionTransition != nil {
7063 i.cfg.SessionTransition(buildAndCommitLogin)
7064 } else {
7065 buildAndCommitLogin()
7066 }
7067 if buildErr != nil {
7068 i.dialog.ShowResult(false, buildErr.Error())
7069 return
7070 }
7071 if clearHandoff {
7072 i.persistCompactHandoff(nil)
7073 }
7074 i.dialog.ShowResult(true, "")
7075 }
7076}
7077
7078// clearPendingCompactTurnLocked drops work that only makes sense after the
7079// current compaction. The caller must hold i.mu.
7080func (i *Interactive) clearPendingCompactTurnLocked() {
7081 i.pendingCompactPrompt = ""
7082 i.pendingCompactImages = nil
7083 i.hasPendingCompactPrompt = false
7084 i.continueAfterCompact = false
7085 i.pendingPostCompactNote = ""
7086}
7087
7088// compactHandoffLocked returns a self-contained persistence snapshot. The
7089// caller must hold i.mu.
7090func (i *Interactive) compactHandoffLocked() json.RawMessage {
7091 return encodeCompactHandoff(i.compactContinuation)
7092}
7093
7094// setCompactContinuationLocked updates the private handoff state and returns
7095// its persistence snapshot plus whether persistence is needed. The caller must
7096// hold i.mu.
7097func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
7098 if i.compactContinuation == state {
7099 return i.compactHandoffLocked(), false
7100 }
7101 i.compactContinuation = state
7102 return i.compactHandoffLocked(), true
7103}
7104
7105// resetCompactContinuationLocked clears the private handoff state and returns
7106// its persistence snapshot plus whether persistence is needed. The caller must
7107// hold i.mu.
7108func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
7109 return i.setCompactContinuationLocked(compactContinuationState{})
7110}
7111
7112func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
7113 if persist := i.cfg.PersistCompactHandoff; persist != nil {
7114 if err := persist(state); err != nil {
7115 i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
7116 }
7117 }
7118}
7119
7120func (i *Interactive) resetCompactHandoff() {
7121 i.mu.Lock()
7122 state, persist := i.resetCompactContinuationLocked()
7123 i.mu.Unlock()
7124 if persist {
7125 i.persistCompactHandoff(state)
7126 }
7127}
7128
7129func (i *Interactive) currentCompactHandoff() json.RawMessage {
7130 if current := i.cfg.CurrentCompactHandoff; current != nil {
7131 return current()
7132 }
7133 return nil
7134}
7135
7136func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
7137 i.mu.Lock()
7138 i.compactContinuation = state
7139 i.mu.Unlock()
7140}
7141
7142func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
7143 state := decodeCompactHandoff(i.currentCompactHandoff())
7144 i.restoreCompactHandoff(state)
7145 return state
7146}
7147
7148// autoCompactContinuationPrompt is an interactive-only compact-handoff
7149// instruction. Its completion criteria must stay separate from Ponytail so
7150// toggling the optional coding-style addendum never changes recovery behavior.
7151const 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.`
7152
7153type compactHandoffResume uint8
7154
7155const (
7156 compactHandoffDiscard compactHandoffResume = iota
7157 compactHandoffContinueExisting
7158 compactHandoffAppendPrompt
7159)
7160
7161// classifyCompactHandoffResume validates that the persisted checkpoint still
7162// describes the effective transcript. A normal assistant reply or a newer
7163// explicit user message supersedes the checkpoint; tool activity remains an
7164// unfinished existing turn. No hidden prompt means the process stopped after
7165// recording the handoff but before appending its context message.
7166func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
7167 lastHandoff := -1
7168 for idx := len(messages) - 1; idx >= 0; idx-- {
7169 message := messages[idx]
7170 if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
7171 lastHandoff = idx
7172 break
7173 }
7174 }
7175 if lastHandoff < 0 {
7176 return compactHandoffAppendPrompt
7177 }
7178 for _, message := range messages[lastHandoff+1:] {
7179 switch message.Role {
7180 case provider.RoleTool:
7181 continue
7182 case provider.RoleAssistant:
7183 hasToolCall := false
7184 for _, content := range message.Content {
7185 if _, ok := content.(provider.ToolCallBlock); ok {
7186 hasToolCall = true
7187 break
7188 }
7189 }
7190 if !hasToolCall {
7191 return compactHandoffDiscard
7192 }
7193 default:
7194 return compactHandoffDiscard
7195 }
7196 }
7197 return compactHandoffContinueExisting
7198}
7199
7200// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
7201// active session. When its hidden context message was already persisted, the
7202// agent continues that existing turn rather than appending it again.
7203func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
7204 if parent == nil {
7205 parent = i.runCtx
7206 }
7207 if parent == nil {
7208 parent = context.Background()
7209 }
7210 i.mu.Lock()
7211 state := i.compactContinuation
7212 ag := i.agent
7213 busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7214 if state.reason == compactContinuationNone || ag == nil || busy {
7215 i.mu.Unlock()
7216 return
7217 }
7218
7219 var next string
7220 continueQueued := false
7221 var resume compactHandoffResume
7222 var handoff json.RawMessage
7223 var persistHandoff bool
7224 switch {
7225 case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7226 next, i.queued = i.queued[0], i.queued[1:]
7227 handoff, persistHandoff = i.resetCompactContinuationLocked()
7228 case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7229 continueQueued = true
7230 handoff, persistHandoff = i.resetCompactContinuationLocked()
7231 default:
7232 resume = classifyCompactHandoffResume(ag.Messages())
7233 if resume == compactHandoffDiscard {
7234 handoff, persistHandoff = i.resetCompactContinuationLocked()
7235 }
7236 }
7237 // Reserve the turn slot before persisting or dispatching so concurrent input
7238 // can only queue behind the restored handoff, never start a competing turn.
7239 starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7240 i.busy = starting
7241 i.mu.Unlock()
7242 if persistHandoff {
7243 i.persistCompactHandoff(handoff)
7244 }
7245 if !starting {
7246 i.invalidate()
7247 return
7248 }
7249 switch {
7250 case next != "":
7251 i.startTurn(parent, next)
7252 case continueQueued:
7253 i.startTurnRequest(parent, "", nil, true, false)
7254 case resume == compactHandoffContinueExisting:
7255 i.startTurnRequest(parent, "", nil, true, false)
7256 case resume == compactHandoffAppendPrompt:
7257 i.startAutoCompactContinuation(parent)
7258 }
7259}
7260
7261// startAutoCompactContinuation adds an internal user turn that tells the
7262// model to resume the task after threshold compaction. Provider requests
7263// cannot continue from an assistant tail on every supported API, so the
7264// continuation must be represented as a user message. It is hidden from
7265// the rendered transcript but remains in the persisted/model context.
7266func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
7267 i.mu.Lock()
7268 ag := i.agent
7269 if ag == nil {
7270 i.busy = false
7271 pendingIdleWork := i.takePendingIdleWorkLocked()
7272 i.mu.Unlock()
7273 runPendingIdleWork(pendingIdleWork)
7274 i.invalidate()
7275 return
7276 }
7277 // The caller selected the continuation while holding i.mu. Keep that
7278 // ordering through the hidden append so an explicitly queued user prompt
7279 // cannot be overtaken by an old-task handoff. Forced truncation is the
7280 // existing exception: it keeps priority and the queued prompt runs after it.
7281 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
7282 next := i.queued[0]
7283 i.queued = i.queued[1:]
7284 handoff, persistHandoff := i.resetCompactContinuationLocked()
7285 i.mu.Unlock()
7286 if persistHandoff {
7287 i.persistCompactHandoff(handoff)
7288 }
7289 i.startTurn(parent, next)
7290 return
7291 }
7292 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
7293 handoff, persistHandoff := i.resetCompactContinuationLocked()
7294 i.mu.Unlock()
7295 if persistHandoff {
7296 i.persistCompactHandoff(handoff)
7297 }
7298 i.startTurnRequest(parent, "", nil, true, false)
7299 return
7300 }
7301 ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
7302 autoCompactContinueMetaKey: "true",
7303 })
7304 i.mu.Unlock()
7305 i.startTurnRequest(parent, "", nil, true, false)
7306}
7307
7308// runCompact invokes core.Agent.Compact and reflects the progress in
7309// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
7310// cancel via the same cancelTurn channel used for normal turns.
7311//
7312// When auto is true the spinner message is pinned to "condensing
7313// history" and the status bar surfaces "(auto)" next to the context
7314// percentage so it's obvious the system triggered this, not the user.
7315// request.force tells the post-compact hand-off to resume even if the
7316// transcript tail has visible assistant text, for example after StopLength.
7317func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
7318 auto := request.origin != compactOriginManual
7319 if i.agent == nil {
7320 i.mu.Lock()
7321 i.statusErr = "not logged in. type /login first."
7322 i.mu.Unlock()
7323 return
7324 }
7325 ctx, cancel := context.WithCancel(parent)
7326 i.mu.Lock()
7327 i.busy = true
7328 i.compacting = true
7329 i.spin.Start()
7330 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7331 if auto {
7332 i.activity.kind = activityCondensingHistory
7333 i.autoCompacting = true
7334 } else {
7335 i.activity.kind = activityCompactingHistory
7336 }
7337 i.cancelTurn = cancel
7338 var initialHandoff json.RawMessage
7339 persistInitialHandoff := false
7340 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
7341 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
7342 }
7343 i.statusErr = ""
7344 i.statusOK = ""
7345 // Do NOT set streamOn: the summary text should not be visible
7346 // in the chat while compacting. The user just sees the spinner
7347 // and can keep typing / queue prompts.
7348 i.scrollOffset = 0
7349 i.helpBlock = nil
7350 i.mu.Unlock()
7351 if persistInitialHandoff {
7352 i.persistCompactHandoff(initialHandoff)
7353 }
7354 i.invalidate()
7355
7356 go func() {
7357 // Sink discards deltas — we don't stream the summary to the UI.
7358 sink := func(delta string) {}
7359 msgsBefore := i.agent.Messages()
7360 i.mu.Lock()
7361 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7362 i.mu.Unlock()
7363 continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
7364 if request.force {
7365 continuationReason = compactContinuationForcedLength
7366 }
7367 // Keep the usual recent tail when possible, but never let it cover
7368 // the whole transcript. A short session can still be at 70–90% of
7369 // a model's window because one prompt or tool result is large; the
7370 // automatic path must summarize that session instead of failing
7371 // before it reaches the compaction provider.
7372 keepTail := 4
7373 if n := len(msgsBefore); n > 0 && keepTail >= n {
7374 keepTail = n - 1
7375 }
7376 summary, err := i.agent.Compact(ctx, keepTail, sink)
7377 _ = summary
7378 goalMessage, goalActive := i.goalContinuationMessage()
7379 i.mu.Lock()
7380 // Keep busy/compacting asserted while cleanup and queue selection run.
7381 // Completion updates can arrive in this window; clearing busy before
7382 // inspecting the queues lets them race a new turn or be stranded.
7383 i.resetStreamingStateLocked()
7384 i.cancelTurn = nil
7385 i.autoCompacting = false
7386 pendingIdleWork := i.takePendingIdleWorkLocked()
7387
7388 // Drain pending work after the transcript is clean. Overflow recovery
7389 // continues the user message already in the transcript. Pre-turn
7390 // compaction preserves its complete text-and-images request. Regular
7391 // prompts typed during compaction remain in the host queue.
7392 var next string
7393 var nextImages []provider.ImageBlock
7394 var hasNext bool
7395 var continueExisting bool
7396 var continueAutomatically bool
7397 var continueGoal bool
7398 var handoff json.RawMessage
7399 var persistHandoff bool
7400
7401 switch {
7402 case err != nil && ctx.Err() != nil:
7403 i.statusErr = ""
7404 if auto {
7405 i.statusOK = "auto-condense cancelled"
7406 } else {
7407 i.statusOK = "compaction cancelled"
7408 }
7409 i.queued = nil // drop queue on cancel
7410 i.clearPendingCompactTurnLocked()
7411 handoff, persistHandoff = i.resetCompactContinuationLocked()
7412 if i.agent != nil {
7413 i.agent.DrainQueuedMessages()
7414 }
7415 case err != nil:
7416 i.statusErr = "compaction failed: " + err.Error()
7417 i.statusOK = ""
7418 i.queued = nil // drop queue on error
7419 i.clearPendingCompactTurnLocked()
7420 handoff, persistHandoff = i.resetCompactContinuationLocked()
7421 if i.agent != nil {
7422 i.agent.DrainQueuedMessages()
7423 }
7424 default:
7425 i.statusErr = ""
7426 // Read token count from the compaction message meta.
7427 tokens := ""
7428 msgs := i.agent.Messages()
7429 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
7430 tokens = msgs[0].Meta["tokens_before"]
7431 }
7432 switch {
7433 case i.pendingPostCompactNote != "":
7434 i.statusOK = i.pendingPostCompactNote
7435 case tokens != "":
7436 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
7437 default:
7438 i.statusOK = "compacted (ctrl+o to expand)"
7439 }
7440 i.pendingPostCompactNote = ""
7441 i.extNotes = stripAutoCompactNotes(i.extNotes)
7442 i.lastCtxInput = 0
7443 i.toolCalls = map[string]*tui.ToolCallView{}
7444 i.toolOrder = nil
7445 i.toolGate = map[string]int{}
7446 i.resetTranscriptRenderLocked()
7447 switch {
7448 case i.continueAfterCompact:
7449 continueExisting = true
7450 i.continueAfterCompact = false
7451 case i.hasPendingCompactPrompt:
7452 next = i.pendingCompactPrompt
7453 nextImages = i.pendingCompactImages
7454 i.pendingCompactPrompt = ""
7455 i.pendingCompactImages = nil
7456 i.hasPendingCompactPrompt = false
7457 hasNext = true
7458 case continuationReason == compactContinuationForcedLength:
7459 // Forced truncated-output continuation keeps its existing
7460 // priority over an explicitly queued prompt.
7461 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7462 continueAutomatically = true
7463 case len(i.queued) > 0:
7464 next, i.queued = i.queued[0], i.queued[1:]
7465 hasNext = true
7466 handoff, persistHandoff = i.resetCompactContinuationLocked()
7467 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
7468 if continuationReason == compactContinuationStatusRescue {
7469 attempts := 1
7470 if i.compactContinuation.reason == compactContinuationStatusRescue {
7471 attempts = i.compactContinuation.rescueAttempts + 1
7472 }
7473 if attempts > maxStatusRescueContinuations {
7474 handoff, persistHandoff = i.resetCompactContinuationLocked()
7475 break
7476 }
7477 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7478 reason: continuationReason,
7479 rescueAttempts: attempts,
7480 })
7481 } else {
7482 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7483 }
7484 continueAutomatically = true
7485 case auto && goalActive && !i.coordinatorHasPendingWorkers():
7486 continueGoal = true
7487 handoff, persistHandoff = i.resetCompactContinuationLocked()
7488 default:
7489 handoff, persistHandoff = i.resetCompactContinuationLocked()
7490 }
7491 }
7492 // Keep the host busy until the hand-off decision is committed under
7493 // the mutex. A completion update arriving after the compaction result
7494 // but before this assignment is then queued for the selected follow-up
7495 // instead of starting a competing turn.
7496 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
7497 i.compacting = false
7498 i.autoCompacting = false
7499 i.mu.Unlock()
7500 if persistHandoff {
7501 i.persistCompactHandoff(handoff)
7502 }
7503 runPendingIdleWork(pendingIdleWork)
7504 i.invalidate()
7505
7506 if hasNext || continueExisting || continueAutomatically || continueGoal {
7507 p := i.runCtx
7508 if p == nil {
7509 p = context.Background()
7510 }
7511 switch {
7512 case continueExisting:
7513 i.startTurnRequest(p, "", nil, true, true)
7514 case hasNext:
7515 i.startTurnWithImages(p, next, nextImages)
7516 case continueAutomatically:
7517 i.startAutoCompactContinuation(p)
7518 case continueGoal:
7519 i.startGoalContinuation(p, goalMessage)
7520 }
7521 }
7522 }()
7523}
7524
7525// shellEscapeCommand reports whether text is a "!command" shell
7526// escape and, if so, returns the command with the leading '!' (and
7527// surrounding whitespace) stripped. A bare "!" with no command is
7528// treated as not an escape so it falls through to the normal prompt
7529// path rather than running an empty shell.
7530func shellEscapeCommand(text string) (string, bool) {
7531 return ShellEscapeCommand(text)
7532}
7533
7534// ShellEscapeCommand reports whether text is a "!command" shell
7535// escape and, if so, returns the command with the leading '!' (and
7536// surrounding whitespace) stripped. A bare "!" with no command is
7537// treated as not an escape so it falls through to the normal prompt
7538// path rather than running an empty shell.
7539func ShellEscapeCommand(text string) (string, bool) {
7540 trimmed := strings.TrimLeft(text, " \t")
7541 if !strings.HasPrefix(trimmed, "!") {
7542 return "", false
7543 }
7544 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
7545 if cmd == "" {
7546 return "", false
7547 }
7548 return cmd, true
7549}
7550
7551// startShellEscape runs a "!command" in the same shell the bash tool
7552// uses, in the session working directory, honoring the /jail sandbox.
7553// It shares the busy/cancel state with the agent: esc cancels it, and
7554// it refuses to start while a turn or another shell escape is already
7555// in flight. Its terminal-log output is appended to the transcript as
7556// user context without automatically starting a model turn.
7557func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
7558 i.mu.Lock()
7559 if i.busy || i.shellRunning {
7560 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
7561 i.statusOK = ""
7562 i.mu.Unlock()
7563 i.invalidate()
7564 return
7565 }
7566 if parent == nil {
7567 parent = i.runCtx
7568 }
7569 if parent == nil {
7570 parent = context.Background()
7571 }
7572 ctx, cancel := context.WithCancel(parent)
7573 i.busy = true
7574 i.shellRunning = true
7575 i.shellLive = "$ " + cmd + "\n\n"
7576 i.cancelTurn = cancel
7577 i.statusErr = ""
7578 i.statusOK = ""
7579 i.spin.Start()
7580 i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
7581 // Clear stale extension notes the same way a new turn would so the
7582 // screen doesn't accumulate transient state.
7583 i.extNotes = nil
7584 i.scrollOffset = 0
7585 i.parkedTurn = 0
7586 i.parkedTotal = 0
7587 i.helpBlock = nil
7588 sandbox := i.cfg.Sandbox
7589 cwd := i.cfg.CWD
7590 i.mu.Unlock()
7591 i.invalidate()
7592
7593 go func() {
7594 defer cancel()
7595 raw, _ := json.Marshal(map[string]any{"command": cmd})
7596 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
7597 progress := func(chunk string) {
7598 i.mu.Lock()
7599 i.shellLive += chunk
7600 i.mu.Unlock()
7601 i.invalidate()
7602 }
7603 res, err := bash.Execute(ctx, raw, progress)
7604
7605 var out string
7606 if err != nil {
7607 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
7608 } else {
7609 for _, c := range res.Content {
7610 if tb, ok := c.(provider.TextBlock); ok {
7611 out += tb.Text
7612 }
7613 }
7614 }
7615 cancelled := ctx.Err() != nil
7616 failed := err != nil || res.IsError || cancelled
7617 if cancelled {
7618 out += "\n\n[cancelled]"
7619 }
7620
7621 if i.agent != nil {
7622 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
7623 }
7624
7625 i.mu.Lock()
7626 i.shellRunning = false
7627 i.shellLive = ""
7628 i.busy = false
7629 i.cancelTurn = nil
7630 pendingIdleWork := i.takePendingIdleWorkLocked()
7631 awaitingPre := i.awaitingStartupPre
7632 if failed {
7633 if cancelled {
7634 i.statusErr = "shell command cancelled"
7635 } else {
7636 i.statusErr = "shell command failed"
7637 }
7638 i.statusOK = ""
7639 } else {
7640 i.statusOK = "shell command finished"
7641 i.statusErr = ""
7642 }
7643 i.mu.Unlock()
7644 runPendingIdleWork(pendingIdleWork)
7645 i.invalidate()
7646 if awaitingPre {
7647 i.completeStartupPre()
7648 }
7649 }()
7650}
7651
7652func (i *Interactive) startTurn(parent context.Context, prompt string) {
7653 i.startTurnWithImages(parent, prompt, nil)
7654}
7655
7656func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
7657 i.startTurnRequest(parent, prompt, images, false, false)
7658}
7659
7660// startTurnRequest starts a new prompt or continues an existing user turn.
7661// continueExisting selects Agent.Continue so callers can retry a transcript
7662// message without appending it twice. overflowRecoveryAttempted suppresses
7663// the pre-turn threshold guard and the one-shot overflow recovery path; a
7664// normal post-compaction continuation leaves it false so a still-too-large
7665// rebuilt context can recover once more.
7666func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
7667 if i.agent == nil {
7668 // Text startup pre cannot run without credentials; continue so
7669 // deferred InitialInput (pre-fill or auto-submit) still applies.
7670 i.mu.Lock()
7671 awaitingPre := i.awaitingStartupPre
7672 i.mu.Unlock()
7673 if awaitingPre {
7674 i.completeStartupPre()
7675 }
7676 return
7677 }
7678 // Pre-turn safety: if the most recent context measurement is
7679 // already past the auto-compact threshold, condense before
7680 // sending so the next outbound request stays under the limit.
7681 // The condense flow re-fires the user's queued prompt for us, so
7682 // we just hand it off and exit.
7683 i.mu.Lock()
7684 var resetHandoff json.RawMessage
7685 var persistResetHandoff bool
7686 if !continueExisting {
7687 resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
7688 }
7689 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7690 if needsPreCompact {
7691 // Reserve the compaction hand-off before releasing i.mu. A completion
7692 // update arriving in the small gap before runCompact acquires the
7693 // mutex must join the host queue rather than starting a competing turn.
7694 i.busy = true
7695 i.compacting = true
7696 i.autoCompacting = true
7697 if continueExisting {
7698 i.continueAfterCompact = true
7699 } else {
7700 i.pendingCompactPrompt = prompt
7701 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
7702 i.hasPendingCompactPrompt = true
7703 }
7704 i.statusErr = ""
7705 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
7706 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7707 i.mu.Unlock()
7708 if persistResetHandoff {
7709 i.persistCompactHandoff(resetHandoff)
7710 }
7711 i.invalidate()
7712 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7713 return
7714 }
7715 i.mu.Unlock()
7716 if persistResetHandoff {
7717 i.persistCompactHandoff(resetHandoff)
7718 }
7719
7720 ctx, cancel := context.WithCancel(parent)
7721 i.mu.Lock()
7722 i.busy = true
7723 i.spin.Start()
7724 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7725 i.cancelTurn = cancel
7726 i.statusErr = ""
7727 i.statusOK = ""
7728 i.streaming.Reset()
7729 i.streamOn = true
7730 i.pendingAlert = nil
7731 i.toolCalls = map[string]*tui.ToolCallView{}
7732 i.toolOrder = nil
7733 i.toolGate = map[string]int{}
7734 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
7735 i.scrollOffset = 0 // jump back to the bottom on new turn
7736 // Lift the resume tail cap once the user starts interacting. The
7737 // cap is purely a first-paint optimization (don't markdown the
7738 // whole history before showing anything). Keeping it active during
7739 // a turn makes the rendered chat a sliding window: appended
7740 // messages push older ones off the TOP of the buffer, which the
7741 // renderer must treat as a change above the viewport and repaint
7742 // fully, snapping the terminal's native scrollback to the bottom on
7743 // every streamed chunk. A fresh session has no cap (append-only),
7744 // which is why the jump only shows up in resumed sessions. Dropping
7745 // the cap here makes resumed turns append-only too.
7746 i.view.TailLimit = 0
7747 // Reset the auto-follow baseline so the very next render at
7748 // interactive.go:1053 doesn't see a synthetic shrink between
7749 // "last frame had the previous turn's tool overlay" and
7750 // "this frame had it cleared above". Without this, the guard
7751 // reads delta = -(rows in cleared overlay) and decrements
7752 // scrollOffset, which on terminals that mirror zut's pane
7753 // scroll into the host scrollbar visibly yanks the viewport.
7754 // See autofollow_shrink_test.go for the exact arithmetic.
7755 i.prevChatLen = 0
7756 i.prevChatCols = 0
7757 i.parkedTurn = 0 // starting a turn clears the /jump parked state
7758 i.parkedTotal = 0
7759 i.helpBlock = nil // hide the help block once the user asks something
7760 i.mu.Unlock()
7761 i.invalidate()
7762
7763 var (
7764 lastStop provider.StopReason
7765 lastTurnErr error
7766 )
7767 sink := func(ev core.AgentEvent) {
7768 if e, ok := ev.(core.EvTurnEnd); ok {
7769 lastStop = e.Stop
7770 lastTurnErr = e.Err
7771 }
7772 i.handleEventForPresentation(ev)
7773 }
7774
7775 releaseCompletionHold := i.beginCompletionDeliveryHold()
7776 go func() {
7777 defer releaseCompletionHold()
7778 var err error
7779 if continueExisting {
7780 err = i.agent.Continue(ctx, sink)
7781 } else {
7782 err = i.agent.Prompt(ctx, prompt, images, sink)
7783 }
7784 i.mu.Lock()
7785 // Keep busy asserted through final cleanup and queue selection. A
7786 // watcher may enqueue a completion summary concurrently with the
7787 // provider's final event; publishing idle before inspecting queues can
7788 // strand that summary in the core agent queue or start an overlapping
7789 // turn.
7790 // Don't touch streamPending / streamFlushPending here — the
7791 // pacer may still be draining the final deltas and needs to
7792 // paint them even though Prompt has returned. It will reset
7793 // streamOn on its own once the buffer empties.
7794 if len(i.streamPending) == 0 {
7795 i.streamOn = false
7796 }
7797 i.cancelTurn = nil
7798 pendingIdleWork := i.takePendingIdleWorkLocked()
7799 if err != nil && ctx.Err() == nil {
7800 i.statusErr = err.Error()
7801 }
7802 // Decide whether to offer a model rescue picker for recoverable
7803 // provider failures (auth/rate/temporary). The picker opens after
7804 // the mutex is released so it can take its own locks freely.
7805 var (
7806 offer bool
7807 rescueWhy string
7808 rescueImgs []provider.ImageBlock
7809 rescueModel string
7810 rescueProv string
7811 rescueFprov string
7812 )
7813 if err != nil && ctx.Err() == nil {
7814 if ok, reason := classifyRescueError(err); ok {
7815 offer = true
7816 rescueWhy = reason
7817 rescueImgs = images
7818 rescueModel = i.cfg.Model
7819 rescueProv = i.cfg.Provider
7820 rescueFprov = extractFailedProvider(err)
7821 if rescueFprov == "" {
7822 rescueFprov = i.cfg.Provider
7823 }
7824 // Suppress the red banner — the rescue dialog already
7825 // surfaces the failure.
7826 i.statusErr = ""
7827 }
7828 }
7829 // Detect responses that reject the current context, either as an
7830 // HTTP 413 payload limit or a model context-window error. Token-
7831 // based auto-compact can miss both when metadata is stale or the
7832 // limit is measured in raw bytes. Compact once, then continue the
7833 // user message already present in the transcript.
7834 contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
7835 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
7836 if recoverContextOverflow {
7837 i.statusErr = ""
7838 i.continueAfterCompact = true
7839 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
7840 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
7841 }
7842 // Persist the assistant's reply (and every tool row before
7843 // it) to the session file while the turn memory is hot.
7844 // Without this, WriteNewTranscript only fires at zut exit,
7845 // meaning a crash or ungraceful kill drops the whole
7846 // conversation. FlushSession is idempotent (it advances the
7847 // baseline so subsequent flushes only write new rows).
7848 flush := i.cfg.FlushSession
7849 i.mu.Unlock()
7850 runPendingIdleWork(pendingIdleWork)
7851 if flush != nil {
7852 flush()
7853 }
7854 terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7855 if terminalGoalError {
7856 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7857 }
7858 i.mu.Lock()
7859 awaitingPre := i.awaitingStartupPre
7860 // A newer explicit prompt may have cleared the handoff while the
7861 // completed turn was being flushed. Re-read it under the mutex before
7862 // deciding whether this continuation may spend another rescue attempt.
7863 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7864 // Pop the next queued message, if any, and relaunch.
7865 var next string
7866 var hasNext bool
7867 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
7868 next, i.queued = i.queued[0], i.queued[1:]
7869 hasNext = true
7870 }
7871 // If the turn was cancelled or errored, drop the queue so the
7872 // user isn't bombarded with stale messages after an interrupt.
7873 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
7874 i.queued = nil
7875 if i.agent != nil {
7876 i.agent.DrainQueuedMessages()
7877 }
7878 }
7879 // Decide whether the next thing to do is an auto-compaction.
7880 // Only fires when the turn completed cleanly AND no host-side
7881 // or agent-side queued messages are waiting (otherwise a queued
7882 // message would race the condense).
7883 agentQueued := 0
7884 if i.agent != nil {
7885 agentQueued = i.agent.QueuedMessageCount()
7886 }
7887 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7888 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7889 continueStatusRescue := false
7890 var handoff json.RawMessage
7891 var persistHandoff bool
7892 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7893 followUpMessages := i.agent.Messages()
7894 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7895 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7896 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7897 reason: compactContinuationStatusRescue,
7898 rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7899 })
7900 continueStatusRescue = true
7901 } else {
7902 handoff, persistHandoff = i.resetCompactContinuationLocked()
7903 }
7904 }
7905 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7906 handoff, persistHandoff = i.resetCompactContinuationLocked()
7907 }
7908 // The agent run can finish before the paced final text reaches the
7909 // transcript. A compaction replaces that transcript, so it must never
7910 // race the still-live stream frame; otherwise stale deltas can repaint
7911 // after the replacement and corrupt the scrollback renderer's model.
7912 if recoverContextOverflow || shouldAutoCompact {
7913 i.resetStreamingStateLocked()
7914 }
7915 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7916 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7917 i.mu.Unlock()
7918 if persistHandoff {
7919 i.persistCompactHandoff(handoff)
7920 }
7921 if alertReason != "" {
7922 i.scheduleMainAlert(alertReason)
7923 }
7924 i.invalidate()
7925 parent := i.runCtx
7926 if parent == nil {
7927 parent = context.Background()
7928 }
7929 if awaitingPre {
7930 i.completeStartupPre()
7931 return
7932 }
7933 switch {
7934 case hasNext:
7935 i.startTurn(parent, next)
7936 case continueQueued:
7937 i.startTurnRequest(parent, "", nil, true, false)
7938 case continueStatusRescue:
7939 i.startAutoCompactContinuation(parent)
7940 case offer:
7941 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7942 case recoverContextOverflow:
7943 i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
7944 case shouldAutoCompact:
7945 i.runCompact(parent, compactContinuationRequest{
7946 origin: compactOriginAfterTurnThreshold,
7947 force: lastStop == provider.StopLength,
7948 lastStop: lastStop,
7949 turnError: lastTurnErr,
7950 })
7951 }
7952 }()
7953}
7954
7955func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
7956 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
7957 return ""
7958 }
7959 if rescue {
7960 return "rescue_required"
7961 }
7962 if stop == provider.StopAborted {
7963 return ""
7964 }
7965 if err != nil || turnErr != nil || stop == provider.StopError {
7966 return "agent_error"
7967 }
7968 if stop == provider.StopLength {
7969 return "response_truncated"
7970 }
7971 return "agent_done"
7972}
7973
7974// openRescueDialog surfaces the rescue model picker after a recoverable
7975// provider failure. The pending prompt + images are stashed on the
7976// Interactive so a later applyRescueSelection can re-run the same turn
7977// against the freshly-picked model. activeProvider/failedProvider are
7978// usually the same, but some clients embed different prefixes in their
7979// errors than the configured provider id, so we accept both.
7980func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
7981 if i.rescueDialog == nil {
7982 return
7983 }
7984 loggedIn := []string{}
7985 if i.cfg.LoggedInProviders != nil {
7986 loggedIn = i.cfg.LoggedInProviders()
7987 }
7988 fprov := failedProvider
7989 if fprov == "" {
7990 fprov = activeProvider
7991 }
7992 i.mu.Lock()
7993 i.pendingRescuePrompt = prompt
7994 i.pendingRescueImages = images
7995 i.mu.Unlock()
7996 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
7997 i.invalidate()
7998}
7999
8000// applyRescueSelection switches model (cross-provider if needed) and
8001// re-runs the same prompt+images that just failed. Mirrors
8002// applyModelSelection's transcript-carry logic so the user keeps full
8003// session continuity across the swap.
8004func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
8005 if model == "" {
8006 return
8007 }
8008 i.applyRescueModelSelection(prov, model)
8009 i.mu.Lock()
8010 images := i.pendingRescueImages
8011 if prompt == "" {
8012 prompt = i.pendingRescuePrompt
8013 }
8014 i.pendingRescuePrompt = ""
8015 i.pendingRescueImages = nil
8016 i.mu.Unlock()
8017 parent := i.runCtx
8018 if parent == nil {
8019 parent = context.Background()
8020 }
8021 i.startTurnWithImages(parent, prompt, images)
8022}
8023
8024func stripAutoCompactNotes(notes []string) []string {
8025 if len(notes) == 0 {
8026 return notes
8027 }
8028 out := notes[:0]
8029 for _, n := range notes {
8030 if strings.Contains(n, "condensing history") {
8031 continue
8032 }
8033 out = append(out, n)
8034 }
8035 return out
8036}
8037
8038// autoCompactNoteLine returns a styled chat-area note for the
8039// inline auto-compact heads-up. Lives in extNotes so it survives
8040// the busy-spinner overwrite of the status row.
8041func autoCompactNoteLine(th tui.Theme, msg string) string {
8042 return " " + th.FGColor(th.Warning, "⚠ "+msg)
8043}
8044
8045// NormalizeAutoCompactThreshold applies the persisted auto-compaction
8046// setting's supported values and default. It is exported so non-interactive
8047// hosts can share the interactive mode's exact configuration semantics.
8048func NormalizeAutoCompactThreshold(threshold *int) int {
8049 const defaultAutoCompactThreshold = 85
8050 if threshold == nil {
8051 return defaultAutoCompactThreshold
8052 }
8053 switch *threshold {
8054 case 0, 70, 80, 85, 90, 95:
8055 return *threshold
8056 default:
8057 return defaultAutoCompactThreshold
8058 }
8059}
8060
8061// ShouldAutoCompact reports whether the latest request context reached the
8062// configured percentage of the model's advertised context window.
8063func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
8064 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
8065 return false
8066 }
8067 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
8068}
8069
8070// classifyCompactionContinuation chooses a private handoff reason from the
8071// successful turn tail. Structural unfinished-tail handling is independent of
8072// the status matcher. A status rescue is admitted only for an after-turn
8073// threshold compaction or an already-active status handoff, and only after a
8074// normal successful end.
8075func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
8076 if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
8077 return compactContinuationStructuralTail
8078 }
8079 if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
8080 return compactContinuationNone
8081 }
8082 if likelyForwardWorkStatus(msgs) {
8083 return compactContinuationStatusRescue
8084 }
8085 return compactContinuationNone
8086}
8087
8088// structuralCompactionContinuation preserves the pre-existing unfinished-tail
8089// behavior without consulting the status matcher.
8090func structuralCompactionContinuation(msgs []provider.Message) bool {
8091 if len(msgs) == 0 {
8092 return false
8093 }
8094 last := msgs[len(msgs)-1]
8095 if last.Role != provider.RoleAssistant {
8096 return true
8097 }
8098 hasText := false
8099 for _, content := range last.Content {
8100 switch block := content.(type) {
8101 case provider.ToolCallBlock:
8102 return true
8103 case provider.TextBlock:
8104 if strings.TrimSpace(block.Text) != "" {
8105 hasText = true
8106 }
8107 case provider.ReasoningBlock:
8108 // Reasoning without a visible answer is not terminal.
8109 default:
8110 return true
8111 }
8112 }
8113 return !hasText
8114}
8115
8116// likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
8117// first-person future-work commitments and never treats generic plan prose as
8118// a host control signal.
8119func likelyForwardWorkStatus(msgs []provider.Message) bool {
8120 if len(msgs) == 0 {
8121 return false
8122 }
8123 last := msgs[len(msgs)-1]
8124 if last.Role != provider.RoleAssistant {
8125 return false
8126 }
8127 var text strings.Builder
8128 for _, content := range last.Content {
8129 block, ok := content.(provider.TextBlock)
8130 if !ok {
8131 if _, hasTool := content.(provider.ToolCallBlock); hasTool {
8132 return false
8133 }
8134 continue
8135 }
8136 if strings.TrimSpace(block.Text) == "" {
8137 continue
8138 }
8139 if text.Len() > 0 {
8140 text.WriteByte(' ')
8141 }
8142 text.WriteString(block.Text)
8143 }
8144 if text.Len() == 0 {
8145 return false
8146 }
8147 normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
8148 normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
8149 for _, phrase := range []string{
8150 "next i will",
8151 "next, i will",
8152 "i'll now",
8153 "i will now",
8154 "i am going to",
8155 "i need to continue",
8156 "then i will",
8157 "then, i will",
8158 "i will proceed",
8159 } {
8160 if containsFutureWorkPhrase(normalized, phrase) {
8161 return true
8162 }
8163 }
8164 return false
8165}
8166
8167func containsFutureWorkPhrase(text, phrase string) bool {
8168 for offset := 0; offset < len(text); {
8169 index := strings.Index(text[offset:], phrase)
8170 if index < 0 {
8171 return false
8172 }
8173 index += offset
8174 end := index + len(phrase)
8175 if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
8176 return true
8177 }
8178 offset = end
8179 }
8180 return false
8181}
8182
8183func wordRuneBefore(text string, index int) bool {
8184 if index == 0 {
8185 return false
8186 }
8187 rune_, _ := utf8.DecodeLastRuneInString(text[:index])
8188 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
8189}
8190
8191func wordRuneAfter(text string, index int) bool {
8192 if index == len(text) {
8193 return false
8194 }
8195 rune_, _ := utf8.DecodeRuneInString(text[index:])
8196 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
8197}
8198
8199// shouldAutoCompactLocked reports whether the last turn pushed context
8200// usage past the auto-compact threshold. Must be called with i.mu
8201// held; it reads lastCtxInput and the current model's context window.
8202func (i *Interactive) shouldAutoCompactLocked() bool {
8203 if i.agent == nil {
8204 return false
8205 }
8206 if i.autoCompacting {
8207 return false
8208 }
8209 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
8210 if err != nil || m.ContextWindow <= 0 {
8211 return false
8212 }
8213 threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
8214 return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
8215}
8216
8217func eventAffectsPresentation(ev core.AgentEvent) bool {
8218 switch ev.(type) {
8219 case core.EvToolProgress:
8220 return false
8221 default:
8222 return true
8223 }
8224}
8225
8226func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
8227 i.toolRenderRevision++
8228 if i.toolRenderRevision == 0 {
8229 // Overflow is practically unreachable, but resetting the sequence must
8230 // also drop old revisions before they can alias new frames.
8231 if i.view != nil {
8232 i.view.InvalidateRenderCache()
8233 }
8234 i.toolRenderRevision = 1
8235 }
8236 tc.Revision = i.toolRenderRevision
8237}
8238
8239func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8240 i.handleEvent(ev)
8241 // Progress payloads are still delivered to the core sink and applied
8242 // in order, but they have no interactive-visible representation.
8243 if eventAffectsPresentation(ev) {
8244 i.invalidate()
8245 }
8246}
8247
8248func (i *Interactive) handleEvent(ev core.AgentEvent) {
8249 i.mu.Lock()
8250 defer i.mu.Unlock()
8251 i.activity.apply(ev)
8252 switch e := ev.(type) {
8253 case core.EvAssistantStart:
8254 // Fires at the top of every oneTurn, including follow-up
8255 // turns after tool use. Without this, the streaming buffer
8256 // is still marked off from the previous assistant message
8257 // and the final summary text pops in all at once instead
8258 // of typewriter-streaming delta by delta.
8259 i.streaming.Reset()
8260 i.streamPending = i.streamPending[:0]
8261 i.streamFlushPending = false
8262 i.streamOn = true
8263 // Clear the live tool-call overlay. Any tools from the
8264 // previous round are now fully folded into the transcript
8265 // (assistant tool_use block + tool role message with the
8266 // result), so keeping them in the overlay would duplicate
8267 // them in the view — once inside the finalised transcript
8268 // and once below the streaming block, with the streaming
8269 // summary sandwiched in between. The next EvToolUseStart
8270 // will populate fresh entries for this turn's tools.
8271 i.toolCalls = map[string]*tui.ToolCallView{}
8272 i.toolOrder = nil
8273 i.toolGate = map[string]int{}
8274 case core.EvTextDelta:
8275 // Buffer into streamPending; the paintPace ticker drains
8276 // it into i.streaming a few runes at a time for a smooth
8277 // typewriter effect independent of upstream chunk size.
8278 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
8279 i.streamOn = true
8280 case core.EvAssistantMessage:
8281 // OnAssistant + telegram mirroring always fire on message
8282 // arrival — they read the FINAL message content, which is
8283 // complete regardless of what's still in the pacer.
8284 i.assistantMessageSideEffects(e.Message)
8285 // If the pacer still has characters to drain, keep streamOn
8286 // true and mark flush pending; the paintPace ticker will
8287 // drain the remainder and reset streaming state when done.
8288 // Otherwise (rare: full-replay sessions, abort paths) clear
8289 // synchronously so a later render doesn't show stale text.
8290 if len(i.streamPending) > 0 {
8291 i.streamFlushPending = true
8292 return
8293 }
8294 i.resetStreamingStateLocked()
8295 case core.EvToolUseStart:
8296 // Live streaming: pre-create the view so the user sees the
8297 // tool call being composed in real time. Any subsequent
8298 // EvToolCall for the same ID updates the same struct (the
8299 // final parsed args + name are already known here).
8300 if _, exists := i.toolCalls[e.ID]; !exists {
8301 tc := &tui.ToolCallView{
8302 ID: e.ID,
8303 Name: e.Name,
8304 Streaming: true,
8305 }
8306 i.bumpToolRevisionLocked(tc)
8307 i.toolCalls[e.ID] = tc
8308 i.toolOrder = append(i.toolOrder, e.ID)
8309 i.gateToolLocked(e.ID)
8310 }
8311 case core.EvToolUseArgs:
8312 if tc, ok := i.toolCalls[e.ID]; ok {
8313 tc.RawJSONBuf += e.Delta
8314 i.bumpToolRevisionLocked(tc)
8315 // Refresh the live path as soon as it parses; used in
8316 // the header (write /Users/example/Desktop/demo.ts)
8317 // while the content is still streaming.
8318 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
8319 tc.LivePath = p
8320 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
8321 tc.LivePath = p
8322 }
8323 }
8324 case core.EvToolUseEnd:
8325 if tc, ok := i.toolCalls[e.ID]; ok {
8326 tc.Streaming = false
8327 i.bumpToolRevisionLocked(tc)
8328 }
8329 case core.EvToolCall:
8330 // If we already pre-created the view during streaming, just
8331 // refresh the final Args summary. Otherwise create a new one
8332 // (non-streaming providers or legacy paths).
8333 if tc, ok := i.toolCalls[e.ID]; ok {
8334 tc.Args = tui.ShortArgs(e.Name, e.Args)
8335 i.bumpToolRevisionLocked(tc)
8336 if tc.RawJSONBuf == "" {
8337 tc.RawJSONBuf = string(e.Args)
8338 }
8339 tc.Streaming = false
8340 } else {
8341 tc := &tui.ToolCallView{
8342 ID: e.ID,
8343 Name: e.Name,
8344 Args: tui.ShortArgs(e.Name, e.Args),
8345 RawJSONBuf: string(e.Args),
8346 }
8347 i.bumpToolRevisionLocked(tc)
8348 i.toolCalls[e.ID] = tc
8349 i.toolOrder = append(i.toolOrder, e.ID)
8350 i.gateToolLocked(e.ID)
8351 }
8352 case core.EvToolResult:
8353 if tc, ok := i.toolCalls[e.ID]; ok {
8354 tc.Done = true
8355 tc.Error = e.Result.IsError
8356 tc.Preview = ""
8357 var text strings.Builder
8358 for _, c := range e.Result.Content {
8359 if tb, ok := c.(provider.TextBlock); ok {
8360 if text.Len() > 0 {
8361 text.WriteString("\n")
8362 }
8363 text.WriteString(tb.Text)
8364 }
8365 }
8366 tc.Result = text.String()
8367 i.bumpToolRevisionLocked(tc)
8368 }
8369 if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
8370 if update.Status == core.GoalActive {
8371 // A manager may advance a terminal goal to the next persisted
8372 // goal in the same mission. Do not let a late tool result resume
8373 // a goal explicitly paused by the user.
8374 if i.goalStatus != core.GoalPaused {
8375 i.goalStatus = core.GoalActive
8376 }
8377 } else if i.goalStatus == core.GoalActive {
8378 i.goalStatus = update.Status
8379 }
8380 }
8381 case core.EvUsage:
8382 i.cumUsage = e.Cumulative
8383 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
8384 i.lastCtxInput = contextUsed
8385 }
8386 case core.EvTurnEnd:
8387 if e.Stop == provider.StopAborted {
8388 i.resetStreamingStateLocked()
8389 i.statusErr = ""
8390 i.statusOK = "cancelled"
8391 return
8392 }
8393 if e.Stop == provider.StopLength {
8394 // The model hit its output-token cap mid-response, so the
8395 // reply (often a long write/edit) is truncated. Surface it
8396 // explicitly, otherwise the turn just ends and reads like
8397 // the UI gave up. The agent already requests the model's
8398 // full MaxOutput budget, so this means the response genuinely
8399 // exceeded that ceiling; ask the user to continue.
8400 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
8401 i.statusOK = ""
8402 return
8403 }
8404 // Don't surface mid-loop stream errors as a red banner here.
8405 // EvTurnEnd fires after every step in a multi-step tool loop,
8406 // so a transient 503 / network blip would briefly paint a red
8407 // banner over the still-streaming chat before the agent loop
8408 // either retries or exits. The final error (if any) is set by
8409 // startTurnWithImages once Prompt() returns, and recoverable
8410 // failures are routed to the rescue picker instead — which
8411 // keeps the chat clean while the agent is working.
8412 _ = e.Err
8413 }
8414}
8415
8416// WebSearchPolicyGeneration returns the generation a resolver must present at
8417// its final interactive commit. Policy transitions advance it before changing
8418// the gate or generic-child ceiling.
8419func (i *Interactive) WebSearchPolicyGeneration() uint64 {
8420 return i.webSearchPolicyGeneration.Load()
8421}
8422
8423// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
8424// ag is still the live interactive agent. Callers without a long-running
8425// resolve use the current policy generation.
8426func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
8427 return i.applyAgentPromptConfig(ag, system, tools, 0, false)
8428}
8429
8430// ApplyAgentPromptConfigAtWebSearchGeneration additionally rejects a registry
8431// resolved before a web-search policy transition. This final check is made in
8432// the same critical section as live-agent replacement and the core prompt swap.
8433func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
8434 return i.applyAgentPromptConfig(ag, system, tools, generation, true)
8435}
8436
8437func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
8438 if ag == nil {
8439 return nil, false
8440 }
8441 // Match the lock order used by dynamic tool mutations so a refresh
8442 // cannot race a snapshot-and-replace operation that would reintroduce
8443 // the old registry after the atomic prompt swap.
8444 i.agentMu.Lock()
8445 defer i.agentMu.Unlock()
8446 i.mu.Lock()
8447 defer i.mu.Unlock()
8448 if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
8449 return nil, false
8450 }
8451 if i.telegramBridge != nil {
8452 // Telegram prompts arrive over an external messaging channel without a
8453 // per-request confirmation surface. Keep the normal interactive
8454 // registry from reintroducing web_search from the moment the bridge is
8455 // attached, including while its startup handshake is in flight.
8456 delete(tools, "web_search")
8457 }
8458 oldTools := ag.SetPromptConfig(system, tools)
8459 _, webSearchAvailable := tools["web_search"]
8460 i.setWebSearchAvailable(webSearchAvailable)
8461 return oldTools, true
8462}
8463
8464// prepareReplacementAgentLocked applies session-wide capability policy before
8465// ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
8466func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
8467 if ag == nil {
8468 i.setWebSearchAvailable(false)
8469 return
8470 }
8471 registry := ag.ToolsSnapshot()
8472 if i.telegramBridge != nil {
8473 // External Telegram prompts may arrive as soon as the replacement is
8474 // published, so post-swap cleanup is too late.
8475 delete(registry, "web_search")
8476 ag.SetTools(registry)
8477 }
8478 _, webSearchAvailable := registry["web_search"]
8479 i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
8480}
8481
8482// DeferUntilIdle runs work immediately when the interactive agent is idle,
8483// or after the current turn/compaction/shell operation releases the busy
8484// state. Callbacks run outside i.mu.
8485func (i *Interactive) DeferUntilIdle(fn func()) {
8486 if fn == nil {
8487 return
8488 }
8489 i.mu.Lock()
8490 if i.busy {
8491 i.pendingIdleWork = append(i.pendingIdleWork, fn)
8492 i.mu.Unlock()
8493 return
8494 }
8495 i.mu.Unlock()
8496 fn()
8497}
8498
8499func (i *Interactive) takePendingIdleWorkLocked() []func() {
8500 work := i.pendingIdleWork
8501 i.pendingIdleWork = nil
8502 return work
8503}
8504
8505func runPendingIdleWork(work []func()) {
8506 for _, fn := range work {
8507 if fn != nil {
8508 fn()
8509 }
8510 }
8511}
8512
8513// Agent returns the current agent, if any. Used by cli.go to flush the
8514// final transcript to the session file.
8515func (i *Interactive) Agent() *core.Agent {
8516 i.mu.Lock()
8517 defer i.mu.Unlock()
8518 return i.agent
8519}
8520
8521// silence unused import in some build configs
8522var _ = fmt.Sprintf
8523
8524// runReloadExt triggers a live reload of every extension (discovered
8525// + explicit). Runs on a goroutine so the TUI stays responsive; the
8526// Manager.Reload takes a couple of hundred ms to shut down subprocs
8527// and respawn them. Shows a temporary status line throughout.
8528func (i *Interactive) runReloadExt(ctx context.Context) {
8529 if i.cfg.Extensions == nil {
8530 i.mu.Lock()
8531 i.statusErr = "no extension manager in this build"
8532 i.mu.Unlock()
8533 i.invalidate()
8534 return
8535 }
8536 i.mu.Lock()
8537 i.statusOK = "Reloading extensions..."
8538 i.statusErr = ""
8539 i.mu.Unlock()
8540 i.invalidate()
8541
8542 go func() {
8543 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
8544 msg, failed := formatReloadStatus(stats)
8545 seq := i.setReloadStatus(msg, failed)
8546 i.invalidate()
8547 go i.dismissReloadStatus(ctx, seq, msg, failed)
8548 }()
8549}
8550
8551// Confirm implements core.Confirmer. The agent goroutine calls
8552// this synchronously before every tool invocation when --no-yolo is
8553// active. We push the request onto the confirmDialog queue, trigger
8554// a redraw, and block the caller until the user answers.
8555//
8556// If the session is cancelled or the TUI exits mid-prompt, any
8557// pending request is refused via CancelAll so the agent doesn't
8558// deadlock.
8559func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
8560 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
8561}
8562
8563// ConfirmToolCall attaches a side-effect-free preview to the matching live
8564// tool panel, then blocks until the user approves or refuses the call.
8565func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
8566 resp := make(chan core.ConfirmDecision, 1)
8567 if isBtwOrigin(call.Origin) {
8568 req := &confirmRequest{
8569 toolName: call.Name,
8570 preview: call.Summary,
8571 resp: resp,
8572 returnToChild: true,
8573 }
8574 if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
8575 i.confirmDialog.Enqueue(req)
8576 }) {
8577 return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
8578 }
8579 i.invalidate()
8580 return <-resp
8581 }
8582 if call.ID != "" {
8583 i.mu.Lock()
8584 if tc, ok := i.toolCalls[call.ID]; ok {
8585 tc.Args = call.Summary
8586 tc.Preview = call.Content
8587 i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
8588 }
8589 i.mu.Unlock()
8590 }
8591 i.confirmDialog.Enqueue(&confirmRequest{
8592 toolName: call.Name,
8593 preview: call.Summary,
8594 resp: resp,
8595 })
8596 i.invalidate()
8597 return <-resp
8598}
8599
8600// openTelegramDialog shows the picker for `/telegram` with no arg.
8601// Items depend on current state: disconnect + status when running,
8602// connect + status when stopped.
8603func (i *Interactive) openTelegramDialog() {
8604 items := i.telegramMenuItems()
8605 if len(items) == 0 {
8606 i.mu.Lock()
8607 i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
8608 i.mu.Unlock()
8609 i.invalidate()
8610 return
8611 }
8612 i.telegramDialog.Open(items)
8613 i.invalidate()
8614}
8615
8616// telegramMenuItems builds the dialog entries for the current
8617// bridge state. Returns empty when no bot.json exists so the
8618// caller can show a helpful status line instead of an empty menu.
8619func (i *Interactive) telegramMenuItems() []telegramItem {
8620 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8621 if err != nil || cfg.BotToken == "" {
8622 return nil
8623 }
8624 var items []telegramItem
8625 if i.telegramBridge != nil && i.telegramBridge.Active() {
8626 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
8627 st := i.telegramBridge.State()
8628 hint := "active"
8629 if st.Username != "" {
8630 hint += " as @" + st.Username
8631 }
8632 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
8633 } else {
8634 label := "connect"
8635 hint := "start mirroring dms into this session"
8636 if cfg.AllowedUserID == 0 {
8637 hint = "awaiting pairing (send /start to the bot once connected)"
8638 }
8639 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
8640 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
8641 }
8642 return items
8643}
8644
8645// doTelegram dispatches one of the three explicit actions. Called
8646// from /telegram <action> or after the picker selects a row.
8647func (i *Interactive) doTelegram(action string) {
8648 switch action {
8649 case "connect":
8650 i.telegramConnect()
8651 case "disconnect":
8652 i.telegramDisconnect()
8653 case "status":
8654 i.telegramStatus()
8655 default:
8656 i.mu.Lock()
8657 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
8658 i.mu.Unlock()
8659 i.invalidate()
8660 }
8661}
8662
8663// telegramConnect starts the bridge. Refuses if it's already
8664// running or if the on-disk bot.json is missing a token.
8665func (i *Interactive) telegramConnect() {
8666 if i.telegramBridge != nil && i.telegramBridge.Active() {
8667 i.mu.Lock()
8668 i.statusOK = "telegram already connected"
8669 i.statusErr = ""
8670 i.mu.Unlock()
8671 i.invalidate()
8672 return
8673 }
8674 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8675 if err != nil {
8676 i.mu.Lock()
8677 i.statusErr = "telegram: " + err.Error()
8678 i.mu.Unlock()
8679 i.invalidate()
8680 return
8681 }
8682 if cfg.BotToken == "" {
8683 i.mu.Lock()
8684 i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
8685 i.mu.Unlock()
8686 i.invalidate()
8687 return
8688 }
8689 // Refuse to start when a background daemon is already polling
8690 // the same bot. Two concurrent long-poll consumers race each
8691 // update and one always loses, so DMs get half-delivered. The
8692 // user can `zut telegram-bot stop` first, then /telegram connect.
8693 if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8694 i.mu.Lock()
8695 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
8696 i.mu.Unlock()
8697 i.invalidate()
8698 return
8699 }
8700 bridge := &telegram.Bridge{
8701 Client: telegram.NewClient(cfg.BotToken),
8702 Config: cfg,
8703 Save: func(next telegram.Config) error {
8704 return telegram.SaveConfig(i.cfg.ZutHome, next)
8705 },
8706 Host: &telegramHost{iv: i},
8707 }
8708 i.mu.Lock()
8709 i.telegramBridge = bridge
8710 i.mu.Unlock()
8711 // Strip web_search before the bridge's startup handshake can accept a
8712 // Telegram update. ApplyAgentPromptConfig also keeps it stripped from
8713 // concurrent refreshes while this bridge pointer is attached.
8714 i.applyTelegramTools(true)
8715 if err := bridge.Start(i.runCtx); err != nil {
8716 i.mu.Lock()
8717 i.telegramBridge = nil
8718 i.mu.Unlock()
8719 refreshErr := i.refreshToolsAfterTelegram()
8720 i.mu.Lock()
8721 i.statusErr = "telegram connect failed: " + err.Error()
8722 if refreshErr != nil {
8723 i.statusErr += "; tool refresh: " + refreshErr.Error()
8724 }
8725 i.mu.Unlock()
8726 i.invalidate()
8727 return
8728 }
8729 state := bridge.State()
8730 label := "telegram connected"
8731 if state.Username != "" {
8732 label += " as @" + state.Username
8733 }
8734 if state.PairedID == 0 {
8735 label += " — send /start to the bot from your phone to claim it"
8736 }
8737 i.mu.Lock()
8738 i.statusOK = label
8739 i.statusErr = ""
8740 i.mu.Unlock()
8741 i.invalidate()
8742}
8743
8744// telegramDisconnect stops the bridge. No-op when already stopped.
8745func (i *Interactive) telegramDisconnect() {
8746 if i.telegramBridge == nil || !i.telegramBridge.Active() {
8747 i.mu.Lock()
8748 i.statusOK = "telegram already disconnected"
8749 i.statusErr = ""
8750 i.mu.Unlock()
8751 i.invalidate()
8752 return
8753 }
8754 bridge := i.telegramBridge
8755 bridge.Stop()
8756 i.mu.Lock()
8757 // Clear the pointer before rebuilding the normal registry. This both
8758 // allows web_search back into the refresh and marks the bridge inactive
8759 // for any concurrent prompt-config commit.
8760 i.telegramBridge = nil
8761 i.mu.Unlock()
8762 refreshErr := i.refreshToolsAfterTelegram()
8763 i.mu.Lock()
8764 if refreshErr != nil {
8765 i.statusOK = ""
8766 i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error()
8767 } else {
8768 i.statusOK = "telegram disconnected"
8769 i.statusErr = ""
8770 }
8771 i.mu.Unlock()
8772 i.invalidate()
8773}
8774
8775// refreshToolsAfterTelegram restores normal tools only through a successful
8776// resolver refresh. Failure removes Telegram-only tools but leaves web_search
8777// revoked in the live registry, stale snapshots, and generic-child policy.
8778func (i *Interactive) refreshToolsAfterTelegram() error {
8779 if i.cfg.RefreshTools == nil {
8780 i.stripWebSearchTool()
8781 i.applyTelegramTools(false)
8782 return errors.New("live tool refresh is unavailable")
8783 }
8784 if err := i.cfg.RefreshTools(); err != nil {
8785 i.stripWebSearchTool()
8786 i.applyTelegramTools(false)
8787 return err
8788 }
8789 i.applyTelegramTools(false)
8790 return nil
8791}
8792
8793// telegramSenderAdapter wraps the bridge so the tools package can
8794// drive it without importing telegram directly. The Active() check
8795// is forwarded to the bridge so the tool can fail clearly with a
8796// model-readable error when the user disconnected mid-turn.
8797type telegramSenderAdapter struct {
8798 bridge *telegram.Bridge
8799}
8800
8801func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
8802 if a.bridge == nil {
8803 return fmt.Errorf("telegram bridge is not connected")
8804 }
8805 return a.bridge.SendImage(ctx, path, caption)
8806}
8807
8808func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
8809 if a.bridge == nil {
8810 return fmt.Errorf("telegram bridge is not connected")
8811 }
8812 return a.bridge.SendDocument(ctx, path, caption)
8813}
8814
8815func (a telegramSenderAdapter) Active() bool {
8816 return a.bridge != nil && a.bridge.Active()
8817}
8818
8819// TrackSubagentWorker is the exported entry point used by the cli to
8820// hand a freshly-spawned auto-subagents agent off to the shared tracker.
8821func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, _ bool) {
8822 i.trackSubagentWorker(a, task, false, false)
8823}
8824
8825// TrackResumedSubagentWorker watches a resumed follow-up independently of the
8826// worker's original task. A long-lived daemon can complete several manager
8827// turns, each of which must produce its own automatic delivery.
8828func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) {
8829 i.trackSubagentWorker(a, prompt, true, false)
8830}
8831
8832// PrepareResumedSubagentWorker registers a resumed turn before its prompt is
8833// sent. The returned cleanup removes the registration if the resume operation
8834// fails before delivery. It is used by the resume tool's pre-send hook; the
8835// existing TrackResumedSubagentWorker signature remains the post-success API.
8836func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) func() {
8837 return i.trackSubagentWorker(a, prompt, true, true)
8838}
8839
8840// TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8841// task watcher already owns the worker, the shared tracker leaves that watcher
8842// responsible for the terminal outcome.
8843func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
8844 if i == nil || a == nil {
8845 return
8846 }
8847 tracker := i.ensureCompletionTracker()
8848 i.registerCoordinatorWorker(a.ID)
8849 tracker.TrackExit(a, a.Task)
8850 i.invalidate()
8851 i.requestCompletionDelivery()
8852}
8853
8854func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp, future bool) func() {
8855 if i == nil || a == nil {
8856 return nil
8857 }
8858 tracker := i.ensureCompletionTracker()
8859 i.registerCoordinatorWorker(a.ID)
8860 var cancel func()
8861 if future {
8862 cancel = tracker.TrackFutureTurn(a, task, followUp)
8863 } else {
8864 cancel = tracker.TrackTurn(a, task, followUp)
8865 }
8866 i.invalidate()
8867 i.requestCompletionDelivery()
8868 return cancel
8869}
8870
8871func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8872 // Tests and lightweight embedders may construct Interactive with a struct
8873 // literal instead of NewInteractive, so retain this lazy fallback.
8874 i.completionDeliveryMu.Lock()
8875 defer i.completionDeliveryMu.Unlock()
8876 if i.completionTracker == nil {
8877 i.completionTracker = subagents.NewCompletionTracker()
8878 }
8879 return i.completionTracker
8880}
8881
8882func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
8883 if i.turnCoordinator == nil {
8884 i.turnCoordinator = orchestration.New()
8885 }
8886 return i.turnCoordinator
8887}
8888
8889func (i *Interactive) coordinatorAcceptsUserInput() bool {
8890 if i == nil {
8891 return false
8892 }
8893 i.completionDeliveryMu.Lock()
8894 accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
8895 i.completionDeliveryMu.Unlock()
8896 return accepts
8897}
8898
8899func (i *Interactive) coordinatorHasPendingWorkers() bool {
8900 if i == nil {
8901 return false
8902 }
8903 i.completionDeliveryMu.Lock()
8904 pending := i.ensureCoordinatorLocked().HasPendingWorkers()
8905 i.completionDeliveryMu.Unlock()
8906 return pending
8907}
8908
8909func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
8910 if i == nil {
8911 return nil
8912 }
8913 i.completionDeliveryMu.Lock()
8914 result := i.ensureCoordinatorLocked().Apply(event)
8915 i.completionDeliveryMu.Unlock()
8916 return result.Actions
8917}
8918
8919// registerCoordinatorWorker associates a tracker registration with the open
8920// manager wave. Direct embedders that register a worker outside startTurn get
8921// a one-worker sealed wave, retaining the exported tracker API's old behavior.
8922func (i *Interactive) registerCoordinatorWorker(workerID string) {
8923 if i == nil || workerID == "" {
8924 return
8925 }
8926 i.completionDeliveryMu.Lock()
8927 coordinator := i.ensureCoordinatorLocked()
8928 implicitWave := i.completionDeliveryHolds == 0
8929 if implicitWave {
8930 coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
8931 }
8932 i.coordinatorWorkerSeq++
8933 registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
8934 if i.coordinatorWorkerIDs == nil {
8935 i.coordinatorWorkerIDs = make(map[string][]string)
8936 }
8937 i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
8938 coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
8939 var actions []orchestration.Action
8940 if implicitWave {
8941 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
8942 }
8943 i.completionDeliveryMu.Unlock()
8944 i.executeCoordinatorActions(actions)
8945}
8946
8947func (i *Interactive) cancelCoordinator() {
8948 if i == nil {
8949 return
8950 }
8951 i.completionDeliveryMu.Lock()
8952 coordinator := i.ensureCoordinatorLocked()
8953 actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
8954 // A later user turn starts a fresh wave. Drop tracker registrations as well
8955 // as coordinator identities so late outcomes cannot match a new worker with
8956 // the same Agent.ID.
8957 if i.completionTracker != nil {
8958 i.completionTracker.Reset()
8959 }
8960 i.turnCoordinator = orchestration.New()
8961 i.coordinatorWorkerIDs = nil
8962 i.completionDeliveryMu.Unlock()
8963 i.executeCoordinatorActions(actions)
8964}
8965
8966func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
8967 if i == nil || agentID == "" {
8968 return ""
8969 }
8970 i.completionDeliveryMu.Lock()
8971 defer i.completionDeliveryMu.Unlock()
8972 ids := i.coordinatorWorkerIDs[agentID]
8973 if len(ids) == 0 {
8974 return ""
8975 }
8976 workerID := ids[0]
8977 if len(ids) == 1 {
8978 delete(i.coordinatorWorkerIDs, agentID)
8979 } else {
8980 i.coordinatorWorkerIDs[agentID] = ids[1:]
8981 }
8982 return workerID
8983}
8984
8985func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
8986 for _, action := range actions {
8987 if action.Kind != orchestration.ActionRunManager {
8988 continue
8989 }
8990 prompt := action.Text
8991 if len(action.Completions) != 0 {
8992 instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
8993 update := subagents.FormatCompletionUpdate(action.Completions, instruction)
8994 if prompt != "" {
8995 prompt = update + "\n\nQueued user request:\n" + prompt
8996 } else {
8997 prompt = update
8998 }
8999 }
9000 if prompt != "" {
9001 i.submitOrQueue(prompt, nil, false)
9002 } else if action.Reason == orchestration.WakeGoal {
9003 parent := i.runCtx
9004 if parent == nil {
9005 parent = context.Background()
9006 }
9007 i.requestGoalContinuationIfIdle(parent)
9008 }
9009 }
9010}
9011
9012// requestCompletionDelivery starts at most one waiter for the current active
9013// set. A request arriving while that waiter is formatting or submitting is
9014// picked up before the waiter exits, so a later worker cannot be lost in the
9015// handoff between batches.
9016func (i *Interactive) requestCompletionDelivery() {
9017 if i == nil {
9018 return
9019 }
9020 i.ensureCompletionTracker()
9021 i.completionDeliveryMu.Lock()
9022 i.completionDeliveryRequest = true
9023 if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
9024 i.completionDeliveryMu.Unlock()
9025 return
9026 }
9027 i.completionDeliveryRunning = true
9028 i.completionDeliveryMu.Unlock()
9029
9030 go i.deliverCompletionUpdates()
9031}
9032
9033// beginCompletionDeliveryHold keeps all completions observed during one
9034// parent model turn together. Tool calls are executed sequentially inside the
9035// core agent, so the release point is the owning registration boundary for
9036// same-parent-turn batching rather than a timing-based debounce.
9037func (i *Interactive) beginCompletionDeliveryHold() func() {
9038 if i == nil {
9039 return func() {}
9040 }
9041 i.completionDeliveryMu.Lock()
9042 i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
9043 i.completionDeliveryHolds++
9044 i.completionDeliveryMu.Unlock()
9045 return i.releaseCompletionDeliveryHold
9046}
9047
9048func (i *Interactive) releaseCompletionDeliveryHold() {
9049 if i == nil {
9050 return
9051 }
9052 start := false
9053 var actions []orchestration.Action
9054 i.completionDeliveryMu.Lock()
9055 if i.completionDeliveryHolds > 0 {
9056 i.completionDeliveryHolds--
9057 }
9058 if i.completionDeliveryHolds == 0 {
9059 // The coordinator must observe the goal before sealing the wave, so a
9060 // pending worker wins the wake decision instead of a direct continuation.
9061 _, goalActive := i.goalContinuationMessage()
9062 coordinator := i.ensureCoordinatorLocked()
9063 coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
9064 actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
9065 }
9066 if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
9067 i.completionDeliveryRunning = true
9068 start = true
9069 }
9070 i.completionDeliveryMu.Unlock()
9071 i.executeCoordinatorActions(actions)
9072 if start {
9073 go i.deliverCompletionUpdates()
9074 }
9075}
9076
9077func (i *Interactive) completionWaitContext() context.Context {
9078 i.mu.Lock()
9079 ctx := i.runCtx
9080 i.mu.Unlock()
9081 if ctx == nil {
9082 return context.Background()
9083 }
9084 return ctx
9085}
9086
9087func (i *Interactive) deliverCompletionUpdates() {
9088 for {
9089 i.completionDeliveryMu.Lock()
9090 i.completionDeliveryRequest = false
9091 tracker := i.completionTracker
9092 i.completionDeliveryMu.Unlock()
9093
9094 batch, err := tracker.WaitIdle(i.completionWaitContext())
9095 if err == nil && len(batch) != 0 {
9096 var actions []orchestration.Action
9097 for _, completion := range batch {
9098 workerID := i.takeCoordinatorWorkerID(completion.AgentID)
9099 if workerID == "" {
9100 continue
9101 }
9102 actions = append(actions, i.applyCoordinator(orchestration.Event{
9103 Kind: orchestration.EventWorkerFinished,
9104 WorkerID: workerID,
9105 Completion: completion,
9106 })...)
9107 }
9108 i.executeCoordinatorActions(actions)
9109 }
9110
9111 i.completionDeliveryMu.Lock()
9112 if i.completionDeliveryRequest {
9113 i.completionDeliveryMu.Unlock()
9114 continue
9115 }
9116 i.completionDeliveryRunning = false
9117 i.completionDeliveryMu.Unlock()
9118 return
9119 }
9120}
9121
9122// autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,
9123// in the same order Resolve appends them to a new agent. The profile manifest
9124// is independent of strict orchestration and remains available in on-demand
9125// mode whenever the primary agent can spawn a worker.
9126func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
9127 addenda := make([]string, 0, 2)
9128 if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
9129 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
9130 addenda = append(addenda, addendum)
9131 }
9132 }
9133 if !orchestrating {
9134 if cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
9135 if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
9136 addenda = append(addenda, addendum)
9137 }
9138 }
9139 return addenda
9140 }
9141
9142 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
9143 addenda = append(addenda, addendum)
9144 }
9145 return addenda
9146}
9147
9148// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
9149// have been appended by auto-subagents. Resolve appends owned blocks at the
9150// end, so removing the last occurrence preserves an identical base block.
9151func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
9152 idx := strings.LastIndex(system, addendum)
9153 if idx < 0 {
9154 return system, false
9155 }
9156 return system[:idx] + system[idx+len(addendum):], true
9157}
9158
9159// applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by subagent
9160// prompting on the running agent. Toggling strict orchestration changes the
9161// delegation guidance while retaining the profile manifest when usable.
9162func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
9163 // i.mu is sufficient here: this path updates the existing agent's system
9164 // prompt and managed addenda in place; it does not replace the agent or
9165 // its tool registry, so agentMu is not required.
9166 i.mu.Lock()
9167 defer i.mu.Unlock()
9168 if i.agent == nil {
9169 return
9170 }
9171
9172 sys, _ := i.agent.PromptConfig()
9173 changed := false
9174 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
9175 var removed bool
9176 sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
9177 changed = changed || removed
9178 }
9179 i.managedAutoSubagentsAddenda = nil
9180
9181 addenda := autoSubagentsAddenda(i.cfg, orchestrating)
9182 if changed && len(addenda) > 0 {
9183 sys = strings.TrimRight(sys, "\n")
9184 }
9185 for _, addendum := range addenda {
9186 if sys != "" {
9187 sys += "\n\n"
9188 }
9189 sys += addendum
9190 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
9191 changed = true
9192 }
9193 if !changed {
9194 return
9195 }
9196 if len(addenda) == 0 {
9197 sys = strings.TrimRight(sys, "\n") + "\n"
9198 }
9199 i.agent.SetSystemPrompt(sys)
9200}
9201
9202// applyAutoSubagentsTool registers the canonical subagent tools whenever
9203// launch-time policy permits them. Auto-subagents controls prompt behavior,
9204// while the tools remain available for explicit user-requested delegation.
9205// Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
9206// /reload-ext additions survive a settings change.
9207func (i *Interactive) autoSubagentsAvailable() bool {
9208 return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
9209}
9210
9211func (i *Interactive) autoSubagentsUnavailableHint() string {
9212 var hints []string
9213 if i.cfg.Supervisor == nil {
9214 hints = append(hints, "subagent supervisor not available in this mode")
9215 }
9216 if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
9217 hints = append(hints, "launch-time tool policy excludes subagent manager tools")
9218 }
9219 return strings.Join(hints, "; ")
9220}
9221
9222func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
9223 return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
9224}
9225
9226func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
9227 if cfg.AutoSubagentsStatusToolAllowed != nil {
9228 return *cfg.AutoSubagentsStatusToolAllowed
9229 }
9230 // Preserve the old single-boolean embedding contract: when callers have
9231 // not supplied the new field, status follows the existing delegation gate.
9232 return autoSubagentsToolAllowedConfig(cfg)
9233}
9234
9235func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
9236 if cfg.AutoSubagentsStopToolAllowed != nil {
9237 return *cfg.AutoSubagentsStopToolAllowed
9238 }
9239 return autoSubagentsToolAllowedConfig(cfg)
9240}
9241
9242func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
9243 if cfg.AutoSubagentsResumeToolAllowed != nil {
9244 return *cfg.AutoSubagentsResumeToolAllowed
9245 }
9246 return autoSubagentsToolAllowedConfig(cfg)
9247}
9248
9249func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
9250 return autoSubagentsToolAllowedConfig(cfg) ||
9251 autoSubagentsStatusToolAllowedConfig(cfg) ||
9252 autoSubagentsStopToolAllowedConfig(cfg) ||
9253 autoSubagentsResumeToolAllowedConfig(cfg)
9254}
9255
9256func (i *Interactive) autoSubagentsToolAllowed() bool {
9257 return autoSubagentsToolAllowedConfig(i.cfg)
9258}
9259
9260func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
9261 return autoSubagentsStatusToolAllowedConfig(i.cfg)
9262}
9263
9264func (i *Interactive) autoSubagentsStopToolAllowed() bool {
9265 return autoSubagentsStopToolAllowedConfig(i.cfg)
9266}
9267
9268func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
9269 return autoSubagentsResumeToolAllowedConfig(i.cfg)
9270}
9271
9272func (i *Interactive) autoSubagentsEnabledLocked() bool {
9273 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
9274}
9275
9276func (i *Interactive) applyAutoSubagentsTool() {
9277 i.agentMu.Lock()
9278 defer i.agentMu.Unlock()
9279 i.mu.Lock()
9280 defer i.mu.Unlock()
9281 if i.agent == nil {
9282 return
9283 }
9284 current := i.agent.ToolsSnapshot()
9285 next := core.Registry{}
9286 for name, t := range current {
9287 if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
9288 continue
9289 }
9290 next[name] = t
9291 }
9292 if i.autoSubagentsAvailable() {
9293 if i.autoSubagentsToolAllowed() {
9294 canonical := &tools.SubagentSpawnTool{
9295 Supervisor: i.cfg.Supervisor,
9296 Enabled: func() bool { return true },
9297 DefaultModel: func() string { return i.cfg.Model },
9298 DefaultProvider: func() string { return i.cfg.Provider },
9299 DefaultReasoning: func() string { return i.cfg.Reasoning },
9300 ResolveSubagent: i.cfg.ResolveSubagent,
9301 OnSpawned: func(a *subagents.Agent, task string, required bool) {
9302 i.TrackSubagentWorker(a, task, required)
9303 },
9304 }
9305 next[canonical.Name()] = canonical
9306 }
9307 if i.autoSubagentsStatusToolAllowed() {
9308 statusTool := &tools.SubagentStatusTool{
9309 Supervisor: i.cfg.Supervisor,
9310 Enabled: func() bool { return true },
9311 }
9312 next[statusTool.Name()] = statusTool
9313 }
9314 if i.autoSubagentsStopToolAllowed() {
9315 stopTool := &tools.SubagentStopTool{
9316 Supervisor: i.cfg.Supervisor,
9317 Enabled: func() bool { return true },
9318 OnStopRequested: i.TrackStoppedSubagentWorker,
9319 }
9320 next[stopTool.Name()] = stopTool
9321 }
9322 if i.autoSubagentsResumeToolAllowed() {
9323 resumeTool := &tools.SubagentResumeTool{
9324 Supervisor: i.cfg.Supervisor,
9325 Enabled: func() bool { return true },
9326 BeforeResumed: func(a *subagents.Agent, prompt string, required bool) func() {
9327 return i.PrepareResumedSubagentWorker(a, prompt, required)
9328 },
9329 }
9330 next[resumeTool.Name()] = resumeTool
9331 }
9332 }
9333 i.agent.SetTools(next)
9334}
9335
9336// applyTelegramTools registers (active=true) or removes (active=false)
9337// the telegram_send_image and telegram_send_file tools on the running
9338// agent so the model only sees them while the bridge is connected.
9339// Snapshots and mutates the live tool registry so any extension or
9340// /reload-ext additions made while Telegram is connected survive a
9341// later /telegram disconnect. The two Telegram entries are always replaced;
9342// web_search is additionally stripped only while the external bridge is active.
9343func (i *Interactive) applyTelegramTools(active bool) {
9344 if active {
9345 // The child ceiling must be in place even when no live main agent exists,
9346 // and before Bridge.Start can accept an external prompt.
9347 i.setWebSearchAvailable(false)
9348 }
9349 i.agentMu.Lock()
9350 defer i.agentMu.Unlock()
9351 i.mu.Lock()
9352 defer i.mu.Unlock()
9353 if i.agent == nil {
9354 return
9355 }
9356 current := i.agent.ToolsSnapshot()
9357 next := core.Registry{}
9358 for name, t := range current {
9359 if name == "telegram_send_image" || name == "telegram_send_file" {
9360 continue
9361 }
9362 if active && name == "web_search" {
9363 // External Telegram prompts have no per-request confirmation
9364 // surface, so V1 does not expose native web search while paired.
9365 continue
9366 }
9367 next[name] = t
9368 }
9369 if active {
9370 sender := telegramSenderAdapter{bridge: i.telegramBridge}
9371 next["telegram_send_image"] = &tools.TelegramSendImageTool{
9372 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
9373 }
9374 next["telegram_send_file"] = &tools.TelegramSendFileTool{
9375 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
9376 }
9377 }
9378 i.agent.SetTools(next)
9379 _, webSearchAvailable := next["web_search"]
9380 i.setWebSearchAvailable(webSearchAvailable && !active)
9381}
9382
9383// telegramStatus writes a one-liner describing the bridge state.
9384// Reports on both the in-tui bridge and the background daemon so
9385// the user isn't confused when the daemon owns the poll loop.
9386func (i *Interactive) telegramStatus() {
9387 var msg string
9388 if i.telegramBridge != nil && i.telegramBridge.Active() {
9389 s := i.telegramBridge.State()
9390 msg = "telegram: connected (tui bridge)"
9391 if s.Username != "" {
9392 msg += " as @" + s.Username
9393 }
9394 if s.PairedID != 0 {
9395 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
9396 } else {
9397 msg += " - awaiting pairing"
9398 }
9399 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
9400 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
9401 } else {
9402 cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
9403 if cfg.BotToken == "" {
9404 msg = "telegram: not configured. run `zut telegram-bot setup` first."
9405 } else {
9406 msg = "telegram: disconnected"
9407 if cfg.BotUsername != "" {
9408 msg += " (@" + cfg.BotUsername + " ready to connect)"
9409 }
9410 }
9411 }
9412 i.mu.Lock()
9413 i.statusOK = msg
9414 i.statusErr = ""
9415 i.mu.Unlock()
9416 i.invalidate()
9417}
9418
9419// telegramHost adapts *Interactive to telegram.Host so the bridge
9420// can call back into the TUI without importing modes directly.
9421type telegramHost struct{ iv *Interactive }
9422
9423func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
9424 h.iv.SubmitOrQueue(prompt, images)
9425}
9426
9427func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
9428
9429func (h *telegramHost) Status() string {
9430 h.iv.mu.Lock()
9431 providerName := h.iv.cfg.Provider
9432 model := h.iv.cfg.Model
9433 cwd := h.iv.cfg.CWD
9434 usage := h.iv.cumUsage
9435 subscription := h.iv.cfg.AuthMethod == "oauth"
9436 ctxUsed := h.iv.lastCtxInput
9437 busy := h.iv.busy
9438 queued := len(h.iv.queued)
9439 h.iv.mu.Unlock()
9440
9441 ctxMax := 0
9442 if m, err := provider.FindModel(providerName, model); err == nil {
9443 ctxMax = m.ContextWindow
9444 }
9445 return telegram.FormatStatus(telegram.StatusSnapshot{
9446 Provider: providerName,
9447 Model: model,
9448 CWD: cwd,
9449 Usage: usage,
9450 Subscription: subscription,
9451 ContextUsed: ctxUsed,
9452 ContextMax: ctxMax,
9453 Busy: busy,
9454 Queued: queued,
9455 })
9456}
9457
9458func (h *telegramHost) Notify(level, message string) {
9459 h.iv.mu.Lock()
9460 switch level {
9461 case "error", "warn":
9462 h.iv.statusErr = message
9463 h.iv.statusOK = ""
9464 default:
9465 h.iv.statusOK = message
9466 h.iv.statusErr = ""
9467 }
9468 h.iv.mu.Unlock()
9469 h.iv.invalidate()
9470}
9471
9472// openSessionOpsDialog shows the picker for `/session` with no arg.
9473// Always offers export, import, fork, tree; the handlers bail with
9474// a clear status message when the precondition isn't met (empty
9475// transcript on fork; no parent/siblings on tree).
9476func (i *Interactive) openSessionOpsDialog() {
9477 items := []sessionOpsItem{
9478 {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
9479 {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
9480 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
9481 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
9482 }
9483 i.sessionOpsDialog.Open(items)
9484 i.invalidate()
9485}
9486
9487// doSessionOp dispatches export, import, fork, or tree. arg is the
9488// optional positional argument from e.g. /session export <path>
9489// or /session import <path>; fork and tree ignore it.
9490func (i *Interactive) doSessionOp(action, arg string) {
9491 switch action {
9492 case "export":
9493 i.doSessionExport(arg)
9494 case "import":
9495 i.doSessionImport(arg)
9496 case "fork":
9497 i.doSessionFork()
9498 case "tree":
9499 i.doSessionTree()
9500 default:
9501 i.mu.Lock()
9502 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
9503 i.mu.Unlock()
9504 i.invalidate()
9505 }
9506}
9507
9508// doSessionExport writes the live session file to destination path
9509// dst. When dst is empty we default to ~/Downloads (falling back to
9510// the user's home directory if it doesn't exist). The helper
9511// expands a leading `~` and creates any missing parent directories.
9512func (i *Interactive) doSessionExport(dst string) {
9513 src := i.currentSessionPath()
9514 if src == "" {
9515 i.mu.Lock()
9516 i.statusErr = "export: no session is active (running with --no-session?)"
9517 i.mu.Unlock()
9518 i.invalidate()
9519 return
9520 }
9521 // Persist any in-memory agent messages to the session file so
9522 // the export carries the full conversation. Without this, the
9523 // default lazy-flush-at-exit strategy leaves most of a running
9524 // session unwritten and the export ends up with only the meta.
9525 if i.cfg.FlushSession != nil {
9526 i.cfg.FlushSession()
9527 }
9528 dst = unquotePath(dst)
9529 if dst == "" {
9530 dst = defaultExportDir()
9531 } else {
9532 dst = expandTilde(dst)
9533 }
9534 out, err := core.ExportSession(src, dst)
9535 if err != nil {
9536 i.mu.Lock()
9537 i.statusErr = "export: " + err.Error()
9538 i.mu.Unlock()
9539 i.invalidate()
9540 return
9541 }
9542 i.mu.Lock()
9543 i.statusOK = "exported session to " + friendlyPath(out)
9544 i.statusErr = ""
9545 i.mu.Unlock()
9546 i.invalidate()
9547}
9548
9549// doSessionImport copies the .zutsession file at src into the
9550// running cwd's sessions directory and loads it as the active
9551// session, same as `/sessions` -> pick. When src is empty we ask
9552// the user to pass a path (no usable default here).
9553func (i *Interactive) doSessionImport(src string) {
9554 src = unquotePath(src)
9555 if src == "" {
9556 i.mu.Lock()
9557 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
9558 i.mu.Unlock()
9559 i.invalidate()
9560 return
9561 }
9562 src = expandTilde(src)
9563 if _, err := os.Stat(src); err != nil {
9564 i.mu.Lock()
9565 i.statusErr = "import: " + err.Error()
9566 i.mu.Unlock()
9567 i.invalidate()
9568 return
9569 }
9570 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
9571 if err != nil {
9572 i.mu.Lock()
9573 i.statusErr = "import: " + err.Error()
9574 i.mu.Unlock()
9575 i.invalidate()
9576 return
9577 }
9578 if i.cfg.LoadSession == nil {
9579 i.mu.Lock()
9580 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
9581 i.statusErr = ""
9582 i.mu.Unlock()
9583 i.invalidate()
9584 return
9585 }
9586 i.markSessionTitleSwitching()
9587 if err := i.cfg.LoadSession(newPath); err != nil {
9588 i.restoreFailedSessionTitle()
9589 i.mu.Lock()
9590 i.statusErr = "import: load failed: " + err.Error()
9591 i.mu.Unlock()
9592 i.invalidate()
9593 return
9594 }
9595 i.restoreLoadedSessionTitle()
9596 state := i.restoreCurrentCompactHandoff()
9597 i.mu.Lock()
9598 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
9599 i.statusErr = ""
9600 if i.agent != nil {
9601 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9602 i.cumUsage = i.agent.Cost()
9603 last := i.agent.LastTurnUsage()
9604 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9605 if len(i.view.Messages) > initialResumeTailLimit {
9606 i.view.TailLimit = initialResumeTailLimit
9607 } else {
9608 i.view.TailLimit = 0
9609 }
9610 i.view.InvalidateRenderCache()
9611 }
9612 i.mu.Unlock()
9613 i.invalidate()
9614 if state.reason != compactContinuationNone {
9615 i.startRestoredCompactHandoff(i.runCtx)
9616 }
9617}
9618
9619// defaultExportDir returns ~/Downloads when it exists, or ~ as a
9620// fallback, or /tmp on exotic machines with no home dir.
9621func defaultExportDir() string {
9622 home, err := os.UserHomeDir()
9623 if err != nil || home == "" {
9624 return os.TempDir()
9625 }
9626 downloads := filepath.Join(home, "Downloads")
9627 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
9628 return downloads
9629 }
9630 return home
9631}
9632
9633// expandTilde turns a leading ~ into the user's home directory.
9634// Returns the input unchanged when there's no tilde or no home.
9635func expandTilde(p string) string {
9636 if p == "" || p[0] != '~' {
9637 return p
9638 }
9639 home, err := os.UserHomeDir()
9640 if err != nil || home == "" {
9641 return p
9642 }
9643 if len(p) == 1 {
9644 return home
9645 }
9646 if p[1] == '/' || p[1] == filepath.Separator {
9647 return filepath.Join(home, p[2:])
9648 }
9649 return p
9650}
9651
9652// unquotePath strips a matching pair of surrounding single or
9653// double quotes. Drag-drop paste in the tui auto-quotes dropped
9654// file paths so the shell-like `/session import 'foo bar.zs'`
9655// stays well-formed; when the TUI's own slash handler consumes
9656// the arg, we want the raw path back.
9657func unquotePath(p string) string {
9658 p = strings.TrimSpace(p)
9659 if len(p) >= 2 {
9660 first := p[0]
9661 last := p[len(p)-1]
9662 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
9663 p = p[1 : len(p)-1]
9664 }
9665 }
9666 return p
9667}
9668
9669// friendlyPath collapses the user's home directory to a leading ~
9670// so status messages read cleanly. Falls back to the raw path when
9671// the home dir is unknown.
9672func friendlyPath(p string) string {
9673 home, err := os.UserHomeDir()
9674 if err != nil || home == "" {
9675 return p
9676 }
9677 if strings.HasPrefix(p, home+string(filepath.Separator)) {
9678 return "~" + p[len(home):]
9679 }
9680 return p
9681}
9682
9683// doSessionFork opens the /jump turn picker in "fork mode". The
9684// next selection branches the current session at that user turn
9685// instead of scrolling the viewport to it.
9686func (i *Interactive) doSessionFork() {
9687 path := i.currentSessionPath()
9688 if path == "" {
9689 i.mu.Lock()
9690 i.statusErr = "fork: no session is active (running with --no-session?)"
9691 i.mu.Unlock()
9692 i.invalidate()
9693 return
9694 }
9695 i.mu.Lock()
9696 busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
9697 queued := len(i.queued) != 0
9698 ag := i.agent
9699 i.mu.Unlock()
9700 if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
9701 i.mu.Lock()
9702 i.statusErr = "fork: wait until the current turn and queued messages finish"
9703 i.mu.Unlock()
9704 i.invalidate()
9705 return
9706 }
9707 if i.cfg.FlushSession != nil {
9708 i.cfg.FlushSession()
9709 }
9710 snapshot, err := core.ReadSessionSnapshot(path)
9711 if err != nil {
9712 i.mu.Lock()
9713 i.statusErr = "fork: " + err.Error()
9714 i.mu.Unlock()
9715 i.invalidate()
9716 return
9717 }
9718 msgs := snapshot.Messages
9719 if len(msgs) == 0 {
9720 i.mu.Lock()
9721 i.statusErr = "fork: transcript is empty; nothing to fork from"
9722 i.mu.Unlock()
9723 i.invalidate()
9724 return
9725 }
9726 i.pendingFork = true
9727 i.jumpDialog.Open(msgs, "")
9728 i.invalidate()
9729}
9730
9731// doSessionTree opens the current session family after the shared
9732// fail-closed gate succeeds. It intentionally does not fall back to the
9733// in-memory transcript: a tree that cannot be persisted and reloaded cannot
9734// safely create a navigation branch.
9735func (i *Interactive) doSessionTree() {
9736 i.openSessionTree()
9737}
9738
9739// applySessionTreeMessageSelection keeps the old scalar integration
9740// source-compatible for package consumers. New dialog selections use the
9741// structured sessionTreeTarget path below.
9742func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
9743 target := sessionTreeTarget{
9744 SourcePath: src,
9745 EffectiveIndex: msgIdx,
9746 SelectionBoundary: msgIdx + 1,
9747 Role: role,
9748 UserDraft: prompt,
9749 Boundary: sessionTreeMessageBoundary,
9750 }
9751 if role == provider.RoleUser {
9752 target.SelectionBoundary = msgIdx
9753 }
9754 i.applySessionTreeTarget(target, turnNo)
9755}
9756
9757// applySessionTreeTarget checks out a structured dialog target. The target's
9758// source and effective indices come from the same preflight snapshot the
9759// dialog rendered, while the live read below preserves compatibility with
9760// the existing LoadSession(path) error callback.
9761func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
9762 // Selection is a state-changing operation too. Use the selection gate so
9763 // a queued/busy turn cannot race the hidden branch or accidentally submit
9764 // a provider turn while the session is being swapped. The open gate cannot
9765 // be reused here because the tree dialog itself owns the keyboard.
9766 if !i.canCommitSessionTreeSelection() {
9767 i.setSessionTreeError("tree: session branching is not available in this build")
9768 return
9769 }
9770 src := target.SourcePath
9771 if src == "" {
9772 src = i.currentSessionPath()
9773 }
9774 if src == "" {
9775 i.setSessionTreeError("tree: no session is active")
9776 return
9777 }
9778
9779 // Read and validate the selected row before flushing or creating anything.
9780 // This gives tool-call/result pairs an atomic boundary and prevents a
9781 // malformed selection from changing the active session. Historical rows
9782 // come from a pre-compaction segment; current rows use the effective
9783 // snapshot that the running agent resumes from.
9784 var msgs []provider.Message
9785 var historicalSegment core.SessionHistorySegment
9786 if target.Historical {
9787 history, err := core.ReadSessionHistory(src)
9788 if err != nil {
9789 i.setSessionTreeError("tree: read selection: " + err.Error())
9790 return
9791 }
9792 if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
9793 i.setSessionTreeError("tree: selected history segment is unavailable")
9794 return
9795 }
9796 historicalSegment = history.Segments[target.HistorySegment]
9797 msgs = historicalSegment.Messages
9798 } else {
9799 sess, current, err := core.OpenSession(src)
9800 if err != nil {
9801 i.setSessionTreeError("tree: read selection: " + err.Error())
9802 return
9803 }
9804 if err := sess.Close(); err != nil {
9805 i.setSessionTreeError("tree: close selection: " + err.Error())
9806 return
9807 }
9808 msgs = current
9809 }
9810 selection, err := sessionTreeSelection(msgs, target)
9811 if err != nil {
9812 i.setSessionTreeError("tree: " + err.Error())
9813 return
9814 }
9815 if i.cfg.FlushSession != nil {
9816 i.cfg.FlushSession()
9817 }
9818 var newPath string
9819 if target.Historical {
9820 newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
9821 } else {
9822 newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
9823 }
9824 if err != nil {
9825 i.setSessionTreeError("tree: " + err.Error())
9826 return
9827 }
9828 i.markSessionTitleSwitching()
9829 if err := i.cfg.LoadSession(newPath); err != nil {
9830 i.restoreFailedSessionTitle()
9831 i.setSessionTreeError("tree: checkout failed: " + err.Error())
9832 return
9833 }
9834 state := i.restoreCurrentCompactHandoff()
9835
9836 // LoadSession is intentionally the old func(string) error callback: the
9837 // CLI and embedders already own agent/session swapping. Refresh the view
9838 // from the swapped agent here instead of requiring a new callback result.
9839 i.resetSessionTitleForFreshBranch()
9840 i.scrollToBottom()
9841 i.mu.Lock()
9842 i.lastCtxInput = 0
9843 if i.agent != nil {
9844 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9845 i.cumUsage = i.agent.Cost()
9846 last := i.agent.LastTurnUsage()
9847 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9848 i.view.InvalidateRenderCache()
9849 }
9850 i.toolCalls = map[string]*tui.ToolCallView{}
9851 i.toolOrder = nil
9852 i.toolGate = map[string]int{}
9853 i.extNotes = nil
9854 if selection.restoreDraft {
9855 i.ed.SetValue(selection.draftText)
9856 i.clipboardImages = selection.images
9857 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
9858 } else {
9859 i.ed.Clear()
9860 i.clipboardImages = nil
9861 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
9862 }
9863 i.statusErr = ""
9864 i.mu.Unlock()
9865 i.scrollToBottom()
9866 // Selection does not submit, start, or queue the restored user draft; only
9867 // a later explicit Enter does that. An inherited compact handoff may resume
9868 // its hidden continuation below when this branch has the complete effective
9869 // transcript, without submitting the restored draft.
9870 i.invalidate()
9871 if state.reason != compactContinuationNone {
9872 i.startRestoredCompactHandoff(i.runCtx)
9873 }
9874}
9875
9876// applyForkSelection branches the current session at msgIdx+1 (so
9877// the selected user message and everything before it is included
9878// in the new branch), then switches the running agent to the new
9879// file. Called from the jump-dialog handler when pendingFork=true.
9880func (i *Interactive) applyForkSelection(msgIdx int) {
9881 i.pendingFork = false
9882 src := i.currentSessionPath()
9883 if src == "" {
9884 i.mu.Lock()
9885 i.statusErr = "fork: no session is active"
9886 i.mu.Unlock()
9887 i.invalidate()
9888 return
9889 }
9890 if i.cfg.FlushSession != nil {
9891 i.cfg.FlushSession()
9892 }
9893 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
9894 // the selected user message is included.
9895 upTo := msgIdx + 1
9896 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
9897 if err != nil {
9898 i.mu.Lock()
9899 i.statusErr = "fork: " + err.Error()
9900 i.mu.Unlock()
9901 i.invalidate()
9902 return
9903 }
9904 if i.cfg.LoadSession == nil {
9905 i.mu.Lock()
9906 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
9907 i.statusErr = ""
9908 i.mu.Unlock()
9909 i.invalidate()
9910 return
9911 }
9912 i.markSessionTitleSwitching()
9913 if err := i.cfg.LoadSession(newPath); err != nil {
9914 i.restoreFailedSessionTitle()
9915 i.mu.Lock()
9916 i.statusErr = "fork: switch failed: " + err.Error()
9917 i.mu.Unlock()
9918 i.invalidate()
9919 return
9920 }
9921 state := i.restoreCurrentCompactHandoff()
9922 i.resetSessionTitleForFreshBranch()
9923 i.mu.Lock()
9924 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
9925 i.statusErr = ""
9926 i.mu.Unlock()
9927 i.invalidate()
9928 if state.reason != compactContinuationNone {
9929 i.startRestoredCompactHandoff(i.runCtx)
9930 }
9931}
9932
9933// formatInt is a tiny strconv.Itoa shim; keeps the handler above
9934// from needing a strconv import just for one call.
9935func formatInt(n int) string {
9936 return fmt.Sprintf("%d", n)
9937}
9938
9939// assistantText returns the concatenated text of every TextBlock in
9940// m. Used by the streaming-view dedupe guard to tell when a live
9941// streamed reply has already been promoted into the transcript.
9942func assistantText(m provider.Message) string {
9943 var sb strings.Builder
9944 for _, c := range m.Content {
9945 if tb, ok := c.(provider.TextBlock); ok {
9946 if sb.Len() > 0 {
9947 sb.WriteByte('\n')
9948 }
9949 sb.WriteString(tb.Text)
9950 }
9951 }
9952 return sb.String()
9953}
9954
9955// resetTranscriptRenderLocked invalidates every render cache that assumes
9956// the previous transcript remains structurally intact. Compaction is a
9957// replacement, not an append: the flow renderer must repaint from the new
9958// transcript rather than diff it against scrollback rows from the old one.
9959// Must be called with i.mu held.
9960func (i *Interactive) resetTranscriptRenderLocked() {
9961 i.view.InvalidateRenderCache()
9962 i.chatCacheValid = false
9963 i.prevChatLen = 0
9964 i.prevChatCols = 0
9965 i.prevChatRows = 0
9966 i.prevScrollOffset = 0
9967 i.requestRendererInvalidate()
9968}
9969
9970// resetStreamingStateLocked clears every piece of streaming state
9971// in one shot. Used by abort paths (turn cancel, compact hand-off,
9972// queue drain) so the pacer doesn't keep draining stale runes from
9973// a prior turn. Must be called with i.mu held.
9974func (i *Interactive) resetStreamingStateLocked() {
9975 i.streaming.Reset()
9976 i.streamPending = i.streamPending[:0]
9977 i.streamFlushPending = false
9978 i.streamOn = false
9979 i.openAllToolGatesLocked()
9980}
9981
9982// openAllToolGatesLocked drops every pending tool gate so that any
9983// tool registered during this turn renders unconditionally from now
9984// on. Called when streaming finalizes (the paced text has fully
9985// drained and `streaming` is about to reset to length 0): without
9986// this, the gate comparison against a freshly-reset streaming buffer
9987// would wrongly re-hide tools that had already cleared their gate.
9988// Must be called with i.mu held.
9989func (i *Interactive) openAllToolGatesLocked() {
9990 for id := range i.toolGate {
9991 i.toolGate[id] = 0
9992 }
9993}
9994
9995// gateToolLocked records the stream position at which a tool call may
9996// become visible. The gate is the total length the streaming buffer
9997// will reach once the pacer has drained everything currently queued
9998// (already painted + still pending). Holding the tool block back
9999// until the pacer crosses that mark guarantees the prose emitted
10000// before the tool call finishes typing out above it, instead of the
10001// tool block snapping in while the paragraph is still filling in.
10002//
10003// We only gate while text is actively streaming. If no stream is in
10004// flight (gate 0), the tool shows immediately, which is the correct
10005// behaviour for tool-only turns and replayed sessions. First
10006// registration wins so a later EvToolCall can't move an existing
10007// gate. Must be called with i.mu held.
10008func (i *Interactive) gateToolLocked(id string) {
10009 if _, ok := i.toolGate[id]; ok {
10010 return
10011 }
10012 if !i.streamOn {
10013 i.toolGate[id] = 0
10014 return
10015 }
10016 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
10017}
10018
10019// toolGateOpenLocked reports whether the gated tool block may render
10020// yet, i.e. the pacer has drained enough text to reach the position
10021// recorded when the tool call arrived. Must be called with i.mu held.
10022func (i *Interactive) toolGateOpenLocked(id string) bool {
10023 gate, ok := i.toolGate[id]
10024 if !ok || gate == 0 {
10025 return true
10026 }
10027 return i.streaming.Len() >= gate
10028}
10029
10030// assistantMessageSideEffects runs the non-visual hooks attached to
10031// EvAssistantMessage: the host-provided OnAssistant callback and the
10032// telegram-bridge mirror. Called with i.mu held.
10033//
10034// Factored out of handleEvent because the streaming pacer may defer
10035// visual reset until after the last buffered rune has painted, but
10036// the callbacks themselves must fire on message arrival so
10037// downstream observers (session persistence, telegram, cost panels)
10038// don't wait on a UI animation to catch up.
10039func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
10040 if i.cfg.OnAssistant != nil {
10041 i.cfg.OnAssistant(m)
10042 }
10043 if i.telegramBridge != nil && i.telegramBridge.Active() {
10044 var sb strings.Builder
10045 for _, c := range m.Content {
10046 if tb, ok := c.(provider.TextBlock); ok {
10047 if sb.Len() > 0 {
10048 sb.WriteString("\n")
10049 }
10050 sb.WriteString(tb.Text)
10051 }
10052 }
10053 if text := sb.String(); strings.TrimSpace(text) != "" {
10054 go i.telegramBridge.OnAssistantText(text)
10055 }
10056 }
10057}
10058
10059// paintPaceRate is how many runes the streaming pacer releases per
10060// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
10061// that a 500-rune summary finishes in ~1.3s, slow enough to look
10062// like a human typing. Empirically matches the feel of provider
10063// paths that already drip-stream natively.
10064const paintPaceRate = 6
10065
10066// paintPaceInterval is the tick interval for the streaming pacer.
10067// 16ms lines up with the redraw throttle so we never paint faster
10068// than the terminal can keep up.
10069const paintPaceInterval = 16 * time.Millisecond
10070
10071// runStreamPacer drains buffered deltas from streamPending into
10072// streaming a small batch per tick, invalidating after each move.
10073// It stops when the context cancels (tui shutdown).
10074//
10075// Why a pacer: providers differ wildly in how they chunk their
10076// text_delta events. The API-key path on Anthropic emits ~30 drips
10077// for a 400-token summary; the OAuth path can coalesce the same
10078// summary into 3 fat chunks, visually indistinguishable from "the
10079// whole reply just appeared". The pacer normalizes that so every
10080// path looks the same on screen.
10081func (i *Interactive) runStreamPacer(ctx context.Context) {
10082 t := time.NewTicker(paintPaceInterval)
10083 defer t.Stop()
10084 for {
10085 select {
10086 case <-ctx.Done():
10087 return
10088 case <-t.C:
10089 i.mu.Lock()
10090 if len(i.streamPending) == 0 {
10091 // EvAssistantMessage already fired but the pacer
10092 // was still draining a tick ago. Everything is now
10093 // painted; clear the streaming flags so the next
10094 // redraw shows the finalised transcript message
10095 // and hides the streaming overlay.
10096 if i.streamFlushPending {
10097 i.streamFlushPending = false
10098 i.streaming.Reset()
10099 i.streamOn = false
10100 i.openAllToolGatesLocked()
10101 i.mu.Unlock()
10102 i.invalidate()
10103 continue
10104 }
10105 i.mu.Unlock()
10106 continue
10107 }
10108 n := paintPaceRate
10109 if n > len(i.streamPending) {
10110 n = len(i.streamPending)
10111 }
10112 i.streaming.WriteString(string(i.streamPending[:n]))
10113 i.streamPending = i.streamPending[n:]
10114 i.mu.Unlock()
10115 i.invalidate()
10116 }
10117 }
10118}