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