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