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