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