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