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