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