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