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