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 {
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 i.mu.Lock()
5119 previousFlag := i.cfg.AutoSubagentsEnabled
5120 previous := previousFlag != nil && *previousFlag
5121 val := value
5122 i.cfg.AutoSubagentsEnabled = &val
5123 i.mu.Unlock()
5124 if i.cfg.SettingsStore != nil {
5125 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
5126 i.mu.Lock()
5127 i.cfg.AutoSubagentsEnabled = previousFlag
5128 i.statusOK = ""
5129 i.statusErr = "settings: " + err.Error()
5130 i.mu.Unlock()
5131 i.resetSettingsToggle(key, previous)
5132 return
5133 }
5134 }
5135 // Add/remove the auto-subagent tools on the live agent so the
5136 // model's tools[] list reflects the toggle on the next turn.
5137 // Without this the tools stay advertised after a disable and
5138 // the model keeps trying to call them.
5139 i.applyAutoSubagentsTool(value)
5140 // Also swap the system-prompt addendum in/out so the model
5141 // knows to use the tools proactively (or stops referencing them
5142 // after a disable).
5143 i.applyAutoSubagentsSystemPrompt(value)
5144 i.mu.Lock()
5145 i.statusOK = "auto-subagents " + onOff(value)
5146 i.statusErr = ""
5147 i.mu.Unlock()
5148 case "fast_mode":
5149 previous := i.cfg.FastMode != nil && *i.cfg.FastMode
5150 if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
5151 i.resetSettingsToggle(key, previous)
5152 i.mu.Lock()
5153 i.statusErr = "settings: " + err.Error()
5154 i.mu.Unlock()
5155 return
5156 }
5157 if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
5158 if err := store.SetFastMode(value); err != nil {
5159 i.resetSettingsToggle(key, previous)
5160 i.mu.Lock()
5161 i.statusErr = "settings: " + err.Error()
5162 i.mu.Unlock()
5163 return
5164 }
5165 }
5166 val := value
5167 i.cfg.FastMode = &val
5168 if i.agent != nil {
5169 i.agent.SetFastMode(value)
5170 }
5171 if i.cfg.Supervisor != nil {
5172 i.cfg.Supervisor.SetFastMode(value)
5173 }
5174 i.mu.Lock()
5175 i.statusOK = "fast mode " + onOff(value)
5176 i.statusErr = ""
5177 i.mu.Unlock()
5178 case "lsp_enabled":
5179 previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
5180 store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
5181 if hasStore {
5182 if err := store.SetLSPEnabled(value); err != nil {
5183 i.mu.Lock()
5184 i.statusErr = "settings: " + err.Error()
5185 i.mu.Unlock()
5186 return
5187 }
5188 }
5189 val := value
5190 i.cfg.LSPEnabled = &val
5191 if i.cfg.RefreshTools != nil {
5192 if err := i.cfg.RefreshTools(); err != nil {
5193 errMsg := "settings: refresh tools: " + err.Error()
5194 rollbackErr := error(nil)
5195 if hasStore {
5196 rollbackErr = store.SetLSPEnabled(previous)
5197 }
5198 if rollbackErr != nil {
5199 // The first write succeeded, so keep the in-memory setting at
5200 // the value that remains durable instead of claiming that the
5201 // rollback took effect.
5202 errMsg += "; rollback persistence: " + rollbackErr.Error()
5203 if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
5204 errMsg += "; durable-state refresh: " + reconcileErr.Error()
5205 }
5206 } else {
5207 previousVal := previous
5208 i.cfg.LSPEnabled = &previousVal
5209 if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
5210 errMsg += "; rollback refresh: " + refreshErr.Error()
5211 }
5212 }
5213 i.mu.Lock()
5214 i.statusOK = ""
5215 i.statusErr = errMsg
5216 i.mu.Unlock()
5217 return
5218 }
5219 }
5220 i.mu.Lock()
5221 i.statusOK = "lsp in main session " + onOff(value)
5222 i.statusErr = ""
5223 i.mu.Unlock()
5224 case "subagent_lsp_enabled":
5225 if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
5226 if err := store.SetSubagentLSPEnabled(value); err != nil {
5227 i.mu.Lock()
5228 i.statusErr = "settings: " + err.Error()
5229 i.mu.Unlock()
5230 return
5231 }
5232 }
5233 val := value
5234 i.cfg.SubagentLSPEnabled = &val
5235 i.mu.Lock()
5236 i.statusOK = "lsp in sub-agents " + onOff(value)
5237 i.statusErr = ""
5238 i.mu.Unlock()
5239 case "jail_by_default":
5240 val := value
5241 i.cfg.JailByDefault = &val
5242 if i.cfg.SettingsStore != nil {
5243 if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
5244 i.mu.Lock()
5245 i.statusErr = "settings: " + err.Error()
5246 i.mu.Unlock()
5247 return
5248 }
5249 }
5250 if i.cfg.Sandbox != nil {
5251 if value {
5252 i.cfg.Sandbox.Lock()
5253 } else {
5254 i.cfg.Sandbox.Unlock()
5255 }
5256 }
5257 i.mu.Lock()
5258 i.statusOK = "jail by default " + onOff(value)
5259 i.statusErr = ""
5260 i.mu.Unlock()
5261 case "recursive_file_suggest":
5262 val := value
5263 i.cfg.RecursiveFileSuggest = &val
5264 if i.cfg.SettingsStore != nil {
5265 if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
5266 i.mu.Lock()
5267 i.statusErr = "settings: " + err.Error()
5268 i.mu.Unlock()
5269 return
5270 }
5271 }
5272 // Flip the live picker so the next @ reflects the new mode
5273 // without restarting zut. SetRecursive drops its cache.
5274 i.fileSuggest.SetRecursive(value)
5275 i.mu.Lock()
5276 i.statusOK = "recursive @-file search " + onOff(value)
5277 i.statusErr = ""
5278 i.mu.Unlock()
5279 case "respect_gitignore":
5280 val := value
5281 i.cfg.RespectGitignore = &val
5282 if i.cfg.SettingsStore != nil {
5283 if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
5284 i.mu.Lock()
5285 i.statusErr = "settings: " + err.Error()
5286 i.mu.Unlock()
5287 return
5288 }
5289 }
5290 i.fileSuggest.SetRespectGitignore(value)
5291 i.mu.Lock()
5292 i.statusOK = "hide gitignored files in @-picker " + onOff(value)
5293 i.statusErr = ""
5294 i.mu.Unlock()
5295 case "compact_mode":
5296 val := value
5297 i.cfg.CompactMode = &val
5298 if i.cfg.SettingsStore != nil {
5299 if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
5300 i.mu.Lock()
5301 i.statusErr = "settings: " + err.Error()
5302 i.mu.Unlock()
5303 return
5304 }
5305 }
5306 i.mu.Lock()
5307 i.view.CompactMode = value
5308 i.view.InvalidateRenderCache()
5309 i.statusOK = "compact transcript rendering " + onOff(value)
5310 i.statusErr = ""
5311 i.mu.Unlock()
5312 case "show_instructions_at_startup":
5313 val := value
5314 i.cfg.ShowInstructionsAtStartup = &val
5315 if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
5316 if err := store.SetShowInstructionsAtStartup(value); err != nil {
5317 i.mu.Lock()
5318 i.statusErr = "settings: " + err.Error()
5319 i.mu.Unlock()
5320 return
5321 }
5322 }
5323 i.mu.Lock()
5324 i.view.StartupAgentName = ""
5325 i.view.StartupContextPaths = nil
5326 i.view.StartupExtensionNames = nil
5327 i.view.StartupSkillNames = nil
5328 if value {
5329 i.view.StartupAgentName = i.cfg.StartupAgentName
5330 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
5331 i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
5332 i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
5333 }
5334 i.statusOK = "show loaded resources at startup " + onOff(value)
5335 i.statusErr = ""
5336 i.mu.Unlock()
5337 }
5338}
5339
5340func (i *Interactive) applyTUIInputStyleSetting(style string) {
5341 defer func() {
5342 if i.rend != nil {
5343 i.rend.Clear()
5344 }
5345 i.invalidate()
5346 }()
5347 style = tui.NormalizeInputStyle(style)
5348 i.cfg.TUIInputStyle = style
5349 i.applyInputCursorColor()
5350 if i.cfg.SettingsStore != nil {
5351 if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
5352 i.mu.Lock()
5353 i.statusErr = "settings: " + err.Error()
5354 i.mu.Unlock()
5355 return
5356 }
5357 }
5358 i.mu.Lock()
5359 i.statusOK = "input style " + style
5360 i.statusErr = ""
5361 i.mu.Unlock()
5362}
5363
5364func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5365 defer func() {
5366 if i.rend != nil {
5367 i.rend.Clear()
5368 }
5369 i.invalidate()
5370 }()
5371 position = tui.NormalizeStatusPosition(position)
5372 i.cfg.TUIStatusPosition = position
5373 if i.cfg.SettingsStore != nil {
5374 if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
5375 i.mu.Lock()
5376 i.statusErr = "settings: " + err.Error()
5377 i.mu.Unlock()
5378 return
5379 }
5380 }
5381 i.mu.Lock()
5382 label := "above input"
5383 if position == tui.StatusPositionBelowInput {
5384 label = "below input"
5385 }
5386 i.statusOK = "status position " + label
5387 i.statusErr = ""
5388 i.mu.Unlock()
5389}
5390
5391func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5392 defer func() {
5393 if i.rend != nil {
5394 i.rend.Clear()
5395 }
5396 i.invalidate()
5397 }()
5398 position = tui.NormalizeWorkingPosition(position)
5399 i.cfg.TUIWorkingPosition = position
5400 if i.cfg.SettingsStore != nil {
5401 if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
5402 i.mu.Lock()
5403 i.statusErr = "settings: " + err.Error()
5404 i.mu.Unlock()
5405 return
5406 }
5407 }
5408 i.mu.Lock()
5409 label := "above input"
5410 if position == tui.WorkingPositionBelowInput {
5411 label = "below input"
5412 }
5413 i.statusOK = "working spinner position " + label
5414 i.statusErr = ""
5415 i.mu.Unlock()
5416}
5417
5418func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5419 defer func() {
5420 if i.rend != nil {
5421 i.rend.Clear()
5422 }
5423 i.invalidate()
5424 }()
5425 position = tui.NormalizeSubagentPosition(position)
5426 i.cfg.TUISubagentPosition = position
5427 if i.cfg.SettingsStore != nil {
5428 if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5429 i.mu.Lock()
5430 i.statusErr = "settings: " + err.Error()
5431 i.mu.Unlock()
5432 return
5433 }
5434 }
5435 i.mu.Lock()
5436 label := "below input"
5437 if position == tui.SubagentPositionAboveInput {
5438 label = "above input"
5439 }
5440 i.statusOK = "running subagent position " + label
5441 i.statusErr = ""
5442 i.mu.Unlock()
5443}
5444
5445func (i *Interactive) applyThemeSetting(name string) {
5446 if i.cfg.SettingsStore != nil {
5447 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
5448 i.mu.Lock()
5449 i.statusErr = "settings: " + err.Error()
5450 i.mu.Unlock()
5451 return
5452 }
5453 }
5454 i.cfg.ThemeName = name
5455 if name == "auto" {
5456 i.cfg.ThemeName = ""
5457 }
5458 i.applyThemeNow(name)
5459}
5460
5461func (i *Interactive) applyThemeNow(name string) {
5462 if name == "" {
5463 name = "auto"
5464 }
5465 detected := tui.Dark
5466 if tui.IsLightTheme(i.cfg.Theme) {
5467 detected = tui.Light
5468 }
5469 // Keep the startup terminal snapshot available when a user switches
5470 // themes live; inherited mode must not issue OSC queries while the TUI
5471 // already owns raw stdin.
5472 detected.Terminal = i.cfg.Theme.Terminal
5473 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
5474 if err != nil {
5475 if i.cfg.SettingsStore != nil {
5476 _ = i.cfg.SettingsStore.SetTheme("auto")
5477 }
5478 i.cfg.ThemeName = ""
5479 th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
5480 i.mu.Lock()
5481 i.statusErr = "theme missing; reset to default"
5482 i.mu.Unlock()
5483 } else {
5484 i.mu.Lock()
5485 label := applied
5486 if label == "" {
5487 label = "auto"
5488 }
5489 i.statusOK = "theme " + label
5490 i.statusErr = ""
5491 i.mu.Unlock()
5492 }
5493 i.cfg.Theme = th
5494 i.view.Theme = th
5495 i.view.InvalidateRenderCache()
5496 i.ed.Prompt = th.AccentBar(th.Accent)
5497 i.applyInputCursorColor()
5498 i.spin.Configure(th)
5499 if i.rend != nil {
5500 i.rend.SetTheme(th)
5501 i.rend.Clear()
5502 }
5503 i.invalidate()
5504}
5505
5506func (i *Interactive) applyReasoningSetting(level string) {
5507 defer func() {
5508 if i.rend != nil {
5509 i.rend.Clear()
5510 }
5511 i.invalidate()
5512 }()
5513 level = provider.NormalizeReasoning(level)
5514 i.cfg.Reasoning = level
5515 if i.cfg.SettingsStore != nil {
5516 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
5517 i.mu.Lock()
5518 i.statusErr = "settings: " + err.Error()
5519 i.mu.Unlock()
5520 return
5521 }
5522 }
5523 i.mu.Lock()
5524 if i.agent != nil {
5525 i.agent.Reasoning = level
5526 }
5527 label := level
5528 if label == "" {
5529 label = "off"
5530 }
5531 i.statusOK = "reasoning level " + label
5532 i.statusErr = ""
5533 i.mu.Unlock()
5534}
5535
5536// buildStudyPrompt returns the canned prompt the /study command
5537// submits to the agent.
5538//
5539// With no argument, /study targets the current directory — the
5540// historical behaviour. With an argument, /study targets that path
5541// instead; either a directory ("read every file in here") or a
5542// single file ("read this file"). The argument can be:
5543//
5544// - a relative path (resolved against cwd)
5545// - an absolute path
5546// - an @-picker chip, which has already been expanded to an
5547// absolute path by expandFileChips before runSlash sees it
5548//
5549// The path is stat'd to pick the right wording ("directory" vs
5550// "file"). If the path doesn't exist, we still build a sensible
5551// prompt rather than erroring — the agent will surface the
5552// missing-file failure itself when it tries to read it, which is
5553// more useful than a refusal here.
5554func buildStudyPrompt(arg, cwd string) string {
5555 arg = strings.TrimSpace(arg)
5556 if arg == "" {
5557 return "Read and understand everything in the current directory."
5558 }
5559 abs := arg
5560 if !filepath.IsAbs(abs) {
5561 abs = filepath.Join(cwd, abs)
5562 }
5563 display := arg
5564 if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
5565 display = rel
5566 }
5567 if info, err := os.Stat(abs); err == nil && !info.IsDir() {
5568 return "Read and understand the file " + display + "."
5569 }
5570 return "Read and understand everything in the directory " + display + "."
5571}
5572
5573// tryPathTabCompleteEditor looks at ed's current value, finds the
5574// path-like token immediately before the cursor (the cursor is always
5575// at the end of the buffer after a keystroke, so "before the cursor"
5576// is the trailing non-whitespace run), and rewrites it to its shell-
5577// style completion against the filesystem.
5578//
5579// Returns true when it consumed the Tab keystroke (token recognised,
5580// completion attempted — even if no candidates matched, the keystroke
5581// is still consumed so it doesn't insert a literal tab character).
5582// Returns false when the token doesn't look like a path; callers then
5583// let Tab fall through to its normal no-op.
5584//
5585// Recognised path shapes:
5586// - ~ or ~/foo expanded via os.UserHomeDir()
5587// - /abs/path or /abs/path/foo absolute
5588// - ./foo, ../foo, foo/bar relative to cwd
5589//
5590// A bare word like "hello" is not treated as a path so plain text
5591// keeps Tab as a literal no-op.
5592//
5593// Free function (not a method) so the same logic runs against the
5594// editor instances owned by btwDialog and subagentsDialog without each
5595// dialog needing its own copy.
5596func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
5597 if ed == nil {
5598 return false
5599 }
5600 val := ed.Value()
5601 // Find the trailing run of non-whitespace.
5602 start := len(val)
5603 for start > 0 {
5604 r := val[start-1]
5605 if r == ' ' || r == '\t' || r == '\n' {
5606 break
5607 }
5608 start--
5609 }
5610 token := val[start:]
5611 if token == "" {
5612 return false
5613 }
5614 if !looksLikePathToken(token) {
5615 return false
5616 }
5617
5618 // Resolve the absolute parent directory + base prefix to match.
5619 parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
5620 if !ok {
5621 return true
5622 }
5623 entries, err := os.ReadDir(parentAbs)
5624 if err != nil {
5625 return true
5626 }
5627 var names []string
5628 var isDir []bool
5629 for _, e := range entries {
5630 name := e.Name()
5631 if !strings.HasPrefix(name, basePrefix) {
5632 continue
5633 }
5634 // Hide dotfiles unless the user explicitly typed a leading dot,
5635 // mirroring bash's default behaviour.
5636 if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
5637 continue
5638 }
5639 names = append(names, name)
5640 isDir = append(isDir, e.IsDir())
5641 }
5642 if len(names) == 0 {
5643 return true
5644 }
5645
5646 var completed string
5647 var completedIsDir bool
5648 if len(names) == 1 {
5649 completed = names[0]
5650 completedIsDir = isDir[0]
5651 } else {
5652 completed = longestCommonPrefix(names)
5653 if completed == basePrefix {
5654 // Already at the deepest unambiguous prefix; nothing to add.
5655 return true
5656 }
5657 }
5658
5659 // Build the replacement token in the same display form the user
5660 // typed (preserve ~ vs absolute vs relative).
5661 newToken := displayParent + completed
5662 if len(names) == 1 && completedIsDir {
5663 newToken += "/"
5664 }
5665
5666 ed.SetValue(val[:start] + newToken)
5667 return true
5668}
5669
5670// tryPathTabComplete is the Interactive-bound convenience wrapper.
5671// It calls the free helper against the main editor and invalidates
5672// the frame on a successful rewrite.
5673func (i *Interactive) tryPathTabComplete() bool {
5674 if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
5675 i.invalidate()
5676 return true
5677 }
5678 return false
5679}
5680
5681// looksLikePathToken reports whether tok is shaped like a filesystem
5682// path. Paths must either start with ~, /, ./, ../ or contain a /.
5683// Plain words are excluded so Tab on "hello" stays a no-op.
5684func looksLikePathToken(tok string) bool {
5685 if tok == "" {
5686 return false
5687 }
5688 if tok[0] == '~' || tok[0] == '/' {
5689 return true
5690 }
5691 if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
5692 return true
5693 }
5694 return strings.Contains(tok, "/")
5695}
5696
5697// resolvePathTabToken splits tok into (absolute parent dir, basename
5698// prefix to match, display-form parent the user typed). ok is false
5699// when the parent dir can't be resolved (e.g. ~ with no $HOME).
5700func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
5701 // Detect ~ expansion.
5702 expanded := tok
5703 homePrefix := ""
5704 if tok == "~" {
5705 home, err := os.UserHomeDir()
5706 if err != nil || home == "" {
5707 return "", "", "", false
5708 }
5709 // "~" alone: complete in $HOME. parent = home, base = "".
5710 return home, "", "~/", true
5711 }
5712 if strings.HasPrefix(tok, "~/") {
5713 home, err := os.UserHomeDir()
5714 if err != nil || home == "" {
5715 return "", "", "", false
5716 }
5717 expanded = home + tok[1:]
5718 homePrefix = "~"
5719 }
5720
5721 dir, base := splitDirBase(expanded)
5722 if !filepath.IsAbs(dir) {
5723 dir = filepath.Join(cwd, dir)
5724 }
5725
5726 // Reconstruct the display form the user typed for the parent,
5727 // keeping ~ when they used it. The base is dropped — the caller
5728 // substitutes the completed name.
5729 display := tok[:len(tok)-len(base)]
5730 if homePrefix != "" && !strings.HasPrefix(display, "~") {
5731 display = homePrefix + display[len(homePrefix):]
5732 }
5733 return dir, base, display, true
5734}
5735
5736// splitDirBase is like filepath.Split but preserves the trailing
5737// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
5738// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
5739// the trailing separator when non-empty so callers can rebuild paths
5740// by concatenation.
5741func splitDirBase(p string) (dir, base string) {
5742 if p == "" {
5743 return ".", ""
5744 }
5745 i := strings.LastIndex(p, "/")
5746 if i < 0 {
5747 return ".", p
5748 }
5749 return p[:i+1], p[i+1:]
5750}
5751
5752func longestCommonPrefix(ss []string) string {
5753 if len(ss) == 0 {
5754 return ""
5755 }
5756 prefix := ss[0]
5757 for _, s := range ss[1:] {
5758 n := 0
5759 for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
5760 n++
5761 }
5762 prefix = prefix[:n]
5763 if prefix == "" {
5764 return ""
5765 }
5766 }
5767 return prefix
5768}
5769
5770func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5771 parts := strings.Fields(cmd)
5772 if len(parts) == 0 {
5773 return false
5774 }
5775
5776 if isSkillCommand(cmd) {
5777 var available []*skills.Skill
5778 if i.cfg.SkillSnapshot != nil {
5779 available = i.cfg.SkillSnapshot()
5780 }
5781 prompt, _, err := expandSkillCommand(cmd, available)
5782 if err != nil {
5783 i.mu.Lock()
5784 i.statusErr = err.Error()
5785 i.statusOK = ""
5786 i.mu.Unlock()
5787 return false
5788 }
5789 i.submitOrQueuePrompt(ctx, prompt)
5790 return false
5791 }
5792
5793 switch strings.ToLower(parts[0]) {
5794 case "/exit":
5795 return true
5796 case "/clear":
5797 if i.agent != nil {
5798 i.agent.SetMessages(nil)
5799 }
5800 i.mu.Lock()
5801 i.toolCalls = map[string]*tui.ToolCallView{}
5802 i.toolOrder = nil
5803 i.toolGate = map[string]int{}
5804 i.statusErr = ""
5805 i.statusOK = ""
5806 i.helpBlock = nil
5807 i.parkedTurn = 0
5808 i.parkedTotal = 0
5809 i.scrollOffset = 0
5810 i.extNotes = nil
5811 i.reloadErrors = nil
5812 handoff, persistHandoff := i.resetCompactContinuationLocked()
5813 i.view.InvalidateRenderCache()
5814 i.mu.Unlock()
5815 if persistHandoff {
5816 i.persistCompactHandoff(handoff)
5817 }
5818 case "/help":
5819 i.mu.Lock()
5820 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
5821 i.statusErr = ""
5822 i.statusOK = ""
5823 // Pin the viewport to the newest content so the help block,
5824 // which we just appended to the end of the transcript, is
5825 // what the user actually sees.
5826 i.scrollOffset = 0
5827 i.mu.Unlock()
5828 case "/login":
5829 i.dialog.Open(i.cfg.ZutHome)
5830 case "/logout":
5831 if len(parts) >= 2 {
5832 // Explicit target: /logout anthropic | openai | all
5833 i.doLogout(parts[1])
5834 break
5835 }
5836 // No arg: open the picker over whichever providers are
5837 // currently logged in. If nothing's logged in, bail with a
5838 // status line.
5839 i.openLogoutDialog()
5840 case "/model":
5841 if len(parts) >= 2 {
5842 i.applyModelSelection("", parts[1])
5843 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
5844 i.mu.Lock()
5845 i.modelRefreshing = true
5846 i.statusOK = "Refreshing models"
5847 i.statusErr = ""
5848 i.mu.Unlock()
5849 go func() {
5850 refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
5851 defer cancel()
5852 i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
5853 }()
5854 } else {
5855 i.openModelPickerAfterRefresh(nil)
5856 }
5857 case "/llama":
5858 if i.cfg.LlamaCPPConfig == nil {
5859 i.mu.Lock()
5860 i.statusErr = "llama.cpp configuration is unavailable"
5861 i.statusOK = ""
5862 i.mu.Unlock()
5863 break
5864 }
5865 baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
5866 if configErr == nil {
5867 configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
5868 }
5869 if configErr != nil {
5870 i.mu.Lock()
5871 i.statusErr = configErr.Error()
5872 i.statusOK = ""
5873 i.mu.Unlock()
5874 }
5875 case "/reasoning":
5876 i.openReasoningDialog()
5877 case "/fast":
5878 enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
5879 i.applySettingToggle("fast_mode", enabled)
5880 case "/settings":
5881 i.openSettingsDialog()
5882 case "/sessions":
5883 i.mu.Lock()
5884 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5885 i.mu.Unlock()
5886 case "/fork":
5887 i.doSessionFork()
5888 case "/jump":
5889 i.openJumpDialog(parts[1:])
5890 case "/btw":
5891 i.openBtwDialog(parts[1:])
5892 case "/skills":
5893 i.openSkillsDialog()
5894 case "/compact":
5895 i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
5896 case "/study":
5897 // Canned prompt that tells the agent to read every file
5898 // in some target so its later turns have the whole thing
5899 // in context. With no argument, the target is the current
5900 // directory. With an argument, the target is whatever the
5901 // user passed — typed by hand, drag-dropped, or selected
5902 // via the @ file picker (which is why we accept both files
5903 // and directories; the @-picker chips for both have already
5904 // been expanded to absolute paths by expandFileChips above).
5905 // Dispatched through the normal queue-or-start path so it
5906 // behaves identically to typing the prompt by hand.
5907 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
5908 i.mu.Lock()
5909 busy := i.busy
5910 compacting := i.compacting
5911 ag := i.agent
5912 i.mu.Unlock()
5913 if busy {
5914 var handoff json.RawMessage
5915 var persistHandoff bool
5916 if ag != nil && !compacting {
5917 i.mu.Lock()
5918 handoff, persistHandoff = i.resetCompactContinuationLocked()
5919 ag.QueueMessage(studyPrompt)
5920 i.mu.Unlock()
5921 } else {
5922 i.mu.Lock()
5923 handoff, persistHandoff = i.resetCompactContinuationLocked()
5924 i.queued = append(i.queued, studyPrompt)
5925 i.mu.Unlock()
5926 }
5927 if persistHandoff {
5928 i.persistCompactHandoff(handoff)
5929 }
5930 i.invalidate()
5931 break
5932 }
5933 i.startTurn(ctx, studyPrompt)
5934 case "/cd":
5935 // Hidden command: switch the running session's cwd. Not in
5936 // slash_suggest, not in /help. Used by the workspaces
5937 // extension's panel-key Enter handler so picking a row
5938 // jumps zut into that directory without relaunching.
5939 //
5940 // Recovers the raw argument (path) from the original cmd
5941 // string rather than parts, so paths with spaces survive.
5942 // The host's ChangeCWD hook handles validation, session
5943 // close + reopen, agent rebuild, sandbox re-rooting, and
5944 // re-jail-if-jailed semantics.
5945 if i.cfg.ChangeCWD == nil {
5946 i.mu.Lock()
5947 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
5948 i.mu.Unlock()
5949 break
5950 }
5951 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
5952 if path == "" {
5953 i.mu.Lock()
5954 i.statusErr = "/cd: missing path"
5955 i.mu.Unlock()
5956 break
5957 }
5958 if err := i.cfg.ChangeCWD(path); err != nil {
5959 i.mu.Lock()
5960 i.statusErr = "/cd: " + err.Error()
5961 i.statusOK = ""
5962 i.mu.Unlock()
5963 break
5964 }
5965 // ChangeCWD has already updated i.cfg.CWD and swapped the
5966 // agent + session. Reset transient TUI state so the new
5967 // session opens clean.
5968 i.mu.Lock()
5969 i.toolCalls = map[string]*tui.ToolCallView{}
5970 i.toolOrder = nil
5971 i.toolGate = map[string]int{}
5972 i.helpBlock = nil
5973 i.parkedTurn = 0
5974 i.statusOK = "cwd " + i.cfg.CWD
5975 i.statusErr = ""
5976 i.mu.Unlock()
5977 i.fileSuggest.Reset()
5978 i.fileSuggest.SetCWD(i.cfg.CWD)
5979 i.invalidate()
5980 case "/jail":
5981 if i.cfg.Sandbox == nil {
5982 i.mu.Lock()
5983 i.statusErr = "sandbox not available in this build"
5984 i.mu.Unlock()
5985 break
5986 }
5987 i.cfg.Sandbox.Lock()
5988 i.mu.Lock()
5989 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
5990 i.statusErr = ""
5991 i.mu.Unlock()
5992 case "/unjail":
5993 if i.cfg.Sandbox == nil {
5994 i.mu.Lock()
5995 i.statusErr = "sandbox not available in this build"
5996 i.mu.Unlock()
5997 break
5998 }
5999 i.cfg.Sandbox.Unlock()
6000 i.mu.Lock()
6001 i.statusOK = "unjailed"
6002 i.statusErr = ""
6003 i.mu.Unlock()
6004 case "/reload-ext":
6005 i.runReloadExt(ctx)
6006 case "/telegram", "/tg":
6007 if len(parts) >= 2 {
6008 i.doTelegram(parts[1])
6009 break
6010 }
6011 i.openTelegramDialog()
6012 case "/session":
6013 if len(parts) >= 2 {
6014 action := parts[1]
6015 arg := ""
6016 if len(parts) >= 3 {
6017 arg = strings.Join(parts[2:], " ")
6018 }
6019 i.doSessionOp(action, arg)
6020 break
6021 }
6022 i.openSessionOpsDialog()
6023 case "/subagents":
6024 i.runSubagents(ctx, parts[1:])
6025 default:
6026 // Last-resort fallback: try the extension manager. Built-in
6027 // cases above always win; this branch only fires for slash
6028 // commands the extension manager registered. Same routing as
6029 // the editor's submit-handler dispatch path so the autocomplete
6030 // "enter on highlighted suggestion" flow also works.
6031 extName := strings.TrimPrefix(parts[0], "/")
6032 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
6033 rest := ""
6034 if len(parts) > 1 {
6035 rest = strings.Join(parts[1:], " ")
6036 }
6037 go i.invokeExtensionCommand(ctx, extName, rest)
6038 return false
6039 }
6040 i.mu.Lock()
6041 i.statusErr = "unknown command: " + parts[0]
6042 i.mu.Unlock()
6043 }
6044 return false
6045}
6046
6047func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
6048 var loggedIn []string
6049 if i.cfg.LoggedInProviders != nil {
6050 loggedIn = i.cfg.LoggedInProviders()
6051 }
6052 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
6053 i.mu.Lock()
6054 if refreshErr != nil {
6055 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
6056 i.statusOK = ""
6057 } else if i.statusOK == "Refreshing models" {
6058 i.statusOK = ""
6059 }
6060 i.mu.Unlock()
6061}
6062
6063// openLogoutDialog shows the provider picker for `/logout` with no
6064// argument. Only providers the user is currently logged into are
6065// listed, plus an "all" entry when more than one is present. If
6066// nothing's logged in, writes a status line instead of opening an
6067// empty dialog.
6068func (i *Interactive) openLogoutDialog() {
6069 if i.cfg.AuthManager == nil {
6070 i.mu.Lock()
6071 i.statusErr = "no auth manager configured"
6072 i.mu.Unlock()
6073 i.invalidate()
6074 return
6075 }
6076 store := i.cfg.AuthManager.Store()
6077 if store == nil {
6078 i.mu.Lock()
6079 i.statusErr = "auth store is not available"
6080 i.mu.Unlock()
6081 i.invalidate()
6082 return
6083 }
6084 creds, err := store.Load()
6085 if err != nil {
6086 i.mu.Lock()
6087 i.statusErr = "read auth store: " + err.Error()
6088 i.mu.Unlock()
6089 i.invalidate()
6090 return
6091 }
6092
6093 var items []logoutItem
6094 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
6095 if creds.Has(p) {
6096 method := creds.Method(p)
6097 if method == "oauth" {
6098 method = "subscription"
6099 }
6100 items = append(items, logoutItem{
6101 label: providerLabel(p),
6102 target: p,
6103 method: method,
6104 })
6105 }
6106 }
6107 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
6108 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
6109 }
6110 if creds.OpenAI.OAuth != nil {
6111 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
6112 }
6113 for p, c := range creds.AdditionalAPIKeyCreds {
6114 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
6115 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
6116 }
6117 }
6118 if len(items) == 0 {
6119 i.mu.Lock()
6120 i.statusOK = "no credentials stored; already logged out"
6121 i.statusErr = ""
6122 i.mu.Unlock()
6123 i.invalidate()
6124 return
6125 }
6126 if len(items) > 1 {
6127 items = append(items, logoutItem{label: "all", target: "all"})
6128 }
6129
6130 i.logoutDialog.Open(items)
6131 i.invalidate()
6132}
6133
6134// doLogout clears credentials for the given provider (or all providers)
6135// from auth.json. If the active agent was using those credentials, it
6136// is torn down so the user is forced through /login before their next
6137// prompt.
6138//
6139// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
6140func (i *Interactive) doLogout(target string) {
6141 if i.cfg.AuthManager == nil {
6142 i.mu.Lock()
6143 i.statusErr = "no auth manager configured"
6144 i.mu.Unlock()
6145 return
6146 }
6147 store := i.cfg.AuthManager.Store()
6148 if store == nil {
6149 i.mu.Lock()
6150 i.statusErr = "auth store is not available"
6151 i.mu.Unlock()
6152 return
6153 }
6154
6155 var providers []string
6156 switch target {
6157 case "", "all":
6158 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
6159 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
6160 providers = []string{target}
6161 default:
6162 known := false
6163 for _, p := range auth.APIKeyProviders() {
6164 if target == p {
6165 known = true
6166 break
6167 }
6168 }
6169 if !known {
6170 i.mu.Lock()
6171 i.statusErr = "unknown provider: " + target
6172 i.mu.Unlock()
6173 return
6174 }
6175 providers = []string{target}
6176 }
6177
6178 var errs []string
6179 clearedCurrent := false
6180 for _, p := range providers {
6181 var err error
6182 switch p {
6183 case "openai":
6184 err = store.ClearAPIKey("openai")
6185 case "openai-codex":
6186 err = store.ClearOAuth("openai")
6187 default:
6188 err = store.Clear(p)
6189 }
6190 if err != nil {
6191 errs = append(errs, p+": "+err.Error())
6192 continue
6193 }
6194 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6195 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
6196 errs = append(errs, p+": "+err.Error())
6197 continue
6198 }
6199 }
6200 if p == i.cfg.Provider {
6201 clearedCurrent = true
6202 }
6203 }
6204
6205 llamaConfigured := i.llamaConfigured
6206 if i.cfg.LlamaCPPConfig != nil {
6207 baseURL, _, err := i.cfg.LlamaCPPConfig()
6208 llamaConfigured = err == nil && baseURL != ""
6209 }
6210
6211 i.mu.Lock()
6212 i.llamaConfigured = llamaConfigured
6213 if len(errs) > 0 {
6214 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
6215 i.mu.Unlock()
6216 return
6217 }
6218 i.statusErr = ""
6219 var handoff json.RawMessage
6220 var persistHandoff bool
6221 if clearedCurrent {
6222 // The running agent was using a credential we just wiped. Drop
6223 // it so prompts can't go out with the stale client, and hint at
6224 // /login. Close any LSP processes owned by its tools first.
6225 if i.agent != nil {
6226 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6227 }
6228 i.agent = nil
6229 handoff, persistHandoff = i.resetCompactContinuationLocked()
6230 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
6231 } else {
6232 i.statusOK = "logged out of " + strings.Join(providers, ", ")
6233 }
6234 i.mu.Unlock()
6235 if persistHandoff {
6236 i.persistCompactHandoff(handoff)
6237 }
6238}
6239
6240func providerSetupInfo(provider string) (string, []string, bool) {
6241 const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
6242 switch provider {
6243 case "amazon-bedrock":
6244 return "Amazon Bedrock setup", []string{
6245 "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
6246 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
6247 "",
6248 "For Bedrock API keys, set:",
6249 " AWS_BEARER_TOKEN_BEDROCK=...",
6250 " AWS_REGION=us-east-1",
6251 "",
6252 "Docs:",
6253 " " + docsURL,
6254 }, true
6255 case "google-vertex":
6256 return "Google Vertex AI setup", []string{
6257 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
6258 "Set a Google API key, application-default credentials, or a service account.",
6259 "",
6260 "Common environment:",
6261 " GOOGLE_CLOUD_API_KEY=...",
6262 " GOOGLE_CLOUD_PROJECT=...",
6263 " GOOGLE_CLOUD_LOCATION=us-central1",
6264 "",
6265 "Docs:",
6266 " " + docsURL,
6267 }, true
6268 case "cloudflare-workers-ai":
6269 return "Cloudflare Workers AI setup", []string{
6270 "Cloudflare Workers AI needs both an API token and an account ID.",
6271 "",
6272 "Set:",
6273 " CLOUDFLARE_API_KEY=...",
6274 " CLOUDFLARE_ACCOUNT_ID=...",
6275 "",
6276 "Docs:",
6277 " " + docsURL,
6278 }, true
6279 case "cloudflare-ai-gateway":
6280 return "Cloudflare AI Gateway setup", []string{
6281 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
6282 "",
6283 "Set:",
6284 " CLOUDFLARE_API_KEY=...",
6285 " CLOUDFLARE_ACCOUNT_ID=...",
6286 " CLOUDFLARE_GATEWAY_ID=...",
6287 "",
6288 "Docs:",
6289 " " + docsURL,
6290 }, true
6291 case "azure-openai-responses":
6292 return "Azure OpenAI Responses setup", []string{
6293 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
6294 "",
6295 "Set:",
6296 " AZURE_OPENAI_API_KEY=...",
6297 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
6298 " AZURE_OPENAI_API_VERSION=2024-02-01",
6299 "",
6300 "Docs:",
6301 " " + docsURL,
6302 }, true
6303 default:
6304 return "", nil, false
6305 }
6306}
6307
6308func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
6309 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
6310 i.dialog.ShowResult(false, "auth store is unavailable")
6311 return
6312 }
6313 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
6314 i.dialog.ShowResult(false, err.Error())
6315 return
6316 }
6317 i.mu.Lock()
6318 i.llamaConfigured = true
6319 i.statusErr = ""
6320 i.statusOK = "configured llama.cpp router"
6321 i.mu.Unlock()
6322 i.dialog.ShowResult(true, "")
6323}
6324
6325func (i *Interactive) startAPIKeyFlow(provider string) {
6326 if title, lines, ok := providerSetupInfo(provider); ok {
6327 i.dialog.ShowInfo(title, lines)
6328 return
6329 }
6330 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6331 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6332 }
6333 url, err := i.cfg.AuthManager.StartAPIKey(provider)
6334 if err != nil {
6335 i.dialog.ShowResult(false, err.Error())
6336 return
6337 }
6338 i.dialog.ShowWaiting(url)
6339}
6340
6341func (i *Interactive) startOAuthFlow(provider string) {
6342 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6343 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6344 }
6345 // Device-code providers already support headless login and must only
6346 // start one polling flow.
6347 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
6348 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
6349 if err != nil {
6350 i.dialog.ShowResult(false, err.Error())
6351 return
6352 }
6353 i.dialog.ShowWaiting(loginURL)
6354 return
6355 }
6356 // Always run the manual/copy-code flow in parallel with the local
6357 // callback server so headless environments (docker, SSH) can paste
6358 // the authorization code directly without first pressing 'p'.
6359 _, err := i.cfg.AuthManager.StartOAuth(provider)
6360 if err != nil {
6361 i.dialog.ShowResult(false, err.Error())
6362 return
6363 }
6364 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
6365 if mErr == nil {
6366 i.dialog.ShowWaiting(manualURL)
6367 } else {
6368 i.dialog.ShowResult(false, mErr.Error())
6369 }
6370}
6371
6372func (i *Interactive) startManualOAuthFlow(provider string) {
6373 if i.cfg.AuthManager == nil {
6374 return
6375 }
6376 i.cfg.AuthManager.CancelOAuth()
6377 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
6378 if err != nil {
6379 i.dialog.ShowResult(false, err.Error())
6380 return
6381 }
6382 i.dialog.url = url
6383 i.invalidate()
6384}
6385
6386func (i *Interactive) submitManualOAuthCode(code string) {
6387 if i.cfg.AuthManager == nil {
6388 return
6389 }
6390 go func() {
6391 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
6392 i.dialog.ShowResult(false, err.Error())
6393 i.invalidate()
6394 }
6395 }()
6396}
6397
6398// applyModelSelection switches the active model (and provider, if the
6399// new model belongs to a different one). It rebuilds the underlying
6400// client when needed so the provider wire-protocol matches.
6401// cancelAndWaitForIdle cancels the active turn (if any) and blocks
6402// briefly until the turn goroutine has updated i.busy = false. Used
6403// before destructive slash commands so transcript-mutating work
6404// (/clear, /compact, /logout, /login completion, cross-provider
6405// /model swap) doesn't race with the still-running stream.
6406//
6407// The wait is bounded; if the turn doesn't release within the timeout
6408// we proceed anyway. Worst case is a brief overlap that the agent's
6409// own mutex protects against.
6410func (i *Interactive) cancelAndWaitForIdle() {
6411 i.mu.Lock()
6412 busy := i.busy
6413 cancel := i.cancelTurn
6414 i.mu.Unlock()
6415 if !busy {
6416 return
6417 }
6418 if cancel != nil {
6419 i.mu.Lock()
6420 handoff, persistHandoff := i.resetCompactContinuationLocked()
6421 i.mu.Unlock()
6422 cancel()
6423 if persistHandoff {
6424 i.persistCompactHandoff(handoff)
6425 }
6426 }
6427 // ConfirmToolCall waits on its response channel rather than the turn
6428 // context, so cancellation must also resolve every pending prompt.
6429 i.confirmDialog.CancelAll("turn cancelled")
6430 deadline := time.Now().Add(2 * time.Second)
6431 for time.Now().Before(deadline) {
6432 i.mu.Lock()
6433 done := !i.busy
6434 i.mu.Unlock()
6435 if done {
6436 return
6437 }
6438 time.Sleep(10 * time.Millisecond)
6439 }
6440}
6441
6442// openBtwDialog opens the side-chat overlay with a frozen snapshot
6443// of the current main session. The optional argument is auto-
6444// submitted as the first question, so '/btw does X work?' fires the
6445// model call immediately instead of just opening an empty dialog.
6446func (i *Interactive) openBtwDialog(args []string) {
6447 i.agentMu.Lock()
6448 defer i.agentMu.Unlock()
6449 i.mu.Lock()
6450 ag := i.agent
6451 system := ""
6452 if ag != nil {
6453 system, _ = ag.PromptConfig()
6454 }
6455 theme := i.cfg.Theme
6456 model := i.cfg.Model
6457 cwd := i.cfg.CWD
6458 compact := i.compactModeEnabled()
6459 flatTools := i.cfg.FlatTools
6460 lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
6461 i.mu.Unlock()
6462 if ag == nil {
6463 i.mu.Lock()
6464 i.statusErr = "not logged in. type /login first."
6465 i.mu.Unlock()
6466 return
6467 }
6468 seed := strings.TrimSpace(strings.Join(args, " "))
6469 i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6470 i.invalidate()
6471}
6472
6473// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
6474// or queues it behind the active turn using the normal text-only queue.
6475func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6476 i.mu.Lock()
6477 if i.agent == nil {
6478 i.statusErr = "not logged in. type /login first."
6479 i.statusOK = ""
6480 i.mu.Unlock()
6481 i.invalidate()
6482 return
6483 }
6484 if i.busy {
6485 // Keep the mutex held while enqueueing so the turn-completion
6486 // goroutine cannot publish idle and inspect an empty queue between
6487 // this check and the append.
6488 var handoff json.RawMessage
6489 var persistHandoff bool
6490 if i.compacting {
6491 handoff, persistHandoff = i.resetCompactContinuationLocked()
6492 i.queued = append(i.queued, prompt)
6493 } else {
6494 handoff, persistHandoff = i.resetCompactContinuationLocked()
6495 i.agent.QueueMessage(prompt)
6496 }
6497 i.mu.Unlock()
6498 if persistHandoff {
6499 i.persistCompactHandoff(handoff)
6500 }
6501 i.invalidate()
6502 return
6503 }
6504 i.mu.Unlock()
6505 i.startTurn(ctx, prompt)
6506}
6507
6508// openSkillsDialog opens the skill inspector. The picker reflects
6509// whatever SkillSnapshot returns at call time, so edits to a
6510// SKILL.md made during a session show up on the next /skills.
6511func (i *Interactive) openSkillsDialog() {
6512 var list []*skills.Skill
6513 if i.cfg.SkillSnapshot != nil {
6514 list = i.cfg.SkillSnapshot()
6515 }
6516 i.skillsDialog.Open(list)
6517 i.invalidate()
6518}
6519
6520// openJumpDialog builds a /jump picker from the current transcript.
6521// If the user typed "/jump foo" with a filter and it matches exactly
6522// one turn, jump there directly without showing the dialog.
6523func (i *Interactive) openJumpDialog(args []string) {
6524 if i.view == nil || len(i.view.Messages) == 0 {
6525 i.mu.Lock()
6526 i.statusErr = "nothing to jump to \u2014 the session is empty"
6527 i.mu.Unlock()
6528 return
6529 }
6530 filter := strings.TrimSpace(strings.Join(args, " "))
6531 i.jumpDialog.Open(i.view.Messages, filter)
6532 // Shortcut: with a filter argument that matches exactly one turn,
6533 // jump immediately and skip the picker.
6534 if filter != "" {
6535 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6536 t := tgts[0]
6537 i.jumpDialog.Close()
6538 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6539 }
6540 }
6541}
6542
6543// applyJumpSelection scrolls the chat viewport so the user message at
6544// msgIdx is visible at (or near) the top of the chat area. Uses the
6545// anchor slice returned by view.BuildWithAnchors so the mapping from
6546// message index to row is exact, regardless of variable-height tool
6547// blocks above the target.
6548func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6549 cols := i.lastCols()
6550 chat, anchors := i.view.BuildWithAnchors(cols)
6551 var row int
6552 found := false
6553 for _, a := range anchors {
6554 if a.MessageIdx == msgIdx {
6555 row = a.Row
6556 found = true
6557 break
6558 }
6559 }
6560 if !found {
6561 i.mu.Lock()
6562 i.statusErr = "could not resolve jump target"
6563 i.mu.Unlock()
6564 return
6565 }
6566
6567 chatLen := len(chat)
6568 page := i.chatPage()
6569 if page < 1 {
6570 page = 1
6571 }
6572 // scrollOffset is measured from the bottom of the chat slice, so
6573 // to place `row` at the top of the viewport we want:
6574 // chatLen - scrollOffset - page == row
6575 // Solve for scrollOffset and clamp to [0, chatLen-page].
6576 offset := chatLen - (row + page)
6577 if offset < 0 {
6578 offset = 0
6579 }
6580 maxOffset := chatLen - page
6581 if maxOffset < 0 {
6582 maxOffset = 0
6583 }
6584 if offset > maxOffset {
6585 offset = maxOffset
6586 }
6587
6588 i.mu.Lock()
6589 i.scrollOffset = offset
6590 i.parkedTurn = turnNo
6591 i.parkedTotal = totalTurnsLocked(i.view.Messages)
6592 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6593 i.statusErr = ""
6594 i.mu.Unlock()
6595}
6596
6597// totalTurnsLocked counts user messages in the transcript. Caller is
6598// assumed to hold i.mu (the name is a mild reminder; this function
6599// itself doesn't touch shared state beyond the slice it's handed).
6600func totalTurnsLocked(msgs []provider.Message) int {
6601 n := 0
6602 for _, m := range msgs {
6603 if m.Role == provider.RoleUser {
6604 n++
6605 }
6606 }
6607 return n
6608}
6609
6610// applySessionSelection loads the given session via the cli-provided
6611// callback and snaps the viewport to the bottom (the latest message)
6612// so the user lands at the live tail of the resumed conversation.
6613func (i *Interactive) applySessionSelection(path string) {
6614 if i.cfg.LoadSession == nil {
6615 i.mu.Lock()
6616 i.statusErr = "session loading is not wired in this build"
6617 i.mu.Unlock()
6618 return
6619 }
6620 i.mu.Lock()
6621 if i.sessionLoading {
6622 i.statusErr = "already resuming a session"
6623 i.mu.Unlock()
6624 i.invalidate()
6625 return
6626 }
6627 i.sessionLoading = true
6628 i.statusOK = "resuming session: " + path
6629 i.statusErr = ""
6630 i.mu.Unlock()
6631 i.invalidate()
6632 i.markSessionTitleSwitching()
6633
6634 go func() {
6635 err := i.cfg.LoadSession(path)
6636 if err != nil {
6637 i.restoreFailedSessionTitle()
6638 i.mu.Lock()
6639 i.sessionLoading = false
6640 i.statusErr = err.Error()
6641 i.statusOK = ""
6642 i.mu.Unlock()
6643 i.invalidate()
6644 return
6645 }
6646 i.restoreLoadedSessionTitle()
6647 state := decodeCompactHandoff(i.currentCompactHandoff())
6648 i.mu.Lock()
6649 i.compactContinuation = state
6650 i.sessionLoading = false
6651 i.statusOK = "resumed session: " + path
6652 i.statusErr = ""
6653 i.parkedTurn = 0
6654 i.parkedTotal = 0
6655 i.scrollOffset = 0
6656 // Fresh transcript swapped in: drop the auto-follow baseline so
6657 // the next render's follow guard doesn't see the wholesale
6658 // length change as a delta and jump the viewport.
6659 i.prevChatLen = 0
6660 i.prevChatCols = 0
6661 i.extNotes = nil
6662 i.view.InvalidateRenderCache()
6663 if i.agent != nil {
6664 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
6665 i.cumUsage = i.agent.Cost()
6666 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6667 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6668 } else {
6669 i.lastCtxInput = 0
6670 }
6671 // Snap to the tail again — the swap brought in a fresh
6672 // transcript whose markdown / chroma cost we don't want
6673 // blocking the redraw.
6674 if len(i.view.Messages) > initialResumeTailLimit {
6675 i.view.TailLimit = initialResumeTailLimit
6676 } else {
6677 i.view.TailLimit = 0
6678 }
6679 }
6680 i.mu.Unlock()
6681 i.invalidate()
6682 if state.reason != compactContinuationNone {
6683 i.startRestoredCompactHandoff(i.runCtx)
6684 }
6685 }()
6686}
6687
6688func (i *Interactive) applyModelSelection(prov, model string) {
6689 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6690}
6691
6692// applyRescueModelSelection is like applyModelSelection but routes
6693// through BuildAgentForRescue so launch-time --api-key / --base-url
6694// overrides are dropped before the new agent is built. Falls back to
6695// the regular builder when the host doesn't wire a rescue builder.
6696func (i *Interactive) applyRescueModelSelection(prov, model string) {
6697 builder := i.cfg.BuildAgentForRescue
6698 if builder == nil {
6699 builder = i.cfg.BuildAgentFor
6700 }
6701 i.swapModel(prov, model, builder, true)
6702}
6703
6704// swapModel applies a /model selection (or a rescue selection) using
6705// the supplied builder. rescue=true tags the success message so the
6706// user can see that launch-time overrides were ignored.
6707func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6708 var replaced bool
6709 swap := func() {
6710 replaced = i.swapModelUnserialized(prov, model, builder, rescue)
6711 }
6712 if i.cfg.SessionTransition != nil {
6713 i.cfg.SessionTransition(swap)
6714 } else {
6715 swap()
6716 }
6717 if replaced {
6718 i.resetCompactHandoff()
6719 }
6720}
6721
6722func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
6723 if model == "" {
6724 return false
6725 }
6726 m, err := provider.FindModel(prov, model)
6727 if err != nil {
6728 i.mu.Lock()
6729 i.statusErr = err.Error()
6730 i.mu.Unlock()
6731 return false
6732 }
6733 // Same provider AND not a rescue retry: just swap the model on
6734 // the existing agent. Mixed-API providers dispatch from model metadata,
6735 // so the client remains reusable. Rescue retries always rebuild so a stale
6736 // auth header / base URL can't carry over.
6737 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6738 i.mu.Lock()
6739 i.cfg.Model = m.ID
6740 i.agent.Model = m.ID
6741 i.statusOK = "model: " + m.ID
6742 i.statusErr = ""
6743 i.mu.Unlock()
6744 if i.cfg.PersistModel != nil {
6745 i.cfg.PersistModel(i.cfg.Provider, m.ID)
6746 }
6747 return false
6748 }
6749 if builder == nil {
6750 i.mu.Lock()
6751 i.statusErr = "cannot switch provider: no builder configured"
6752 i.mu.Unlock()
6753 return false
6754 }
6755 // Snapshot the current transcript and cumulative usage BEFORE we
6756 // build the replacement agent so we can hand them off. Without
6757 // this the user perceives the entire session as wiped on a
6758 // cross-provider /model swap.
6759 var carryMsgs []provider.Message
6760 var carryCost provider.Usage
6761 if i.agent != nil {
6762 carryMsgs = i.agent.Messages()
6763 carryCost = i.agent.Cost()
6764 }
6765
6766 ag, p, md, err := builder(m.Provider, m.ID)
6767 if err != nil {
6768 i.mu.Lock()
6769 i.statusErr = err.Error()
6770 i.mu.Unlock()
6771 return false
6772 }
6773
6774 // Replay the transcript and seed the cost on the freshly-built
6775 // agent. Messages travel cleanly between providers because they
6776 // use the same provider.Message shape; tool-call ids are local
6777 // to a turn so cross-provider continuation never confuses the
6778 // new model (it just sees the assistant's reply, no orphan
6779 // tool_use blocks because /model swaps are gated to idle state).
6780 if len(carryMsgs) > 0 {
6781 ag.SetMessages(carryMsgs)
6782 }
6783 ag.SeedCost(carryCost)
6784
6785 if i.agent != nil && i.agent != ag {
6786 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6787 }
6788 i.agentMu.Lock()
6789 i.mu.Lock()
6790 i.prepareReplacementAgentLocked(ag)
6791 i.agent = ag
6792 i.cfg.Provider = p
6793 i.cfg.Model = md
6794 if rescue {
6795 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6796 } else {
6797 i.statusOK = "switched to " + p + " / " + md
6798 }
6799 i.statusErr = ""
6800 // Render cache keys are width+content based, so the new agent's
6801 // identical messages will reuse the existing entries. Nothing
6802 // to invalidate.
6803 i.mu.Unlock()
6804 i.agentMu.Unlock()
6805 // The new agent was built off the base tool registry, so any
6806 // dynamically-registered tools need to be reattached. The apply
6807 // helpers are no-ops when their feature is inactive, so the
6808 // cross-provider path still works on a vanilla setup.
6809 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6810 i.applyTelegramTools(i.telegramBridge != nil)
6811 if i.cfg.PersistModel != nil {
6812 i.cfg.PersistModel(p, md)
6813 }
6814 return true
6815}
6816
6817func (i *Interactive) handleAuthEvent(ev auth.Event) {
6818 switch ev.Kind {
6819 case "started":
6820 i.dialog.ShowWaiting(ev.URL)
6821 case "browser_open":
6822 // no-op
6823 case "error":
6824 i.dialog.ShowResult(false, ev.Message)
6825 case "success":
6826 // Keep the credential-driven resolve and its final replacement in the
6827 // same transition as settings/session changes. Otherwise a build that
6828 // started under the old web-search policy could commit afterward.
6829 var buildErr error
6830 var clearHandoff bool
6831 buildAndCommitLogin := func() {
6832 ag, prov, model, err := i.cfg.BuildAgent()
6833 if err != nil {
6834 buildErr = err
6835 return
6836 }
6837 i.agentMu.Lock()
6838 i.mu.Lock()
6839 oldAgent := i.agent
6840 i.prepareReplacementAgentLocked(ag)
6841 _, clearHandoff = i.resetCompactContinuationLocked()
6842 i.agent = ag
6843 i.cfg.Provider = prov
6844 i.cfg.Model = model
6845 i.statusErr = ""
6846 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6847 i.mu.Unlock()
6848 i.agentMu.Unlock()
6849 if oldAgent != nil {
6850 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6851 }
6852 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6853 i.applyTelegramTools(i.telegramBridge != nil)
6854 // Authentication can change the provider used by the live
6855 // agent. Persist it on the active session just like /model.
6856 if i.cfg.PersistModel != nil {
6857 i.cfg.PersistModel(prov, model)
6858 }
6859 }
6860 if i.cfg.SessionTransition != nil {
6861 i.cfg.SessionTransition(buildAndCommitLogin)
6862 } else {
6863 buildAndCommitLogin()
6864 }
6865 if buildErr != nil {
6866 i.dialog.ShowResult(false, buildErr.Error())
6867 return
6868 }
6869 if clearHandoff {
6870 i.persistCompactHandoff(nil)
6871 }
6872 i.dialog.ShowResult(true, "")
6873 }
6874}
6875
6876// clearPendingCompactTurnLocked drops work that only makes sense after the
6877// current compaction. The caller must hold i.mu.
6878func (i *Interactive) clearPendingCompactTurnLocked() {
6879 i.pendingCompactPrompt = ""
6880 i.pendingCompactImages = nil
6881 i.hasPendingCompactPrompt = false
6882 i.continueAfterCompact = false
6883 i.pendingPostCompactNote = ""
6884}
6885
6886// compactHandoffLocked returns a self-contained persistence snapshot. The
6887// caller must hold i.mu.
6888func (i *Interactive) compactHandoffLocked() json.RawMessage {
6889 return encodeCompactHandoff(i.compactContinuation)
6890}
6891
6892// setCompactContinuationLocked updates the private handoff state and returns
6893// its persistence snapshot plus whether persistence is needed. The caller must
6894// hold i.mu.
6895func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
6896 if i.compactContinuation == state {
6897 return i.compactHandoffLocked(), false
6898 }
6899 i.compactContinuation = state
6900 return i.compactHandoffLocked(), true
6901}
6902
6903// resetCompactContinuationLocked clears the private handoff state and returns
6904// its persistence snapshot plus whether persistence is needed. The caller must
6905// hold i.mu.
6906func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
6907 return i.setCompactContinuationLocked(compactContinuationState{})
6908}
6909
6910func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
6911 if persist := i.cfg.PersistCompactHandoff; persist != nil {
6912 if err := persist(state); err != nil {
6913 i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
6914 }
6915 }
6916}
6917
6918func (i *Interactive) resetCompactHandoff() {
6919 i.mu.Lock()
6920 state, persist := i.resetCompactContinuationLocked()
6921 i.mu.Unlock()
6922 if persist {
6923 i.persistCompactHandoff(state)
6924 }
6925}
6926
6927func (i *Interactive) currentCompactHandoff() json.RawMessage {
6928 if current := i.cfg.CurrentCompactHandoff; current != nil {
6929 return current()
6930 }
6931 return nil
6932}
6933
6934func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
6935 i.mu.Lock()
6936 i.compactContinuation = state
6937 i.mu.Unlock()
6938}
6939
6940func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
6941 state := decodeCompactHandoff(i.currentCompactHandoff())
6942 i.restoreCompactHandoff(state)
6943 return state
6944}
6945
6946// autoCompactContinuationPrompt is an interactive-only compact-handoff
6947// instruction. Its completion criteria must stay separate from Ponytail so
6948// toggling the optional coding-style addendum never changes recovery behavior.
6949const 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.`
6950
6951type compactHandoffResume uint8
6952
6953const (
6954 compactHandoffDiscard compactHandoffResume = iota
6955 compactHandoffContinueExisting
6956 compactHandoffAppendPrompt
6957)
6958
6959// classifyCompactHandoffResume validates that the persisted checkpoint still
6960// describes the effective transcript. A normal assistant reply or a newer
6961// explicit user message supersedes the checkpoint; tool activity remains an
6962// unfinished existing turn. No hidden prompt means the process stopped after
6963// recording the handoff but before appending its context message.
6964func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
6965 lastHandoff := -1
6966 for idx := len(messages) - 1; idx >= 0; idx-- {
6967 message := messages[idx]
6968 if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
6969 lastHandoff = idx
6970 break
6971 }
6972 }
6973 if lastHandoff < 0 {
6974 return compactHandoffAppendPrompt
6975 }
6976 for _, message := range messages[lastHandoff+1:] {
6977 switch message.Role {
6978 case provider.RoleTool:
6979 continue
6980 case provider.RoleAssistant:
6981 hasToolCall := false
6982 for _, content := range message.Content {
6983 if _, ok := content.(provider.ToolCallBlock); ok {
6984 hasToolCall = true
6985 break
6986 }
6987 }
6988 if !hasToolCall {
6989 return compactHandoffDiscard
6990 }
6991 default:
6992 return compactHandoffDiscard
6993 }
6994 }
6995 return compactHandoffContinueExisting
6996}
6997
6998// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
6999// active session. When its hidden context message was already persisted, the
7000// agent continues that existing turn rather than appending it again.
7001func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
7002 if parent == nil {
7003 parent = i.runCtx
7004 }
7005 if parent == nil {
7006 parent = context.Background()
7007 }
7008 i.mu.Lock()
7009 state := i.compactContinuation
7010 ag := i.agent
7011 busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7012 if state.reason == compactContinuationNone || ag == nil || busy {
7013 i.mu.Unlock()
7014 return
7015 }
7016
7017 var next string
7018 continueQueued := false
7019 var resume compactHandoffResume
7020 var handoff json.RawMessage
7021 var persistHandoff bool
7022 switch {
7023 case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7024 next, i.queued = i.queued[0], i.queued[1:]
7025 handoff, persistHandoff = i.resetCompactContinuationLocked()
7026 case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7027 continueQueued = true
7028 handoff, persistHandoff = i.resetCompactContinuationLocked()
7029 default:
7030 resume = classifyCompactHandoffResume(ag.Messages())
7031 if resume == compactHandoffDiscard {
7032 handoff, persistHandoff = i.resetCompactContinuationLocked()
7033 }
7034 }
7035 // Reserve the turn slot before persisting or dispatching so concurrent input
7036 // can only queue behind the restored handoff, never start a competing turn.
7037 starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7038 i.busy = starting
7039 i.mu.Unlock()
7040 if persistHandoff {
7041 i.persistCompactHandoff(handoff)
7042 }
7043 if !starting {
7044 i.invalidate()
7045 return
7046 }
7047 switch {
7048 case next != "":
7049 i.startTurn(parent, next)
7050 case continueQueued:
7051 i.startTurnRequest(parent, "", nil, true, false)
7052 case resume == compactHandoffContinueExisting:
7053 i.startTurnRequest(parent, "", nil, true, false)
7054 case resume == compactHandoffAppendPrompt:
7055 i.startAutoCompactContinuation(parent)
7056 }
7057}
7058
7059// startAutoCompactContinuation adds an internal user turn that tells the
7060// model to resume the task after threshold compaction. Provider requests
7061// cannot continue from an assistant tail on every supported API, so the
7062// continuation must be represented as a user message. It is hidden from
7063// the rendered transcript but remains in the persisted/model context.
7064func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
7065 i.mu.Lock()
7066 ag := i.agent
7067 if ag == nil {
7068 i.busy = false
7069 pendingIdleWork := i.takePendingIdleWorkLocked()
7070 i.mu.Unlock()
7071 runPendingIdleWork(pendingIdleWork)
7072 i.invalidate()
7073 return
7074 }
7075 // The caller selected the continuation while holding i.mu. Keep that
7076 // ordering through the hidden append so an explicitly queued user prompt
7077 // cannot be overtaken by an old-task handoff. Forced truncation is the
7078 // existing exception: it keeps priority and the queued prompt runs after it.
7079 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
7080 next := i.queued[0]
7081 i.queued = i.queued[1:]
7082 handoff, persistHandoff := i.resetCompactContinuationLocked()
7083 i.mu.Unlock()
7084 if persistHandoff {
7085 i.persistCompactHandoff(handoff)
7086 }
7087 i.startTurn(parent, next)
7088 return
7089 }
7090 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
7091 handoff, persistHandoff := i.resetCompactContinuationLocked()
7092 i.mu.Unlock()
7093 if persistHandoff {
7094 i.persistCompactHandoff(handoff)
7095 }
7096 i.startTurnRequest(parent, "", nil, true, false)
7097 return
7098 }
7099 ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
7100 autoCompactContinueMetaKey: "true",
7101 })
7102 i.mu.Unlock()
7103 i.startTurnRequest(parent, "", nil, true, false)
7104}
7105
7106// runCompact invokes core.Agent.Compact and reflects the progress in
7107// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
7108// cancel via the same cancelTurn channel used for normal turns.
7109//
7110// When auto is true the spinner message is pinned to "condensing
7111// history" and the status bar surfaces "(auto)" next to the context
7112// percentage so it's obvious the system triggered this, not the user.
7113// request.force tells the post-compact hand-off to resume even if the
7114// transcript tail has visible assistant text, for example after StopLength.
7115func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
7116 auto := request.origin != compactOriginManual
7117 if i.agent == nil {
7118 i.mu.Lock()
7119 i.statusErr = "not logged in. type /login first."
7120 i.mu.Unlock()
7121 return
7122 }
7123 ctx, cancel := context.WithCancel(parent)
7124 i.mu.Lock()
7125 i.busy = true
7126 i.compacting = true
7127 i.spin.Start()
7128 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7129 if auto {
7130 i.activity.kind = activityCondensingHistory
7131 i.autoCompacting = true
7132 } else {
7133 i.activity.kind = activityCompactingHistory
7134 }
7135 i.cancelTurn = cancel
7136 var initialHandoff json.RawMessage
7137 persistInitialHandoff := false
7138 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
7139 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
7140 }
7141 i.statusErr = ""
7142 i.statusOK = ""
7143 // Do NOT set streamOn: the summary text should not be visible
7144 // in the chat while compacting. The user just sees the spinner
7145 // and can keep typing / queue prompts.
7146 i.scrollOffset = 0
7147 i.helpBlock = nil
7148 i.mu.Unlock()
7149 if persistInitialHandoff {
7150 i.persistCompactHandoff(initialHandoff)
7151 }
7152 i.invalidate()
7153
7154 go func() {
7155 // Sink discards deltas — we don't stream the summary to the UI.
7156 sink := func(delta string) {}
7157 msgsBefore := i.agent.Messages()
7158 i.mu.Lock()
7159 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7160 i.mu.Unlock()
7161 continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
7162 if request.force {
7163 continuationReason = compactContinuationForcedLength
7164 }
7165 // Keep the usual recent tail when possible, but never let it cover
7166 // the whole transcript. A short session can still be at 70–90% of
7167 // a model's window because one prompt or tool result is large; the
7168 // automatic path must summarize that session instead of failing
7169 // before it reaches the compaction provider.
7170 keepTail := 4
7171 if n := len(msgsBefore); n > 0 && keepTail >= n {
7172 keepTail = n - 1
7173 }
7174 summary, err := i.agent.Compact(ctx, keepTail, sink)
7175 _ = summary
7176 i.mu.Lock()
7177 // Keep busy/compacting asserted while cleanup and queue selection run.
7178 // Completion updates can arrive in this window; clearing busy before
7179 // inspecting the queues lets them race a new turn or be stranded.
7180 i.resetStreamingStateLocked()
7181 i.cancelTurn = nil
7182 i.autoCompacting = false
7183 pendingIdleWork := i.takePendingIdleWorkLocked()
7184
7185 // Drain pending work after the transcript is clean. Overflow recovery
7186 // continues the user message already in the transcript. Pre-turn
7187 // compaction preserves its complete text-and-images request. Regular
7188 // prompts typed during compaction remain in the host queue.
7189 var next string
7190 var nextImages []provider.ImageBlock
7191 var hasNext bool
7192 var continueExisting bool
7193 var continueAutomatically bool
7194 var handoff json.RawMessage
7195 var persistHandoff bool
7196
7197 switch {
7198 case err != nil && ctx.Err() != nil:
7199 i.statusErr = ""
7200 if auto {
7201 i.statusOK = "auto-condense cancelled"
7202 } else {
7203 i.statusOK = "compaction cancelled"
7204 }
7205 i.queued = nil // drop queue on cancel
7206 i.clearPendingCompactTurnLocked()
7207 handoff, persistHandoff = i.resetCompactContinuationLocked()
7208 if i.agent != nil {
7209 i.agent.DrainQueuedMessages()
7210 }
7211 case err != nil:
7212 i.statusErr = "compaction failed: " + err.Error()
7213 i.statusOK = ""
7214 i.queued = nil // drop queue on error
7215 i.clearPendingCompactTurnLocked()
7216 handoff, persistHandoff = i.resetCompactContinuationLocked()
7217 if i.agent != nil {
7218 i.agent.DrainQueuedMessages()
7219 }
7220 default:
7221 i.statusErr = ""
7222 // Read token count from the compaction message meta.
7223 tokens := ""
7224 msgs := i.agent.Messages()
7225 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
7226 tokens = msgs[0].Meta["tokens_before"]
7227 }
7228 switch {
7229 case i.pendingPostCompactNote != "":
7230 i.statusOK = i.pendingPostCompactNote
7231 case tokens != "":
7232 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
7233 default:
7234 i.statusOK = "compacted (ctrl+o to expand)"
7235 }
7236 i.pendingPostCompactNote = ""
7237 i.extNotes = stripAutoCompactNotes(i.extNotes)
7238 i.lastCtxInput = 0
7239 i.toolCalls = map[string]*tui.ToolCallView{}
7240 i.toolOrder = nil
7241 i.toolGate = map[string]int{}
7242 i.resetTranscriptRenderLocked()
7243 switch {
7244 case i.continueAfterCompact:
7245 continueExisting = true
7246 i.continueAfterCompact = false
7247 case i.hasPendingCompactPrompt:
7248 next = i.pendingCompactPrompt
7249 nextImages = i.pendingCompactImages
7250 i.pendingCompactPrompt = ""
7251 i.pendingCompactImages = nil
7252 i.hasPendingCompactPrompt = false
7253 hasNext = true
7254 case continuationReason == compactContinuationForcedLength:
7255 // Forced truncated-output continuation keeps its existing
7256 // priority over an explicitly queued prompt.
7257 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7258 continueAutomatically = true
7259 case len(i.queued) > 0:
7260 next, i.queued = i.queued[0], i.queued[1:]
7261 hasNext = true
7262 handoff, persistHandoff = i.resetCompactContinuationLocked()
7263 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
7264 if continuationReason == compactContinuationStatusRescue {
7265 attempts := 1
7266 if i.compactContinuation.reason == compactContinuationStatusRescue {
7267 attempts = i.compactContinuation.rescueAttempts + 1
7268 }
7269 if attempts > maxStatusRescueContinuations {
7270 handoff, persistHandoff = i.resetCompactContinuationLocked()
7271 break
7272 }
7273 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7274 reason: continuationReason,
7275 rescueAttempts: attempts,
7276 })
7277 } else {
7278 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7279 }
7280 continueAutomatically = true
7281 default:
7282 handoff, persistHandoff = i.resetCompactContinuationLocked()
7283 }
7284 }
7285 // Keep the host busy until the hand-off decision is committed under
7286 // the mutex. A completion update arriving after the compaction result
7287 // but before this assignment is then queued for the selected follow-up
7288 // instead of starting a competing turn.
7289 i.busy = hasNext || continueExisting || continueAutomatically
7290 i.compacting = false
7291 i.autoCompacting = false
7292 i.mu.Unlock()
7293 if persistHandoff {
7294 i.persistCompactHandoff(handoff)
7295 }
7296 runPendingIdleWork(pendingIdleWork)
7297 i.invalidate()
7298
7299 if hasNext || continueExisting || continueAutomatically {
7300 p := i.runCtx
7301 if p == nil {
7302 p = context.Background()
7303 }
7304 switch {
7305 case continueExisting:
7306 i.startTurnRequest(p, "", nil, true, true)
7307 case hasNext:
7308 i.startTurnWithImages(p, next, nextImages)
7309 case continueAutomatically:
7310 i.startAutoCompactContinuation(p)
7311 }
7312 }
7313 }()
7314}
7315
7316// shellEscapeCommand reports whether text is a "!command" shell
7317// escape and, if so, returns the command with the leading '!' (and
7318// surrounding whitespace) stripped. A bare "!" with no command is
7319// treated as not an escape so it falls through to the normal prompt
7320// path rather than running an empty shell.
7321func shellEscapeCommand(text string) (string, bool) {
7322 return ShellEscapeCommand(text)
7323}
7324
7325// ShellEscapeCommand reports whether text is a "!command" shell
7326// escape and, if so, returns the command with the leading '!' (and
7327// surrounding whitespace) stripped. A bare "!" with no command is
7328// treated as not an escape so it falls through to the normal prompt
7329// path rather than running an empty shell.
7330func ShellEscapeCommand(text string) (string, bool) {
7331 trimmed := strings.TrimLeft(text, " \t")
7332 if !strings.HasPrefix(trimmed, "!") {
7333 return "", false
7334 }
7335 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
7336 if cmd == "" {
7337 return "", false
7338 }
7339 return cmd, true
7340}
7341
7342// startShellEscape runs a "!command" in the same shell the bash tool
7343// uses, in the session working directory, honoring the /jail sandbox.
7344// It shares the busy/cancel state with the agent: esc cancels it, and
7345// it refuses to start while a turn or another shell escape is already
7346// in flight. Its terminal-log output is appended to the transcript as
7347// user context without automatically starting a model turn.
7348func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
7349 i.mu.Lock()
7350 if i.busy || i.shellRunning {
7351 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
7352 i.statusOK = ""
7353 i.mu.Unlock()
7354 i.invalidate()
7355 return
7356 }
7357 if parent == nil {
7358 parent = i.runCtx
7359 }
7360 if parent == nil {
7361 parent = context.Background()
7362 }
7363 ctx, cancel := context.WithCancel(parent)
7364 i.busy = true
7365 i.shellRunning = true
7366 i.shellLive = "$ " + cmd + "\n\n"
7367 i.cancelTurn = cancel
7368 i.statusErr = ""
7369 i.statusOK = ""
7370 i.spin.Start()
7371 i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
7372 // Clear stale extension notes the same way a new turn would so the
7373 // screen doesn't accumulate transient state.
7374 i.extNotes = nil
7375 i.scrollOffset = 0
7376 i.parkedTurn = 0
7377 i.parkedTotal = 0
7378 i.helpBlock = nil
7379 sandbox := i.cfg.Sandbox
7380 cwd := i.cfg.CWD
7381 i.mu.Unlock()
7382 i.invalidate()
7383
7384 go func() {
7385 defer cancel()
7386 raw, _ := json.Marshal(map[string]any{"command": cmd})
7387 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
7388 progress := func(chunk string) {
7389 i.mu.Lock()
7390 i.shellLive += chunk
7391 i.mu.Unlock()
7392 i.invalidate()
7393 }
7394 res, err := bash.Execute(ctx, raw, progress)
7395
7396 var out string
7397 if err != nil {
7398 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
7399 } else {
7400 for _, c := range res.Content {
7401 if tb, ok := c.(provider.TextBlock); ok {
7402 out += tb.Text
7403 }
7404 }
7405 }
7406 cancelled := ctx.Err() != nil
7407 failed := err != nil || res.IsError || cancelled
7408 if cancelled {
7409 out += "\n\n[cancelled]"
7410 }
7411
7412 if i.agent != nil {
7413 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
7414 }
7415
7416 i.mu.Lock()
7417 i.shellRunning = false
7418 i.shellLive = ""
7419 i.busy = false
7420 i.cancelTurn = nil
7421 pendingIdleWork := i.takePendingIdleWorkLocked()
7422 awaitingPre := i.awaitingStartupPre
7423 if failed {
7424 if cancelled {
7425 i.statusErr = "shell command cancelled"
7426 } else {
7427 i.statusErr = "shell command failed"
7428 }
7429 i.statusOK = ""
7430 } else {
7431 i.statusOK = "shell command finished"
7432 i.statusErr = ""
7433 }
7434 i.mu.Unlock()
7435 runPendingIdleWork(pendingIdleWork)
7436 i.invalidate()
7437 if awaitingPre {
7438 i.completeStartupPre()
7439 }
7440 }()
7441}
7442
7443func (i *Interactive) startTurn(parent context.Context, prompt string) {
7444 i.startTurnWithImages(parent, prompt, nil)
7445}
7446
7447func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
7448 i.startTurnRequest(parent, prompt, images, false, false)
7449}
7450
7451// startTurnRequest starts a new prompt or continues an existing user turn.
7452// continueExisting selects Agent.Continue so callers can retry a transcript
7453// message without appending it twice. overflowRecoveryAttempted suppresses
7454// the pre-turn threshold guard and the one-shot overflow recovery path; a
7455// normal post-compaction continuation leaves it false so a still-too-large
7456// rebuilt context can recover once more.
7457func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
7458 if i.agent == nil {
7459 // Text startup pre cannot run without credentials; continue so
7460 // deferred InitialInput (pre-fill or auto-submit) still applies.
7461 i.mu.Lock()
7462 awaitingPre := i.awaitingStartupPre
7463 i.mu.Unlock()
7464 if awaitingPre {
7465 i.completeStartupPre()
7466 }
7467 return
7468 }
7469 // Pre-turn safety: if the most recent context measurement is
7470 // already past the auto-compact threshold, condense before
7471 // sending so the next outbound request stays under the limit.
7472 // The condense flow re-fires the user's queued prompt for us, so
7473 // we just hand it off and exit.
7474 i.mu.Lock()
7475 var resetHandoff json.RawMessage
7476 var persistResetHandoff bool
7477 if !continueExisting {
7478 resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
7479 }
7480 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7481 if needsPreCompact {
7482 // Reserve the compaction hand-off before releasing i.mu. A completion
7483 // update arriving in the small gap before runCompact acquires the
7484 // mutex must join the host queue rather than starting a competing turn.
7485 i.busy = true
7486 i.compacting = true
7487 i.autoCompacting = true
7488 if continueExisting {
7489 i.continueAfterCompact = true
7490 } else {
7491 i.pendingCompactPrompt = prompt
7492 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
7493 i.hasPendingCompactPrompt = true
7494 }
7495 i.statusErr = ""
7496 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
7497 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7498 i.mu.Unlock()
7499 if persistResetHandoff {
7500 i.persistCompactHandoff(resetHandoff)
7501 }
7502 i.invalidate()
7503 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7504 return
7505 }
7506 i.mu.Unlock()
7507 if persistResetHandoff {
7508 i.persistCompactHandoff(resetHandoff)
7509 }
7510
7511 ctx, cancel := context.WithCancel(parent)
7512 i.mu.Lock()
7513 i.busy = true
7514 i.spin.Start()
7515 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7516 i.cancelTurn = cancel
7517 i.statusErr = ""
7518 i.statusOK = ""
7519 i.streaming.Reset()
7520 i.streamOn = true
7521 i.pendingAlert = nil
7522 i.toolCalls = map[string]*tui.ToolCallView{}
7523 i.toolOrder = nil
7524 i.toolGate = map[string]int{}
7525 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
7526 i.scrollOffset = 0 // jump back to the bottom on new turn
7527 // Lift the resume tail cap once the user starts interacting. The
7528 // cap is purely a first-paint optimization (don't markdown the
7529 // whole history before showing anything). Keeping it active during
7530 // a turn makes the rendered chat a sliding window: appended
7531 // messages push older ones off the TOP of the buffer, which the
7532 // renderer must treat as a change above the viewport and repaint
7533 // fully, snapping the terminal's native scrollback to the bottom on
7534 // every streamed chunk. A fresh session has no cap (append-only),
7535 // which is why the jump only shows up in resumed sessions. Dropping
7536 // the cap here makes resumed turns append-only too.
7537 i.view.TailLimit = 0
7538 // Reset the auto-follow baseline so the very next render at
7539 // interactive.go:1053 doesn't see a synthetic shrink between
7540 // "last frame had the previous turn's tool overlay" and
7541 // "this frame had it cleared above". Without this, the guard
7542 // reads delta = -(rows in cleared overlay) and decrements
7543 // scrollOffset, which on terminals that mirror zut's pane
7544 // scroll into the host scrollbar visibly yanks the viewport.
7545 // See autofollow_shrink_test.go for the exact arithmetic.
7546 i.prevChatLen = 0
7547 i.prevChatCols = 0
7548 i.parkedTurn = 0 // starting a turn clears the /jump parked state
7549 i.parkedTotal = 0
7550 i.helpBlock = nil // hide the help block once the user asks something
7551 i.mu.Unlock()
7552 i.invalidate()
7553
7554 var (
7555 lastStop provider.StopReason
7556 lastTurnErr error
7557 )
7558 sink := func(ev core.AgentEvent) {
7559 if e, ok := ev.(core.EvTurnEnd); ok {
7560 lastStop = e.Stop
7561 lastTurnErr = e.Err
7562 }
7563 i.handleEvent(ev)
7564 i.invalidate()
7565 }
7566
7567 go func() {
7568 var err error
7569 if continueExisting {
7570 err = i.agent.Continue(ctx, sink)
7571 } else {
7572 err = i.agent.Prompt(ctx, prompt, images, sink)
7573 }
7574 i.mu.Lock()
7575 // Keep busy asserted through final cleanup and queue selection. A
7576 // watcher may enqueue a completion summary concurrently with the
7577 // provider's final event; publishing idle before inspecting queues can
7578 // strand that summary in the core agent queue or start an overlapping
7579 // turn.
7580 // Don't touch streamPending / streamFlushPending here — the
7581 // pacer may still be draining the final deltas and needs to
7582 // paint them even though Prompt has returned. It will reset
7583 // streamOn on its own once the buffer empties.
7584 if len(i.streamPending) == 0 {
7585 i.streamOn = false
7586 }
7587 i.cancelTurn = nil
7588 pendingIdleWork := i.takePendingIdleWorkLocked()
7589 if err != nil && ctx.Err() == nil {
7590 i.statusErr = err.Error()
7591 }
7592 // Decide whether to offer a model rescue picker for recoverable
7593 // provider failures (auth/rate/temporary). The picker opens after
7594 // the mutex is released so it can take its own locks freely.
7595 var (
7596 offer bool
7597 rescueWhy string
7598 rescueImgs []provider.ImageBlock
7599 rescueModel string
7600 rescueProv string
7601 rescueFprov string
7602 )
7603 if err != nil && ctx.Err() == nil {
7604 if ok, reason := classifyRescueError(err); ok {
7605 offer = true
7606 rescueWhy = reason
7607 rescueImgs = images
7608 rescueModel = i.cfg.Model
7609 rescueProv = i.cfg.Provider
7610 rescueFprov = extractFailedProvider(err)
7611 if rescueFprov == "" {
7612 rescueFprov = i.cfg.Provider
7613 }
7614 // Suppress the red banner — the rescue dialog already
7615 // surfaces the failure.
7616 i.statusErr = ""
7617 }
7618 }
7619 // Detect responses that reject the current context, either as an
7620 // HTTP 413 payload limit or a model context-window error. Token-
7621 // based auto-compact can miss both when metadata is stale or the
7622 // limit is measured in raw bytes. Compact once, then continue the
7623 // user message already present in the transcript.
7624 contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
7625 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
7626 if recoverContextOverflow {
7627 i.statusErr = ""
7628 i.continueAfterCompact = true
7629 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
7630 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
7631 }
7632 // Persist the assistant's reply (and every tool row before
7633 // it) to the session file while the turn memory is hot.
7634 // Without this, WriteNewTranscript only fires at zut exit,
7635 // meaning a crash or ungraceful kill drops the whole
7636 // conversation. FlushSession is idempotent (it advances the
7637 // baseline so subsequent flushes only write new rows).
7638 flush := i.cfg.FlushSession
7639 i.mu.Unlock()
7640 runPendingIdleWork(pendingIdleWork)
7641 if flush != nil {
7642 flush()
7643 }
7644 i.mu.Lock()
7645 awaitingPre := i.awaitingStartupPre
7646 // A newer explicit prompt may have cleared the handoff while the
7647 // completed turn was being flushed. Re-read it under the mutex before
7648 // deciding whether this continuation may spend another rescue attempt.
7649 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7650 // Pop the next queued message, if any, and relaunch.
7651 var next string
7652 var hasNext bool
7653 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
7654 next, i.queued = i.queued[0], i.queued[1:]
7655 hasNext = true
7656 }
7657 // If the turn was cancelled or errored, drop the queue so the
7658 // user isn't bombarded with stale messages after an interrupt.
7659 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
7660 i.queued = nil
7661 if i.agent != nil {
7662 i.agent.DrainQueuedMessages()
7663 }
7664 }
7665 // Decide whether the next thing to do is an auto-compaction.
7666 // Only fires when the turn completed cleanly AND no host-side
7667 // or agent-side queued messages are waiting (otherwise a queued
7668 // message would race the condense).
7669 agentQueued := 0
7670 if i.agent != nil {
7671 agentQueued = i.agent.QueuedMessageCount()
7672 }
7673 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7674 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7675 continueStatusRescue := false
7676 var handoff json.RawMessage
7677 var persistHandoff bool
7678 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7679 followUpMessages := i.agent.Messages()
7680 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7681 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7682 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7683 reason: compactContinuationStatusRescue,
7684 rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7685 })
7686 continueStatusRescue = true
7687 } else {
7688 handoff, persistHandoff = i.resetCompactContinuationLocked()
7689 }
7690 }
7691 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7692 handoff, persistHandoff = i.resetCompactContinuationLocked()
7693 }
7694 // The agent run can finish before the paced final text reaches the
7695 // transcript. A compaction replaces that transcript, so it must never
7696 // race the still-live stream frame; otherwise stale deltas can repaint
7697 // after the replacement and corrupt the scrollback renderer's model.
7698 if recoverContextOverflow || shouldAutoCompact {
7699 i.resetStreamingStateLocked()
7700 }
7701 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7702 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7703 i.mu.Unlock()
7704 if persistHandoff {
7705 i.persistCompactHandoff(handoff)
7706 }
7707 if alertReason != "" {
7708 i.scheduleMainAlert(alertReason)
7709 }
7710 i.invalidate()
7711 parent := i.runCtx
7712 if parent == nil {
7713 parent = context.Background()
7714 }
7715 if awaitingPre {
7716 i.completeStartupPre()
7717 return
7718 }
7719 switch {
7720 case hasNext:
7721 i.startTurn(parent, next)
7722 case continueQueued:
7723 i.startTurnRequest(parent, "", nil, true, false)
7724 case continueStatusRescue:
7725 i.startAutoCompactContinuation(parent)
7726 case offer:
7727 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7728 case recoverContextOverflow:
7729 i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
7730 case shouldAutoCompact:
7731 i.runCompact(parent, compactContinuationRequest{
7732 origin: compactOriginAfterTurnThreshold,
7733 force: lastStop == provider.StopLength,
7734 lastStop: lastStop,
7735 turnError: lastTurnErr,
7736 })
7737 }
7738 }()
7739}
7740
7741func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
7742 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
7743 return ""
7744 }
7745 if rescue {
7746 return "rescue_required"
7747 }
7748 if stop == provider.StopAborted {
7749 return ""
7750 }
7751 if err != nil || turnErr != nil || stop == provider.StopError {
7752 return "agent_error"
7753 }
7754 if stop == provider.StopLength {
7755 return "response_truncated"
7756 }
7757 return "agent_done"
7758}
7759
7760// openRescueDialog surfaces the rescue model picker after a recoverable
7761// provider failure. The pending prompt + images are stashed on the
7762// Interactive so a later applyRescueSelection can re-run the same turn
7763// against the freshly-picked model. activeProvider/failedProvider are
7764// usually the same, but some clients embed different prefixes in their
7765// errors than the configured provider id, so we accept both.
7766func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
7767 if i.rescueDialog == nil {
7768 return
7769 }
7770 loggedIn := []string{}
7771 if i.cfg.LoggedInProviders != nil {
7772 loggedIn = i.cfg.LoggedInProviders()
7773 }
7774 fprov := failedProvider
7775 if fprov == "" {
7776 fprov = activeProvider
7777 }
7778 i.mu.Lock()
7779 i.pendingRescuePrompt = prompt
7780 i.pendingRescueImages = images
7781 i.mu.Unlock()
7782 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
7783 i.invalidate()
7784}
7785
7786// applyRescueSelection switches model (cross-provider if needed) and
7787// re-runs the same prompt+images that just failed. Mirrors
7788// applyModelSelection's transcript-carry logic so the user keeps full
7789// session continuity across the swap.
7790func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
7791 if model == "" {
7792 return
7793 }
7794 i.applyRescueModelSelection(prov, model)
7795 i.mu.Lock()
7796 images := i.pendingRescueImages
7797 if prompt == "" {
7798 prompt = i.pendingRescuePrompt
7799 }
7800 i.pendingRescuePrompt = ""
7801 i.pendingRescueImages = nil
7802 i.mu.Unlock()
7803 parent := i.runCtx
7804 if parent == nil {
7805 parent = context.Background()
7806 }
7807 i.startTurnWithImages(parent, prompt, images)
7808}
7809
7810func stripAutoCompactNotes(notes []string) []string {
7811 if len(notes) == 0 {
7812 return notes
7813 }
7814 out := notes[:0]
7815 for _, n := range notes {
7816 if strings.Contains(n, "condensing history") {
7817 continue
7818 }
7819 out = append(out, n)
7820 }
7821 return out
7822}
7823
7824// autoCompactNoteLine returns a styled chat-area note for the
7825// inline auto-compact heads-up. Lives in extNotes so it survives
7826// the busy-spinner overwrite of the status row.
7827func autoCompactNoteLine(th tui.Theme, msg string) string {
7828 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7829}
7830
7831const defaultAutoCompactThreshold = 85
7832
7833func normalizeAutoCompactThreshold(threshold *int) int {
7834 if threshold == nil {
7835 return defaultAutoCompactThreshold
7836 }
7837 switch *threshold {
7838 case 0, 70, 80, 85, 90, 95:
7839 return *threshold
7840 default:
7841 return defaultAutoCompactThreshold
7842 }
7843}
7844
7845func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7846 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
7847 return false
7848 }
7849 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
7850}
7851
7852// classifyCompactionContinuation chooses a private handoff reason from the
7853// successful turn tail. Structural unfinished-tail handling is independent of
7854// the status matcher. A status rescue is admitted only for an after-turn
7855// threshold compaction or an already-active status handoff, and only after a
7856// normal successful end.
7857func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
7858 if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
7859 return compactContinuationStructuralTail
7860 }
7861 if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
7862 return compactContinuationNone
7863 }
7864 if likelyForwardWorkStatus(msgs) {
7865 return compactContinuationStatusRescue
7866 }
7867 return compactContinuationNone
7868}
7869
7870// structuralCompactionContinuation preserves the pre-existing unfinished-tail
7871// behavior without consulting the status matcher.
7872func structuralCompactionContinuation(msgs []provider.Message) bool {
7873 if len(msgs) == 0 {
7874 return false
7875 }
7876 last := msgs[len(msgs)-1]
7877 if last.Role != provider.RoleAssistant {
7878 return true
7879 }
7880 hasText := false
7881 for _, content := range last.Content {
7882 switch block := content.(type) {
7883 case provider.ToolCallBlock:
7884 return true
7885 case provider.TextBlock:
7886 if strings.TrimSpace(block.Text) != "" {
7887 hasText = true
7888 }
7889 case provider.ReasoningBlock:
7890 // Reasoning without a visible answer is not terminal.
7891 default:
7892 return true
7893 }
7894 }
7895 return !hasText
7896}
7897
7898// likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
7899// first-person future-work commitments and never treats generic plan prose as
7900// a host control signal.
7901func likelyForwardWorkStatus(msgs []provider.Message) bool {
7902 if len(msgs) == 0 {
7903 return false
7904 }
7905 last := msgs[len(msgs)-1]
7906 if last.Role != provider.RoleAssistant {
7907 return false
7908 }
7909 var text strings.Builder
7910 for _, content := range last.Content {
7911 block, ok := content.(provider.TextBlock)
7912 if !ok {
7913 if _, hasTool := content.(provider.ToolCallBlock); hasTool {
7914 return false
7915 }
7916 continue
7917 }
7918 if strings.TrimSpace(block.Text) == "" {
7919 continue
7920 }
7921 if text.Len() > 0 {
7922 text.WriteByte(' ')
7923 }
7924 text.WriteString(block.Text)
7925 }
7926 if text.Len() == 0 {
7927 return false
7928 }
7929 normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
7930 normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
7931 for _, phrase := range []string{
7932 "next i will",
7933 "next, i will",
7934 "i'll now",
7935 "i will now",
7936 "i am going to",
7937 "i need to continue",
7938 "then i will",
7939 "then, i will",
7940 "i will proceed",
7941 } {
7942 if containsFutureWorkPhrase(normalized, phrase) {
7943 return true
7944 }
7945 }
7946 return false
7947}
7948
7949func containsFutureWorkPhrase(text, phrase string) bool {
7950 for offset := 0; offset < len(text); {
7951 index := strings.Index(text[offset:], phrase)
7952 if index < 0 {
7953 return false
7954 }
7955 index += offset
7956 end := index + len(phrase)
7957 if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
7958 return true
7959 }
7960 offset = end
7961 }
7962 return false
7963}
7964
7965func wordRuneBefore(text string, index int) bool {
7966 if index == 0 {
7967 return false
7968 }
7969 rune_, _ := utf8.DecodeLastRuneInString(text[:index])
7970 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7971}
7972
7973func wordRuneAfter(text string, index int) bool {
7974 if index == len(text) {
7975 return false
7976 }
7977 rune_, _ := utf8.DecodeRuneInString(text[index:])
7978 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7979}
7980
7981// shouldAutoCompactLocked reports whether the last turn pushed context
7982// usage past the auto-compact threshold. Must be called with i.mu
7983// held; it reads lastCtxInput and the current model's context window.
7984func (i *Interactive) shouldAutoCompactLocked() bool {
7985 if i.agent == nil {
7986 return false
7987 }
7988 if i.autoCompacting {
7989 return false
7990 }
7991 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
7992 if err != nil || m.ContextWindow <= 0 {
7993 return false
7994 }
7995 threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
7996 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
7997}
7998
7999func (i *Interactive) handleEvent(ev core.AgentEvent) {
8000 i.mu.Lock()
8001 defer i.mu.Unlock()
8002 i.activity.apply(ev)
8003 switch e := ev.(type) {
8004 case core.EvAssistantStart:
8005 // Fires at the top of every oneTurn, including follow-up
8006 // turns after tool use. Without this, the streaming buffer
8007 // is still marked off from the previous assistant message
8008 // and the final summary text pops in all at once instead
8009 // of typewriter-streaming delta by delta.
8010 i.streaming.Reset()
8011 i.streamPending = i.streamPending[:0]
8012 i.streamFlushPending = false
8013 i.streamOn = true
8014 // Clear the live tool-call overlay. Any tools from the
8015 // previous round are now fully folded into the transcript
8016 // (assistant tool_use block + tool role message with the
8017 // result), so keeping them in the overlay would duplicate
8018 // them in the view — once inside the finalised transcript
8019 // and once below the streaming block, with the streaming
8020 // summary sandwiched in between. The next EvToolUseStart
8021 // will populate fresh entries for this turn's tools.
8022 i.toolCalls = map[string]*tui.ToolCallView{}
8023 i.toolOrder = nil
8024 i.toolGate = map[string]int{}
8025 case core.EvTextDelta:
8026 // Buffer into streamPending; the paintPace ticker drains
8027 // it into i.streaming a few runes at a time for a smooth
8028 // typewriter effect independent of upstream chunk size.
8029 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
8030 i.streamOn = true
8031 case core.EvAssistantMessage:
8032 // OnAssistant + telegram mirroring always fire on message
8033 // arrival — they read the FINAL message content, which is
8034 // complete regardless of what's still in the pacer.
8035 i.assistantMessageSideEffects(e.Message)
8036 // If the pacer still has characters to drain, keep streamOn
8037 // true and mark flush pending; the paintPace ticker will
8038 // drain the remainder and reset streaming state when done.
8039 // Otherwise (rare: full-replay sessions, abort paths) clear
8040 // synchronously so a later render doesn't show stale text.
8041 if len(i.streamPending) > 0 {
8042 i.streamFlushPending = true
8043 return
8044 }
8045 i.resetStreamingStateLocked()
8046 case core.EvToolUseStart:
8047 // Live streaming: pre-create the view so the user sees the
8048 // tool call being composed in real time. Any subsequent
8049 // EvToolCall for the same ID updates the same struct (the
8050 // final parsed args + name are already known here).
8051 if _, exists := i.toolCalls[e.ID]; !exists {
8052 i.toolCalls[e.ID] = &tui.ToolCallView{
8053 ID: e.ID,
8054 Name: e.Name,
8055 Streaming: true,
8056 }
8057 i.toolOrder = append(i.toolOrder, e.ID)
8058 i.gateToolLocked(e.ID)
8059 }
8060 case core.EvToolUseArgs:
8061 if tc, ok := i.toolCalls[e.ID]; ok {
8062 tc.RawJSONBuf += e.Delta
8063 // Refresh the live path as soon as it parses; used in
8064 // the header (write /Users/example/Desktop/demo.ts)
8065 // while the content is still streaming.
8066 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
8067 tc.LivePath = p
8068 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
8069 tc.LivePath = p
8070 }
8071 }
8072 case core.EvToolUseEnd:
8073 if tc, ok := i.toolCalls[e.ID]; ok {
8074 tc.Streaming = false
8075 }
8076 case core.EvToolCall:
8077 // If we already pre-created the view during streaming, just
8078 // refresh the final Args summary. Otherwise create a new one
8079 // (non-streaming providers or legacy paths).
8080 if tc, ok := i.toolCalls[e.ID]; ok {
8081 tc.Args = tui.ShortArgs(e.Name, e.Args)
8082 if tc.RawJSONBuf == "" {
8083 tc.RawJSONBuf = string(e.Args)
8084 }
8085 tc.Streaming = false
8086 } else {
8087 i.toolCalls[e.ID] = &tui.ToolCallView{
8088 ID: e.ID,
8089 Name: e.Name,
8090 Args: tui.ShortArgs(e.Name, e.Args),
8091 RawJSONBuf: string(e.Args),
8092 }
8093 i.toolOrder = append(i.toolOrder, e.ID)
8094 i.gateToolLocked(e.ID)
8095 }
8096 case core.EvToolResult:
8097 if tc, ok := i.toolCalls[e.ID]; ok {
8098 tc.Done = true
8099 tc.Error = e.Result.IsError
8100 tc.Preview = ""
8101 var text strings.Builder
8102 for _, c := range e.Result.Content {
8103 if tb, ok := c.(provider.TextBlock); ok {
8104 if text.Len() > 0 {
8105 text.WriteString("\n")
8106 }
8107 text.WriteString(tb.Text)
8108 }
8109 }
8110 tc.Result = text.String()
8111 }
8112 if i.cfg.OnToolResult != nil {
8113 i.cfg.OnToolResult(e.ID, e.Result)
8114 }
8115 case core.EvUsage:
8116 i.cumUsage = e.Cumulative
8117 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
8118 i.lastCtxInput = contextUsed
8119 }
8120 case core.EvTurnEnd:
8121 if e.Stop == provider.StopAborted {
8122 i.resetStreamingStateLocked()
8123 i.statusErr = ""
8124 i.statusOK = "cancelled"
8125 return
8126 }
8127 if e.Stop == provider.StopLength {
8128 // The model hit its output-token cap mid-response, so the
8129 // reply (often a long write/edit) is truncated. Surface it
8130 // explicitly, otherwise the turn just ends and reads like
8131 // the UI gave up. The agent already requests the model's
8132 // full MaxOutput budget, so this means the response genuinely
8133 // exceeded that ceiling; ask the user to continue.
8134 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
8135 i.statusOK = ""
8136 return
8137 }
8138 // Don't surface mid-loop stream errors as a red banner here.
8139 // EvTurnEnd fires after every step in a multi-step tool loop,
8140 // so a transient 503 / network blip would briefly paint a red
8141 // banner over the still-streaming chat before the agent loop
8142 // either retries or exits. The final error (if any) is set by
8143 // startTurnWithImages once Prompt() returns, and recoverable
8144 // failures are routed to the rescue picker instead — which
8145 // keeps the chat clean while the agent is working.
8146 _ = e.Err
8147 }
8148}
8149
8150// WebSearchPolicyGeneration returns the generation a resolver must present at
8151// its final interactive commit. Policy transitions advance it before changing
8152// the gate or generic-child ceiling.
8153func (i *Interactive) WebSearchPolicyGeneration() uint64 {
8154 return i.webSearchPolicyGeneration.Load()
8155}
8156
8157// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
8158// ag is still the live interactive agent. Callers without a long-running
8159// resolve use the current policy generation.
8160func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
8161 return i.applyAgentPromptConfig(ag, system, tools, 0, false)
8162}
8163
8164// ApplyAgentPromptConfigAtWebSearchGeneration additionally rejects a registry
8165// resolved before a web-search policy transition. This final check is made in
8166// the same critical section as live-agent replacement and the core prompt swap.
8167func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
8168 return i.applyAgentPromptConfig(ag, system, tools, generation, true)
8169}
8170
8171func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
8172 if ag == nil {
8173 return nil, false
8174 }
8175 // Match the lock order used by dynamic tool mutations so a refresh
8176 // cannot race a snapshot-and-replace operation that would reintroduce
8177 // the old registry after the atomic prompt swap.
8178 i.agentMu.Lock()
8179 defer i.agentMu.Unlock()
8180 i.mu.Lock()
8181 defer i.mu.Unlock()
8182 if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
8183 return nil, false
8184 }
8185 if i.telegramBridge != nil {
8186 // Telegram prompts arrive over an external messaging channel without a
8187 // per-request confirmation surface. Keep the normal interactive
8188 // registry from reintroducing web_search from the moment the bridge is
8189 // attached, including while its startup handshake is in flight.
8190 delete(tools, "web_search")
8191 }
8192 oldTools := ag.SetPromptConfig(system, tools)
8193 _, webSearchAvailable := tools["web_search"]
8194 i.setWebSearchAvailable(webSearchAvailable)
8195 return oldTools, true
8196}
8197
8198// prepareReplacementAgentLocked applies session-wide capability policy before
8199// ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
8200func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
8201 if ag == nil {
8202 i.setWebSearchAvailable(false)
8203 return
8204 }
8205 registry := ag.ToolsSnapshot()
8206 if i.telegramBridge != nil {
8207 // External Telegram prompts may arrive as soon as the replacement is
8208 // published, so post-swap cleanup is too late.
8209 delete(registry, "web_search")
8210 ag.SetTools(registry)
8211 }
8212 _, webSearchAvailable := registry["web_search"]
8213 i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
8214}
8215
8216// DeferUntilIdle runs work immediately when the interactive agent is idle,
8217// or after the current turn/compaction/shell operation releases the busy
8218// state. Callbacks run outside i.mu.
8219func (i *Interactive) DeferUntilIdle(fn func()) {
8220 if fn == nil {
8221 return
8222 }
8223 i.mu.Lock()
8224 if i.busy {
8225 i.pendingIdleWork = append(i.pendingIdleWork, fn)
8226 i.mu.Unlock()
8227 return
8228 }
8229 i.mu.Unlock()
8230 fn()
8231}
8232
8233func (i *Interactive) takePendingIdleWorkLocked() []func() {
8234 work := i.pendingIdleWork
8235 i.pendingIdleWork = nil
8236 return work
8237}
8238
8239func runPendingIdleWork(work []func()) {
8240 for _, fn := range work {
8241 if fn != nil {
8242 fn()
8243 }
8244 }
8245}
8246
8247// Agent returns the current agent, if any. Used by cli.go to flush the
8248// final transcript to the session file.
8249func (i *Interactive) Agent() *core.Agent {
8250 i.mu.Lock()
8251 defer i.mu.Unlock()
8252 return i.agent
8253}
8254
8255// silence unused import in some build configs
8256var _ = fmt.Sprintf
8257
8258// runReloadExt triggers a live reload of every extension (discovered
8259// + explicit). Runs on a goroutine so the TUI stays responsive; the
8260// Manager.Reload takes a couple of hundred ms to shut down subprocs
8261// and respawn them. Shows a temporary status line throughout.
8262func (i *Interactive) runReloadExt(ctx context.Context) {
8263 if i.cfg.Extensions == nil {
8264 i.mu.Lock()
8265 i.statusErr = "no extension manager in this build"
8266 i.mu.Unlock()
8267 i.invalidate()
8268 return
8269 }
8270 i.mu.Lock()
8271 i.statusOK = "Reloading extensions..."
8272 i.statusErr = ""
8273 i.mu.Unlock()
8274 i.invalidate()
8275
8276 go func() {
8277 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
8278 msg, failed := formatReloadStatus(stats)
8279 seq := i.setReloadStatus(msg, failed)
8280 i.invalidate()
8281 go i.dismissReloadStatus(ctx, seq, msg, failed)
8282 }()
8283}
8284
8285// Confirm implements core.Confirmer. The agent goroutine calls
8286// this synchronously before every tool invocation when --no-yolo is
8287// active. We push the request onto the confirmDialog queue, trigger
8288// a redraw, and block the caller until the user answers.
8289//
8290// If the session is cancelled or the TUI exits mid-prompt, any
8291// pending request is refused via CancelAll so the agent doesn't
8292// deadlock.
8293func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
8294 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
8295}
8296
8297// ConfirmToolCall attaches a side-effect-free preview to the matching live
8298// tool panel, then blocks until the user approves or refuses the call.
8299func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
8300 resp := make(chan core.ConfirmDecision, 1)
8301 if isBtwOrigin(call.Origin) {
8302 req := &confirmRequest{
8303 toolName: call.Name,
8304 preview: call.Summary,
8305 resp: resp,
8306 returnToChild: true,
8307 }
8308 if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
8309 i.confirmDialog.Enqueue(req)
8310 }) {
8311 return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
8312 }
8313 i.invalidate()
8314 return <-resp
8315 }
8316 if call.ID != "" {
8317 i.mu.Lock()
8318 if tc, ok := i.toolCalls[call.ID]; ok {
8319 tc.Args = call.Summary
8320 tc.Preview = call.Content
8321 i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
8322 }
8323 i.mu.Unlock()
8324 }
8325 i.confirmDialog.Enqueue(&confirmRequest{
8326 toolName: call.Name,
8327 preview: call.Summary,
8328 resp: resp,
8329 })
8330 i.invalidate()
8331 return <-resp
8332}
8333
8334// openTelegramDialog shows the picker for `/telegram` with no arg.
8335// Items depend on current state: disconnect + status when running,
8336// connect + status when stopped.
8337func (i *Interactive) openTelegramDialog() {
8338 items := i.telegramMenuItems()
8339 if len(items) == 0 {
8340 i.mu.Lock()
8341 i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
8342 i.mu.Unlock()
8343 i.invalidate()
8344 return
8345 }
8346 i.telegramDialog.Open(items)
8347 i.invalidate()
8348}
8349
8350// telegramMenuItems builds the dialog entries for the current
8351// bridge state. Returns empty when no bot.json exists so the
8352// caller can show a helpful status line instead of an empty menu.
8353func (i *Interactive) telegramMenuItems() []telegramItem {
8354 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8355 if err != nil || cfg.BotToken == "" {
8356 return nil
8357 }
8358 var items []telegramItem
8359 if i.telegramBridge != nil && i.telegramBridge.Active() {
8360 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
8361 st := i.telegramBridge.State()
8362 hint := "active"
8363 if st.Username != "" {
8364 hint += " as @" + st.Username
8365 }
8366 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
8367 } else {
8368 label := "connect"
8369 hint := "start mirroring dms into this session"
8370 if cfg.AllowedUserID == 0 {
8371 hint = "awaiting pairing (send /start to the bot once connected)"
8372 }
8373 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
8374 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
8375 }
8376 return items
8377}
8378
8379// doTelegram dispatches one of the three explicit actions. Called
8380// from /telegram <action> or after the picker selects a row.
8381func (i *Interactive) doTelegram(action string) {
8382 switch action {
8383 case "connect":
8384 i.telegramConnect()
8385 case "disconnect":
8386 i.telegramDisconnect()
8387 case "status":
8388 i.telegramStatus()
8389 default:
8390 i.mu.Lock()
8391 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
8392 i.mu.Unlock()
8393 i.invalidate()
8394 }
8395}
8396
8397// telegramConnect starts the bridge. Refuses if it's already
8398// running or if the on-disk bot.json is missing a token.
8399func (i *Interactive) telegramConnect() {
8400 if i.telegramBridge != nil && i.telegramBridge.Active() {
8401 i.mu.Lock()
8402 i.statusOK = "telegram already connected"
8403 i.statusErr = ""
8404 i.mu.Unlock()
8405 i.invalidate()
8406 return
8407 }
8408 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8409 if err != nil {
8410 i.mu.Lock()
8411 i.statusErr = "telegram: " + err.Error()
8412 i.mu.Unlock()
8413 i.invalidate()
8414 return
8415 }
8416 if cfg.BotToken == "" {
8417 i.mu.Lock()
8418 i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
8419 i.mu.Unlock()
8420 i.invalidate()
8421 return
8422 }
8423 // Refuse to start when a background daemon is already polling
8424 // the same bot. Two concurrent long-poll consumers race each
8425 // update and one always loses, so DMs get half-delivered. The
8426 // user can `zut telegram-bot stop` first, then /telegram connect.
8427 if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8428 i.mu.Lock()
8429 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
8430 i.mu.Unlock()
8431 i.invalidate()
8432 return
8433 }
8434 bridge := &telegram.Bridge{
8435 Client: telegram.NewClient(cfg.BotToken),
8436 Config: cfg,
8437 Save: func(next telegram.Config) error {
8438 return telegram.SaveConfig(i.cfg.ZutHome, next)
8439 },
8440 Host: &telegramHost{iv: i},
8441 }
8442 i.mu.Lock()
8443 i.telegramBridge = bridge
8444 i.mu.Unlock()
8445 // Strip web_search before the bridge's startup handshake can accept a
8446 // Telegram update. ApplyAgentPromptConfig also keeps it stripped from
8447 // concurrent refreshes while this bridge pointer is attached.
8448 i.applyTelegramTools(true)
8449 if err := bridge.Start(i.runCtx); err != nil {
8450 i.mu.Lock()
8451 i.telegramBridge = nil
8452 i.mu.Unlock()
8453 refreshErr := i.refreshToolsAfterTelegram()
8454 i.mu.Lock()
8455 i.statusErr = "telegram connect failed: " + err.Error()
8456 if refreshErr != nil {
8457 i.statusErr += "; tool refresh: " + refreshErr.Error()
8458 }
8459 i.mu.Unlock()
8460 i.invalidate()
8461 return
8462 }
8463 state := bridge.State()
8464 label := "telegram connected"
8465 if state.Username != "" {
8466 label += " as @" + state.Username
8467 }
8468 if state.PairedID == 0 {
8469 label += " — send /start to the bot from your phone to claim it"
8470 }
8471 i.mu.Lock()
8472 i.statusOK = label
8473 i.statusErr = ""
8474 i.mu.Unlock()
8475 i.invalidate()
8476}
8477
8478// telegramDisconnect stops the bridge. No-op when already stopped.
8479func (i *Interactive) telegramDisconnect() {
8480 if i.telegramBridge == nil || !i.telegramBridge.Active() {
8481 i.mu.Lock()
8482 i.statusOK = "telegram already disconnected"
8483 i.statusErr = ""
8484 i.mu.Unlock()
8485 i.invalidate()
8486 return
8487 }
8488 bridge := i.telegramBridge
8489 bridge.Stop()
8490 i.mu.Lock()
8491 // Clear the pointer before rebuilding the normal registry. This both
8492 // allows web_search back into the refresh and marks the bridge inactive
8493 // for any concurrent prompt-config commit.
8494 i.telegramBridge = nil
8495 i.mu.Unlock()
8496 refreshErr := i.refreshToolsAfterTelegram()
8497 i.mu.Lock()
8498 if refreshErr != nil {
8499 i.statusOK = ""
8500 i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error()
8501 } else {
8502 i.statusOK = "telegram disconnected"
8503 i.statusErr = ""
8504 }
8505 i.mu.Unlock()
8506 i.invalidate()
8507}
8508
8509// refreshToolsAfterTelegram restores normal tools only through a successful
8510// resolver refresh. Failure removes Telegram-only tools but leaves web_search
8511// revoked in the live registry, stale snapshots, and generic-child policy.
8512func (i *Interactive) refreshToolsAfterTelegram() error {
8513 if i.cfg.RefreshTools == nil {
8514 i.stripWebSearchTool()
8515 i.applyTelegramTools(false)
8516 return errors.New("live tool refresh is unavailable")
8517 }
8518 if err := i.cfg.RefreshTools(); err != nil {
8519 i.stripWebSearchTool()
8520 i.applyTelegramTools(false)
8521 return err
8522 }
8523 i.applyTelegramTools(false)
8524 return nil
8525}
8526
8527// telegramSenderAdapter wraps the bridge so the tools package can
8528// drive it without importing telegram directly. The Active() check
8529// is forwarded to the bridge so the tool can fail clearly with a
8530// model-readable error when the user disconnected mid-turn.
8531type telegramSenderAdapter struct {
8532 bridge *telegram.Bridge
8533}
8534
8535func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
8536 if a.bridge == nil {
8537 return fmt.Errorf("telegram bridge is not connected")
8538 }
8539 return a.bridge.SendImage(ctx, path, caption)
8540}
8541
8542func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
8543 if a.bridge == nil {
8544 return fmt.Errorf("telegram bridge is not connected")
8545 }
8546 return a.bridge.SendDocument(ctx, path, caption)
8547}
8548
8549func (a telegramSenderAdapter) Active() bool {
8550 return a.bridge != nil && a.bridge.Active()
8551}
8552
8553// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
8554// delegated turn's outcome separately from the long-lived daemon status.
8555type subagentWatchEntry struct {
8556 agent *subagents.Agent
8557 task string
8558 done bool
8559 outcome string
8560 err string
8561}
8562
8563// TrackSubagentWorker is the exported entry point used by the cli to
8564// hand a freshly-spawned auto-subagents agent off to the watcher.
8565func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
8566 i.trackSubagentWorker(a, task)
8567}
8568
8569// trackSubagentWorker records a freshly-spawned auto-subagents agent and
8570// subscribes to both ways its delegated task can finish. Successful
8571// long-lived agents report a prompt-level turn_end and keep listening;
8572// startup failures and unexpected daemon exits only unblock Agent.Wait.
8573// Whichever signal arrives first completes the entry exactly once.
8574//
8575// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
8576// subagent is enabled, so this is a no-op when the feature is off.
8577func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
8578 if i == nil || a == nil {
8579 return
8580 }
8581 entry := &subagentWatchEntry{agent: a, task: task}
8582 i.subagentWatchMu.Lock()
8583 i.subagentWatch = append(i.subagentWatch, entry)
8584 i.subagentWatchMu.Unlock()
8585 i.invalidate()
8586
8587 a.SetOnTurnEnd(func(step int, errMsg string) {
8588 outcome := "completed"
8589 if errMsg != "" {
8590 outcome = "failed"
8591 }
8592 i.completeSupervisorWatchEntry(entry, outcome, errMsg)
8593 })
8594 go func() {
8595 a.Wait()
8596 snap := a.Snapshot()
8597 outcome := string(snap.Status)
8598 if snap.Status == subagents.StatusDone {
8599 outcome = "completed"
8600 }
8601 i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
8602 }()
8603}
8604
8605func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
8606 i.subagentWatchMu.Lock()
8607 if entry.done {
8608 i.subagentWatchMu.Unlock()
8609 return
8610 }
8611 entry.done = true
8612 entry.outcome = outcome
8613 entry.err = errMsg
8614 allDone := true
8615 for _, e := range i.subagentWatch {
8616 if !e.done {
8617 allDone = false
8618 break
8619 }
8620 }
8621 var batch []*subagentWatchEntry
8622 if allDone {
8623 batch = i.subagentWatch
8624 i.subagentWatch = nil
8625 }
8626 i.subagentWatchMu.Unlock()
8627 if len(batch) != 0 {
8628 i.flushSupervisorSummary(batch)
8629 }
8630}
8631
8632// flushSupervisorSummary composes a synthetic user turn describing every
8633// sub-agent's outcome and injects it via SubmitOrQueue so the main
8634// agent picks it up at the next safe boundary. The summary is
8635// phrased as a system update ("Auto-subagents finished: ...") so the
8636// model treats it as observed state, not as a fresh user request.
8637func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8638 if len(batch) == 0 {
8639 return
8640 }
8641 var sb strings.Builder
8642 fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
8643 for idx, e := range batch {
8644 snap := e.agent.Snapshot()
8645 status := e.outcome
8646 if status == "" {
8647 status = string(snap.Status)
8648 }
8649 task := snap.Task
8650 if task == "" {
8651 task = e.task
8652 }
8653 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
8654 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
8655 if snap.Err != "" {
8656 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
8657 } else if e.err != "" {
8658 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
8659 }
8660 if snap.LastAssistant != "" {
8661 sb.WriteString(" final response:\n")
8662 sb.WriteString(snap.LastAssistant)
8663 sb.WriteString("\n")
8664 } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
8665 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
8666 }
8667 sb.WriteString("\n")
8668 }
8669 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.")
8670 i.SubmitOrQueue(sb.String(), nil)
8671}
8672
8673func truncateForSummary(s string, n int) string {
8674 s = strings.TrimSpace(s)
8675 if len(s) <= n {
8676 return s
8677 }
8678 return s[:n-3] + "..."
8679}
8680
8681// autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
8682// toggle, in the same order Resolve appends them to a new agent.
8683func autoSubagentsAddenda(cfg InteractiveConfig) []string {
8684 addenda := make([]string, 0, 2)
8685 if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
8686 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
8687 addenda = append(addenda, addendum)
8688 }
8689 }
8690 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
8691 addenda = append(addenda, addendum)
8692 }
8693 return addenda
8694}
8695
8696func containsAutoSubagentsAddendum(addenda []string, want string) bool {
8697 for _, addendum := range addenda {
8698 if addendum == want {
8699 return true
8700 }
8701 }
8702 return false
8703}
8704
8705// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
8706// have been appended by auto-subagents. Resolve appends owned blocks at the
8707// end, so removing the last occurrence preserves an identical base block.
8708func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
8709 idx := strings.LastIndex(system, addendum)
8710 if idx < 0 {
8711 return system, false
8712 }
8713 return system[:idx] + system[idx+len(addendum):], true
8714}
8715
8716// applyAutoSubagentsSystemPrompt appends (active=true) or strips
8717// (active=false) the auto-subagents prompt blocks on the running agent.
8718// The profile manifest and delegation guidance are managed together so
8719// toggling auto-subagents never leaves the model with names but no tool.
8720func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
8721 // i.mu is sufficient here: this path updates the existing agent's system
8722 // prompt and managed addenda in place; it does not replace the agent or
8723 // its tool registry, so agentMu is not required.
8724 i.mu.Lock()
8725 defer i.mu.Unlock()
8726 if i.agent == nil {
8727 return
8728 }
8729 addenda := autoSubagentsAddenda(i.cfg)
8730 if active {
8731 sys, _ := i.agent.PromptConfig()
8732 for _, addendum := range addenda {
8733 if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
8734 continue
8735 }
8736 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
8737 sys += "\n\n"
8738 }
8739 sys += addendum
8740 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
8741 }
8742 i.agent.SetSystemPrompt(sys)
8743 return
8744 }
8745
8746 sys, _ := i.agent.PromptConfig()
8747 changed := false
8748 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
8749 var removed bool
8750 sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
8751 changed = changed || removed
8752 }
8753 i.managedAutoSubagentsAddenda = nil
8754 if changed {
8755 i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
8756 }
8757}
8758
8759// applyAutoSubagentsTool registers (active=true) or removes (active=false)
8760// the auto-subagent tools on the running agent so the model only sees them
8761// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
8762// snapshot+mutate pattern so extension tools and /reload-ext additions
8763// survive a toggle.
8764func (i *Interactive) autoSubagentsAvailable() bool {
8765 return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
8766}
8767
8768func (i *Interactive) autoSubagentsUnavailableHint() string {
8769 var hints []string
8770 if i.cfg.Supervisor == nil {
8771 hints = append(hints, "subagent supervisor not available in this mode")
8772 }
8773 if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
8774 hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
8775 }
8776 return strings.Join(hints, "; ")
8777}
8778
8779func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
8780 return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
8781}
8782
8783func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
8784 if cfg.AutoSubagentsStatusToolAllowed != nil {
8785 return *cfg.AutoSubagentsStatusToolAllowed
8786 }
8787 // Preserve the old single-boolean embedding contract: when callers have
8788 // not supplied the new field, status follows the existing delegation gate.
8789 return autoSubagentsToolAllowedConfig(cfg)
8790}
8791
8792func (i *Interactive) autoSubagentsToolAllowed() bool {
8793 return autoSubagentsToolAllowedConfig(i.cfg)
8794}
8795
8796func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
8797 return autoSubagentsStatusToolAllowedConfig(i.cfg)
8798}
8799
8800func (i *Interactive) autoSubagentsEnabled() bool {
8801 i.mu.Lock()
8802 defer i.mu.Unlock()
8803 return i.autoSubagentsEnabledLocked()
8804}
8805
8806func (i *Interactive) autoSubagentsEnabledLocked() bool {
8807 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
8808}
8809
8810func (i *Interactive) applyAutoSubagentsTool(active bool) {
8811 i.agentMu.Lock()
8812 defer i.agentMu.Unlock()
8813 i.mu.Lock()
8814 defer i.mu.Unlock()
8815 if i.agent == nil {
8816 return
8817 }
8818 current := i.agent.ToolsSnapshot()
8819 next := core.Registry{}
8820 for name, t := range current {
8821 if name == "subagent_spawn" || name == "subagent_status" {
8822 continue
8823 }
8824 next[name] = t
8825 }
8826 if active && i.autoSubagentsAvailable() {
8827 if i.autoSubagentsToolAllowed() {
8828 canonical := &tools.SubagentSpawnTool{
8829 Supervisor: i.cfg.Supervisor,
8830 Enabled: func() bool { return i.autoSubagentsEnabled() },
8831 DefaultModel: func() string { return i.cfg.Model },
8832 DefaultProvider: func() string { return i.cfg.Provider },
8833 DefaultReasoning: func() string { return i.cfg.Reasoning },
8834 ResolveSubagent: i.cfg.ResolveSubagent,
8835 OnSpawned: i.trackSubagentWorker,
8836 }
8837 next[canonical.Name()] = canonical
8838 }
8839 if i.autoSubagentsStatusToolAllowed() {
8840 statusTool := &tools.SubagentStatusTool{
8841 Supervisor: i.cfg.Supervisor,
8842 Enabled: func() bool { return i.autoSubagentsEnabled() },
8843 }
8844 next[statusTool.Name()] = statusTool
8845 }
8846 }
8847 i.agent.SetTools(next)
8848}
8849
8850// applyTelegramTools registers (active=true) or removes (active=false)
8851// the telegram_send_image and telegram_send_file tools on the running
8852// agent so the model only sees them while the bridge is connected.
8853// Snapshots and mutates the live tool registry so any extension or
8854// /reload-ext additions made while Telegram is connected survive a
8855// later /telegram disconnect. The two Telegram entries are always replaced;
8856// web_search is additionally stripped only while the external bridge is active.
8857func (i *Interactive) applyTelegramTools(active bool) {
8858 if active {
8859 // The child ceiling must be in place even when no live main agent exists,
8860 // and before Bridge.Start can accept an external prompt.
8861 i.setWebSearchAvailable(false)
8862 }
8863 i.agentMu.Lock()
8864 defer i.agentMu.Unlock()
8865 i.mu.Lock()
8866 defer i.mu.Unlock()
8867 if i.agent == nil {
8868 return
8869 }
8870 current := i.agent.ToolsSnapshot()
8871 next := core.Registry{}
8872 for name, t := range current {
8873 if name == "telegram_send_image" || name == "telegram_send_file" {
8874 continue
8875 }
8876 if active && name == "web_search" {
8877 // External Telegram prompts have no per-request confirmation
8878 // surface, so V1 does not expose native web search while paired.
8879 continue
8880 }
8881 next[name] = t
8882 }
8883 if active {
8884 sender := telegramSenderAdapter{bridge: i.telegramBridge}
8885 next["telegram_send_image"] = &tools.TelegramSendImageTool{
8886 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
8887 }
8888 next["telegram_send_file"] = &tools.TelegramSendFileTool{
8889 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
8890 }
8891 }
8892 i.agent.SetTools(next)
8893 _, webSearchAvailable := next["web_search"]
8894 i.setWebSearchAvailable(webSearchAvailable && !active)
8895}
8896
8897// telegramStatus writes a one-liner describing the bridge state.
8898// Reports on both the in-tui bridge and the background daemon so
8899// the user isn't confused when the daemon owns the poll loop.
8900func (i *Interactive) telegramStatus() {
8901 var msg string
8902 if i.telegramBridge != nil && i.telegramBridge.Active() {
8903 s := i.telegramBridge.State()
8904 msg = "telegram: connected (tui bridge)"
8905 if s.Username != "" {
8906 msg += " as @" + s.Username
8907 }
8908 if s.PairedID != 0 {
8909 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
8910 } else {
8911 msg += " - awaiting pairing"
8912 }
8913 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8914 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
8915 } else {
8916 cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
8917 if cfg.BotToken == "" {
8918 msg = "telegram: not configured. run `zut telegram-bot setup` first."
8919 } else {
8920 msg = "telegram: disconnected"
8921 if cfg.BotUsername != "" {
8922 msg += " (@" + cfg.BotUsername + " ready to connect)"
8923 }
8924 }
8925 }
8926 i.mu.Lock()
8927 i.statusOK = msg
8928 i.statusErr = ""
8929 i.mu.Unlock()
8930 i.invalidate()
8931}
8932
8933// telegramHost adapts *Interactive to telegram.Host so the bridge
8934// can call back into the TUI without importing modes directly.
8935type telegramHost struct{ iv *Interactive }
8936
8937func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
8938 h.iv.SubmitOrQueue(prompt, images)
8939}
8940
8941func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
8942
8943func (h *telegramHost) Status() string {
8944 h.iv.mu.Lock()
8945 providerName := h.iv.cfg.Provider
8946 model := h.iv.cfg.Model
8947 cwd := h.iv.cfg.CWD
8948 usage := h.iv.cumUsage
8949 subscription := h.iv.cfg.AuthMethod == "oauth"
8950 ctxUsed := h.iv.lastCtxInput
8951 busy := h.iv.busy
8952 queued := len(h.iv.queued)
8953 h.iv.mu.Unlock()
8954
8955 ctxMax := 0
8956 if m, err := provider.FindModel(providerName, model); err == nil {
8957 ctxMax = m.ContextWindow
8958 }
8959 return telegram.FormatStatus(telegram.StatusSnapshot{
8960 Provider: providerName,
8961 Model: model,
8962 CWD: cwd,
8963 Usage: usage,
8964 Subscription: subscription,
8965 ContextUsed: ctxUsed,
8966 ContextMax: ctxMax,
8967 Busy: busy,
8968 Queued: queued,
8969 })
8970}
8971
8972func (h *telegramHost) Notify(level, message string) {
8973 h.iv.mu.Lock()
8974 switch level {
8975 case "error", "warn":
8976 h.iv.statusErr = message
8977 h.iv.statusOK = ""
8978 default:
8979 h.iv.statusOK = message
8980 h.iv.statusErr = ""
8981 }
8982 h.iv.mu.Unlock()
8983 h.iv.invalidate()
8984}
8985
8986// openSessionOpsDialog shows the picker for `/session` with no arg.
8987// Always offers export, import, fork, tree; the handlers bail with
8988// a clear status message when the precondition isn't met (empty
8989// transcript on fork; no parent/siblings on tree).
8990func (i *Interactive) openSessionOpsDialog() {
8991 items := []sessionOpsItem{
8992 {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
8993 {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
8994 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
8995 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
8996 }
8997 i.sessionOpsDialog.Open(items)
8998 i.invalidate()
8999}
9000
9001// doSessionOp dispatches export, import, fork, or tree. arg is the
9002// optional positional argument from e.g. /session export <path>
9003// or /session import <path>; fork and tree ignore it.
9004func (i *Interactive) doSessionOp(action, arg string) {
9005 switch action {
9006 case "export":
9007 i.doSessionExport(arg)
9008 case "import":
9009 i.doSessionImport(arg)
9010 case "fork":
9011 i.doSessionFork()
9012 case "tree":
9013 i.doSessionTree()
9014 default:
9015 i.mu.Lock()
9016 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
9017 i.mu.Unlock()
9018 i.invalidate()
9019 }
9020}
9021
9022// doSessionExport writes the live session file to destination path
9023// dst. When dst is empty we default to ~/Downloads (falling back to
9024// the user's home directory if it doesn't exist). The helper
9025// expands a leading `~` and creates any missing parent directories.
9026func (i *Interactive) doSessionExport(dst string) {
9027 src := i.currentSessionPath()
9028 if src == "" {
9029 i.mu.Lock()
9030 i.statusErr = "export: no session is active (running with --no-session?)"
9031 i.mu.Unlock()
9032 i.invalidate()
9033 return
9034 }
9035 // Persist any in-memory agent messages to the session file so
9036 // the export carries the full conversation. Without this, the
9037 // default lazy-flush-at-exit strategy leaves most of a running
9038 // session unwritten and the export ends up with only the meta.
9039 if i.cfg.FlushSession != nil {
9040 i.cfg.FlushSession()
9041 }
9042 dst = unquotePath(dst)
9043 if dst == "" {
9044 dst = defaultExportDir()
9045 } else {
9046 dst = expandTilde(dst)
9047 }
9048 out, err := core.ExportSession(src, dst)
9049 if err != nil {
9050 i.mu.Lock()
9051 i.statusErr = "export: " + err.Error()
9052 i.mu.Unlock()
9053 i.invalidate()
9054 return
9055 }
9056 i.mu.Lock()
9057 i.statusOK = "exported session to " + friendlyPath(out)
9058 i.statusErr = ""
9059 i.mu.Unlock()
9060 i.invalidate()
9061}
9062
9063// doSessionImport copies the .zutsession file at src into the
9064// running cwd's sessions directory and loads it as the active
9065// session, same as `/sessions` -> pick. When src is empty we ask
9066// the user to pass a path (no usable default here).
9067func (i *Interactive) doSessionImport(src string) {
9068 src = unquotePath(src)
9069 if src == "" {
9070 i.mu.Lock()
9071 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
9072 i.mu.Unlock()
9073 i.invalidate()
9074 return
9075 }
9076 src = expandTilde(src)
9077 if _, err := os.Stat(src); err != nil {
9078 i.mu.Lock()
9079 i.statusErr = "import: " + err.Error()
9080 i.mu.Unlock()
9081 i.invalidate()
9082 return
9083 }
9084 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
9085 if err != nil {
9086 i.mu.Lock()
9087 i.statusErr = "import: " + err.Error()
9088 i.mu.Unlock()
9089 i.invalidate()
9090 return
9091 }
9092 if i.cfg.LoadSession == nil {
9093 i.mu.Lock()
9094 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
9095 i.statusErr = ""
9096 i.mu.Unlock()
9097 i.invalidate()
9098 return
9099 }
9100 i.markSessionTitleSwitching()
9101 if err := i.cfg.LoadSession(newPath); err != nil {
9102 i.restoreFailedSessionTitle()
9103 i.mu.Lock()
9104 i.statusErr = "import: load failed: " + err.Error()
9105 i.mu.Unlock()
9106 i.invalidate()
9107 return
9108 }
9109 i.restoreLoadedSessionTitle()
9110 state := i.restoreCurrentCompactHandoff()
9111 i.mu.Lock()
9112 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
9113 i.statusErr = ""
9114 if i.agent != nil {
9115 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9116 i.cumUsage = i.agent.Cost()
9117 last := i.agent.LastTurnUsage()
9118 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9119 if len(i.view.Messages) > initialResumeTailLimit {
9120 i.view.TailLimit = initialResumeTailLimit
9121 } else {
9122 i.view.TailLimit = 0
9123 }
9124 i.view.InvalidateRenderCache()
9125 }
9126 i.mu.Unlock()
9127 i.invalidate()
9128 if state.reason != compactContinuationNone {
9129 i.startRestoredCompactHandoff(i.runCtx)
9130 }
9131}
9132
9133// defaultExportDir returns ~/Downloads when it exists, or ~ as a
9134// fallback, or /tmp on exotic machines with no home dir.
9135func defaultExportDir() string {
9136 home, err := os.UserHomeDir()
9137 if err != nil || home == "" {
9138 return os.TempDir()
9139 }
9140 downloads := filepath.Join(home, "Downloads")
9141 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
9142 return downloads
9143 }
9144 return home
9145}
9146
9147// expandTilde turns a leading ~ into the user's home directory.
9148// Returns the input unchanged when there's no tilde or no home.
9149func expandTilde(p string) string {
9150 if p == "" || p[0] != '~' {
9151 return p
9152 }
9153 home, err := os.UserHomeDir()
9154 if err != nil || home == "" {
9155 return p
9156 }
9157 if len(p) == 1 {
9158 return home
9159 }
9160 if p[1] == '/' || p[1] == filepath.Separator {
9161 return filepath.Join(home, p[2:])
9162 }
9163 return p
9164}
9165
9166// unquotePath strips a matching pair of surrounding single or
9167// double quotes. Drag-drop paste in the tui auto-quotes dropped
9168// file paths so the shell-like `/session import 'foo bar.zs'`
9169// stays well-formed; when the TUI's own slash handler consumes
9170// the arg, we want the raw path back.
9171func unquotePath(p string) string {
9172 p = strings.TrimSpace(p)
9173 if len(p) >= 2 {
9174 first := p[0]
9175 last := p[len(p)-1]
9176 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
9177 p = p[1 : len(p)-1]
9178 }
9179 }
9180 return p
9181}
9182
9183// friendlyPath collapses the user's home directory to a leading ~
9184// so status messages read cleanly. Falls back to the raw path when
9185// the home dir is unknown.
9186func friendlyPath(p string) string {
9187 home, err := os.UserHomeDir()
9188 if err != nil || home == "" {
9189 return p
9190 }
9191 if strings.HasPrefix(p, home+string(filepath.Separator)) {
9192 return "~" + p[len(home):]
9193 }
9194 return p
9195}
9196
9197// doSessionFork opens the /jump turn picker in "fork mode". The
9198// next selection branches the current session at that user turn
9199// instead of scrolling the viewport to it.
9200func (i *Interactive) doSessionFork() {
9201 path := i.currentSessionPath()
9202 if path == "" {
9203 i.mu.Lock()
9204 i.statusErr = "fork: no session is active (running with --no-session?)"
9205 i.mu.Unlock()
9206 i.invalidate()
9207 return
9208 }
9209 i.mu.Lock()
9210 busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
9211 queued := len(i.queued) != 0
9212 ag := i.agent
9213 i.mu.Unlock()
9214 if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
9215 i.mu.Lock()
9216 i.statusErr = "fork: wait until the current turn and queued messages finish"
9217 i.mu.Unlock()
9218 i.invalidate()
9219 return
9220 }
9221 if i.cfg.FlushSession != nil {
9222 i.cfg.FlushSession()
9223 }
9224 snapshot, err := core.ReadSessionSnapshot(path)
9225 if err != nil {
9226 i.mu.Lock()
9227 i.statusErr = "fork: " + err.Error()
9228 i.mu.Unlock()
9229 i.invalidate()
9230 return
9231 }
9232 msgs := snapshot.Messages
9233 if len(msgs) == 0 {
9234 i.mu.Lock()
9235 i.statusErr = "fork: transcript is empty; nothing to fork from"
9236 i.mu.Unlock()
9237 i.invalidate()
9238 return
9239 }
9240 i.pendingFork = true
9241 i.jumpDialog.Open(msgs, "")
9242 i.invalidate()
9243}
9244
9245// doSessionTree opens the current session family after the shared
9246// fail-closed gate succeeds. It intentionally does not fall back to the
9247// in-memory transcript: a tree that cannot be persisted and reloaded cannot
9248// safely create a navigation branch.
9249func (i *Interactive) doSessionTree() {
9250 i.openSessionTree()
9251}
9252
9253// applySessionTreeMessageSelection keeps the old scalar integration
9254// source-compatible for package consumers. New dialog selections use the
9255// structured sessionTreeTarget path below.
9256func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
9257 target := sessionTreeTarget{
9258 SourcePath: src,
9259 EffectiveIndex: msgIdx,
9260 SelectionBoundary: msgIdx + 1,
9261 Role: role,
9262 UserDraft: prompt,
9263 Boundary: sessionTreeMessageBoundary,
9264 }
9265 if role == provider.RoleUser {
9266 target.SelectionBoundary = msgIdx
9267 }
9268 i.applySessionTreeTarget(target, turnNo)
9269}
9270
9271// applySessionTreeTarget checks out a structured dialog target. The target's
9272// source and effective indices come from the same preflight snapshot the
9273// dialog rendered, while the live read below preserves compatibility with
9274// the existing LoadSession(path) error callback.
9275func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
9276 // Selection is a state-changing operation too. Use the selection gate so
9277 // a queued/busy turn cannot race the hidden branch or accidentally submit
9278 // a provider turn while the session is being swapped. The open gate cannot
9279 // be reused here because the tree dialog itself owns the keyboard.
9280 if !i.canCommitSessionTreeSelection() {
9281 i.setSessionTreeError("tree: session branching is not available in this build")
9282 return
9283 }
9284 src := target.SourcePath
9285 if src == "" {
9286 src = i.currentSessionPath()
9287 }
9288 if src == "" {
9289 i.setSessionTreeError("tree: no session is active")
9290 return
9291 }
9292
9293 // Read and validate the selected row before flushing or creating anything.
9294 // This gives tool-call/result pairs an atomic boundary and prevents a
9295 // malformed selection from changing the active session. Historical rows
9296 // come from a pre-compaction segment; current rows use the effective
9297 // snapshot that the running agent resumes from.
9298 var msgs []provider.Message
9299 var historicalSegment core.SessionHistorySegment
9300 if target.Historical {
9301 history, err := core.ReadSessionHistory(src)
9302 if err != nil {
9303 i.setSessionTreeError("tree: read selection: " + err.Error())
9304 return
9305 }
9306 if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
9307 i.setSessionTreeError("tree: selected history segment is unavailable")
9308 return
9309 }
9310 historicalSegment = history.Segments[target.HistorySegment]
9311 msgs = historicalSegment.Messages
9312 } else {
9313 sess, current, err := core.OpenSession(src)
9314 if err != nil {
9315 i.setSessionTreeError("tree: read selection: " + err.Error())
9316 return
9317 }
9318 _ = sess.Close()
9319 msgs = current
9320 }
9321 selection, err := sessionTreeSelection(msgs, target)
9322 if err != nil {
9323 i.setSessionTreeError("tree: " + err.Error())
9324 return
9325 }
9326 if i.cfg.FlushSession != nil {
9327 i.cfg.FlushSession()
9328 }
9329 var newPath string
9330 if target.Historical {
9331 newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
9332 } else {
9333 newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
9334 }
9335 if err != nil {
9336 i.setSessionTreeError("tree: " + err.Error())
9337 return
9338 }
9339 i.markSessionTitleSwitching()
9340 if err := i.cfg.LoadSession(newPath); err != nil {
9341 i.restoreFailedSessionTitle()
9342 i.setSessionTreeError("tree: checkout failed: " + err.Error())
9343 return
9344 }
9345 state := i.restoreCurrentCompactHandoff()
9346
9347 // LoadSession is intentionally the old func(string) error callback: the
9348 // CLI and embedders already own agent/session swapping. Refresh the view
9349 // from the swapped agent here instead of requiring a new callback result.
9350 i.resetSessionTitleForFreshBranch()
9351 i.scrollToBottom()
9352 i.mu.Lock()
9353 i.lastCtxInput = 0
9354 if i.agent != nil {
9355 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9356 i.cumUsage = i.agent.Cost()
9357 last := i.agent.LastTurnUsage()
9358 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9359 i.view.InvalidateRenderCache()
9360 }
9361 i.toolCalls = map[string]*tui.ToolCallView{}
9362 i.toolOrder = nil
9363 i.toolGate = map[string]int{}
9364 i.extNotes = nil
9365 if selection.restoreDraft {
9366 i.ed.SetValue(selection.draftText)
9367 i.clipboardImages = selection.images
9368 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
9369 } else {
9370 i.ed.Clear()
9371 i.clipboardImages = nil
9372 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
9373 }
9374 i.statusErr = ""
9375 i.mu.Unlock()
9376 i.scrollToBottom()
9377 // Selection does not submit, start, or queue the restored user draft; only
9378 // a later explicit Enter does that. An inherited compact handoff may resume
9379 // its hidden continuation below when this branch has the complete effective
9380 // transcript, without submitting the restored draft.
9381 i.invalidate()
9382 if state.reason != compactContinuationNone {
9383 i.startRestoredCompactHandoff(i.runCtx)
9384 }
9385}
9386
9387// applyForkSelection branches the current session at msgIdx+1 (so
9388// the selected user message and everything before it is included
9389// in the new branch), then switches the running agent to the new
9390// file. Called from the jump-dialog handler when pendingFork=true.
9391func (i *Interactive) applyForkSelection(msgIdx int) {
9392 i.pendingFork = false
9393 src := i.currentSessionPath()
9394 if src == "" {
9395 i.mu.Lock()
9396 i.statusErr = "fork: no session is active"
9397 i.mu.Unlock()
9398 i.invalidate()
9399 return
9400 }
9401 if i.cfg.FlushSession != nil {
9402 i.cfg.FlushSession()
9403 }
9404 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
9405 // the selected user message is included.
9406 upTo := msgIdx + 1
9407 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
9408 if err != nil {
9409 i.mu.Lock()
9410 i.statusErr = "fork: " + err.Error()
9411 i.mu.Unlock()
9412 i.invalidate()
9413 return
9414 }
9415 if i.cfg.LoadSession == nil {
9416 i.mu.Lock()
9417 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
9418 i.statusErr = ""
9419 i.mu.Unlock()
9420 i.invalidate()
9421 return
9422 }
9423 i.markSessionTitleSwitching()
9424 if err := i.cfg.LoadSession(newPath); err != nil {
9425 i.restoreFailedSessionTitle()
9426 i.mu.Lock()
9427 i.statusErr = "fork: switch failed: " + err.Error()
9428 i.mu.Unlock()
9429 i.invalidate()
9430 return
9431 }
9432 state := i.restoreCurrentCompactHandoff()
9433 i.resetSessionTitleForFreshBranch()
9434 i.mu.Lock()
9435 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
9436 i.statusErr = ""
9437 i.mu.Unlock()
9438 i.invalidate()
9439 if state.reason != compactContinuationNone {
9440 i.startRestoredCompactHandoff(i.runCtx)
9441 }
9442}
9443
9444// formatInt is a tiny strconv.Itoa shim; keeps the handler above
9445// from needing a strconv import just for one call.
9446func formatInt(n int) string {
9447 return fmt.Sprintf("%d", n)
9448}
9449
9450// assistantText returns the concatenated text of every TextBlock in
9451// m. Used by the streaming-view dedupe guard to tell when a live
9452// streamed reply has already been promoted into the transcript.
9453func assistantText(m provider.Message) string {
9454 var sb strings.Builder
9455 for _, c := range m.Content {
9456 if tb, ok := c.(provider.TextBlock); ok {
9457 if sb.Len() > 0 {
9458 sb.WriteByte('\n')
9459 }
9460 sb.WriteString(tb.Text)
9461 }
9462 }
9463 return sb.String()
9464}
9465
9466// resetTranscriptRenderLocked invalidates every render cache that assumes
9467// the previous transcript remains structurally intact. Compaction is a
9468// replacement, not an append: the flow renderer must repaint from the new
9469// transcript rather than diff it against scrollback rows from the old one.
9470// Must be called with i.mu held.
9471func (i *Interactive) resetTranscriptRenderLocked() {
9472 i.view.InvalidateRenderCache()
9473 i.chatCacheValid = false
9474 i.prevChatLen = 0
9475 i.prevChatCols = 0
9476 i.prevChatRows = 0
9477 i.prevScrollOffset = 0
9478 if i.rend != nil {
9479 i.rend.Invalidate()
9480 }
9481}
9482
9483// resetStreamingStateLocked clears every piece of streaming state
9484// in one shot. Used by abort paths (turn cancel, compact hand-off,
9485// queue drain) so the pacer doesn't keep draining stale runes from
9486// a prior turn. Must be called with i.mu held.
9487func (i *Interactive) resetStreamingStateLocked() {
9488 i.streaming.Reset()
9489 i.streamPending = i.streamPending[:0]
9490 i.streamFlushPending = false
9491 i.streamOn = false
9492 i.openAllToolGatesLocked()
9493}
9494
9495// openAllToolGatesLocked drops every pending tool gate so that any
9496// tool registered during this turn renders unconditionally from now
9497// on. Called when streaming finalizes (the paced text has fully
9498// drained and `streaming` is about to reset to length 0): without
9499// this, the gate comparison against a freshly-reset streaming buffer
9500// would wrongly re-hide tools that had already cleared their gate.
9501// Must be called with i.mu held.
9502func (i *Interactive) openAllToolGatesLocked() {
9503 for id := range i.toolGate {
9504 i.toolGate[id] = 0
9505 }
9506}
9507
9508// gateToolLocked records the stream position at which a tool call may
9509// become visible. The gate is the total length the streaming buffer
9510// will reach once the pacer has drained everything currently queued
9511// (already painted + still pending). Holding the tool block back
9512// until the pacer crosses that mark guarantees the prose emitted
9513// before the tool call finishes typing out above it, instead of the
9514// tool block snapping in while the paragraph is still filling in.
9515//
9516// We only gate while text is actively streaming. If no stream is in
9517// flight (gate 0), the tool shows immediately, which is the correct
9518// behaviour for tool-only turns and replayed sessions. First
9519// registration wins so a later EvToolCall can't move an existing
9520// gate. Must be called with i.mu held.
9521func (i *Interactive) gateToolLocked(id string) {
9522 if _, ok := i.toolGate[id]; ok {
9523 return
9524 }
9525 if !i.streamOn {
9526 i.toolGate[id] = 0
9527 return
9528 }
9529 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
9530}
9531
9532// toolGateOpenLocked reports whether the gated tool block may render
9533// yet, i.e. the pacer has drained enough text to reach the position
9534// recorded when the tool call arrived. Must be called with i.mu held.
9535func (i *Interactive) toolGateOpenLocked(id string) bool {
9536 gate, ok := i.toolGate[id]
9537 if !ok || gate == 0 {
9538 return true
9539 }
9540 return i.streaming.Len() >= gate
9541}
9542
9543// assistantMessageSideEffects runs the non-visual hooks attached to
9544// EvAssistantMessage: the host-provided OnAssistant callback and the
9545// telegram-bridge mirror. Called with i.mu held.
9546//
9547// Factored out of handleEvent because the streaming pacer may defer
9548// visual reset until after the last buffered rune has painted, but
9549// the callbacks themselves must fire on message arrival so
9550// downstream observers (session persistence, telegram, cost panels)
9551// don't wait on a UI animation to catch up.
9552func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
9553 if i.cfg.OnAssistant != nil {
9554 i.cfg.OnAssistant(m)
9555 }
9556 if i.telegramBridge != nil && i.telegramBridge.Active() {
9557 var sb strings.Builder
9558 for _, c := range m.Content {
9559 if tb, ok := c.(provider.TextBlock); ok {
9560 if sb.Len() > 0 {
9561 sb.WriteString("\n")
9562 }
9563 sb.WriteString(tb.Text)
9564 }
9565 }
9566 if text := sb.String(); strings.TrimSpace(text) != "" {
9567 go i.telegramBridge.OnAssistantText(text)
9568 }
9569 }
9570}
9571
9572// paintPaceRate is how many runes the streaming pacer releases per
9573// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
9574// that a 500-rune summary finishes in ~1.3s, slow enough to look
9575// like a human typing. Empirically matches the feel of provider
9576// paths that already drip-stream natively.
9577const paintPaceRate = 6
9578
9579// paintPaceInterval is the tick interval for the streaming pacer.
9580// 16ms lines up with the redraw throttle so we never paint faster
9581// than the terminal can keep up.
9582const paintPaceInterval = 16 * time.Millisecond
9583
9584// runStreamPacer drains buffered deltas from streamPending into
9585// streaming a small batch per tick, invalidating after each move.
9586// It stops when the context cancels (tui shutdown).
9587//
9588// Why a pacer: providers differ wildly in how they chunk their
9589// text_delta events. The API-key path on Anthropic emits ~30 drips
9590// for a 400-token summary; the OAuth path can coalesce the same
9591// summary into 3 fat chunks, visually indistinguishable from "the
9592// whole reply just appeared". The pacer normalizes that so every
9593// path looks the same on screen.
9594func (i *Interactive) runStreamPacer(ctx context.Context) {
9595 t := time.NewTicker(paintPaceInterval)
9596 defer t.Stop()
9597 for {
9598 select {
9599 case <-ctx.Done():
9600 return
9601 case <-t.C:
9602 i.mu.Lock()
9603 if len(i.streamPending) == 0 {
9604 // EvAssistantMessage already fired but the pacer
9605 // was still draining a tick ago. Everything is now
9606 // painted; clear the streaming flags so the next
9607 // redraw shows the finalised transcript message
9608 // and hides the streaming overlay.
9609 if i.streamFlushPending {
9610 i.streamFlushPending = false
9611 i.streaming.Reset()
9612 i.streamOn = false
9613 i.openAllToolGatesLocked()
9614 i.mu.Unlock()
9615 i.invalidate()
9616 continue
9617 }
9618 i.mu.Unlock()
9619 continue
9620 }
9621 n := paintPaceRate
9622 if n > len(i.streamPending) {
9623 n = len(i.streamPending)
9624 }
9625 i.streaming.WriteString(string(i.streamPending[:n]))
9626 i.streamPending = i.streamPending[n:]
9627 i.mu.Unlock()
9628 i.invalidate()
9629 }
9630 }
9631}