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