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 the provider's fast tier where supported; 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 "/fast":
5548 enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
5549 i.applySettingToggle("fast_mode", enabled)
5550 case "/settings":
5551 i.openSettingsDialog()
5552 case "/sessions":
5553 i.mu.Lock()
5554 i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5555 i.mu.Unlock()
5556 case "/fork":
5557 i.doSessionFork()
5558 case "/jump":
5559 i.openJumpDialog(parts[1:])
5560 case "/btw":
5561 i.openBtwDialog(parts[1:])
5562 case "/skills":
5563 i.openSkillsDialog()
5564 case "/compact":
5565 i.runCompact(ctx, false)
5566 case "/study":
5567 // Canned prompt that tells the agent to read every file
5568 // in some target so its later turns have the whole thing
5569 // in context. With no argument, the target is the current
5570 // directory. With an argument, the target is whatever the
5571 // user passed — typed by hand, drag-dropped, or selected
5572 // via the @ file picker (which is why we accept both files
5573 // and directories; the @-picker chips for both have already
5574 // been expanded to absolute paths by expandFileChips above).
5575 // Dispatched through the normal queue-or-start path so it
5576 // behaves identically to typing the prompt by hand.
5577 studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
5578 i.mu.Lock()
5579 busy := i.busy
5580 compacting := i.compacting
5581 ag := i.agent
5582 i.mu.Unlock()
5583 if busy {
5584 if ag != nil && !compacting {
5585 ag.QueueMessage(studyPrompt)
5586 } else {
5587 i.mu.Lock()
5588 i.queued = append(i.queued, studyPrompt)
5589 i.mu.Unlock()
5590 }
5591 i.invalidate()
5592 break
5593 }
5594 i.startTurn(ctx, studyPrompt)
5595 case "/cd":
5596 // Hidden command: switch the running session's cwd. Not in
5597 // slash_suggest, not in /help. Used by the workspaces
5598 // extension's panel-key Enter handler so picking a row
5599 // jumps zut into that directory without relaunching.
5600 //
5601 // Recovers the raw argument (path) from the original cmd
5602 // string rather than parts, so paths with spaces survive.
5603 // The host's ChangeCWD hook handles validation, session
5604 // close + reopen, agent rebuild, sandbox re-rooting, and
5605 // re-jail-if-jailed semantics.
5606 if i.cfg.ChangeCWD == nil {
5607 i.mu.Lock()
5608 i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
5609 i.mu.Unlock()
5610 break
5611 }
5612 path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
5613 if path == "" {
5614 i.mu.Lock()
5615 i.statusErr = "/cd: missing path"
5616 i.mu.Unlock()
5617 break
5618 }
5619 if err := i.cfg.ChangeCWD(path); err != nil {
5620 i.mu.Lock()
5621 i.statusErr = "/cd: " + err.Error()
5622 i.statusOK = ""
5623 i.mu.Unlock()
5624 break
5625 }
5626 // ChangeCWD has already updated i.cfg.CWD and swapped the
5627 // agent + session. Reset transient TUI state so the new
5628 // session opens clean.
5629 i.mu.Lock()
5630 i.toolCalls = map[string]*tui.ToolCallView{}
5631 i.toolOrder = nil
5632 i.toolGate = map[string]int{}
5633 i.helpBlock = nil
5634 i.parkedTurn = 0
5635 i.statusOK = "cwd " + i.cfg.CWD
5636 i.statusErr = ""
5637 i.mu.Unlock()
5638 i.fileSuggest.Reset()
5639 i.fileSuggest.SetCWD(i.cfg.CWD)
5640 i.invalidate()
5641 case "/jail":
5642 if i.cfg.Sandbox == nil {
5643 i.mu.Lock()
5644 i.statusErr = "sandbox not available in this build"
5645 i.mu.Unlock()
5646 break
5647 }
5648 i.cfg.Sandbox.Lock()
5649 i.mu.Lock()
5650 i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
5651 i.statusErr = ""
5652 i.mu.Unlock()
5653 case "/unjail":
5654 if i.cfg.Sandbox == nil {
5655 i.mu.Lock()
5656 i.statusErr = "sandbox not available in this build"
5657 i.mu.Unlock()
5658 break
5659 }
5660 i.cfg.Sandbox.Unlock()
5661 i.mu.Lock()
5662 i.statusOK = "unjailed"
5663 i.statusErr = ""
5664 i.mu.Unlock()
5665 case "/reload-ext":
5666 i.runReloadExt(ctx)
5667 case "/telegram", "/tg":
5668 if len(parts) >= 2 {
5669 i.doTelegram(parts[1])
5670 break
5671 }
5672 i.openTelegramDialog()
5673 case "/session":
5674 if len(parts) >= 2 {
5675 action := parts[1]
5676 arg := ""
5677 if len(parts) >= 3 {
5678 arg = strings.Join(parts[2:], " ")
5679 }
5680 i.doSessionOp(action, arg)
5681 break
5682 }
5683 i.openSessionOpsDialog()
5684 case "/subagents":
5685 i.runSubagents(ctx, parts[1:])
5686 default:
5687 // Last-resort fallback: try the extension manager. Built-in
5688 // cases above always win; this branch only fires for slash
5689 // commands the extension manager registered. Same routing as
5690 // the editor's submit-handler dispatch path so the autocomplete
5691 // "enter on highlighted suggestion" flow also works.
5692 extName := strings.TrimPrefix(parts[0], "/")
5693 if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
5694 rest := ""
5695 if len(parts) > 1 {
5696 rest = strings.Join(parts[1:], " ")
5697 }
5698 go i.invokeExtensionCommand(ctx, extName, rest)
5699 return false
5700 }
5701 i.mu.Lock()
5702 i.statusErr = "unknown command: " + parts[0]
5703 i.mu.Unlock()
5704 }
5705 return false
5706}
5707
5708func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
5709 var loggedIn []string
5710 if i.cfg.LoggedInProviders != nil {
5711 loggedIn = i.cfg.LoggedInProviders()
5712 }
5713 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
5714 i.mu.Lock()
5715 if refreshErr != nil {
5716 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
5717 i.statusOK = ""
5718 } else if i.statusOK == "Refreshing models" {
5719 i.statusOK = ""
5720 }
5721 i.mu.Unlock()
5722}
5723
5724// openLogoutDialog shows the provider picker for `/logout` with no
5725// argument. Only providers the user is currently logged into are
5726// listed, plus an "all" entry when more than one is present. If
5727// nothing's logged in, writes a status line instead of opening an
5728// empty dialog.
5729func (i *Interactive) openLogoutDialog() {
5730 if i.cfg.AuthManager == nil {
5731 i.mu.Lock()
5732 i.statusErr = "no auth manager configured"
5733 i.mu.Unlock()
5734 i.invalidate()
5735 return
5736 }
5737 store := i.cfg.AuthManager.Store()
5738 if store == nil {
5739 i.mu.Lock()
5740 i.statusErr = "auth store is not available"
5741 i.mu.Unlock()
5742 i.invalidate()
5743 return
5744 }
5745 creds, err := store.Load()
5746 if err != nil {
5747 i.mu.Lock()
5748 i.statusErr = "read auth store: " + err.Error()
5749 i.mu.Unlock()
5750 i.invalidate()
5751 return
5752 }
5753
5754 var items []logoutItem
5755 for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
5756 if creds.Has(p) {
5757 method := creds.Method(p)
5758 if method == "oauth" {
5759 method = "subscription"
5760 }
5761 items = append(items, logoutItem{
5762 label: providerLabel(p),
5763 target: p,
5764 method: method,
5765 })
5766 }
5767 }
5768 if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
5769 items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
5770 }
5771 if creds.OpenAI.OAuth != nil {
5772 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
5773 }
5774 for p, c := range creds.AdditionalAPIKeyCreds {
5775 if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
5776 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
5777 }
5778 }
5779 if len(items) == 0 {
5780 i.mu.Lock()
5781 i.statusOK = "no credentials stored; already logged out"
5782 i.statusErr = ""
5783 i.mu.Unlock()
5784 i.invalidate()
5785 return
5786 }
5787 if len(items) > 1 {
5788 items = append(items, logoutItem{label: "all", target: "all"})
5789 }
5790
5791 i.logoutDialog.Open(items)
5792 i.invalidate()
5793}
5794
5795// doLogout clears credentials for the given provider (or all providers)
5796// from auth.json. If the active agent was using those credentials, it
5797// is torn down so the user is forced through /login before their next
5798// prompt.
5799//
5800// target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
5801func (i *Interactive) doLogout(target string) {
5802 if i.cfg.AuthManager == nil {
5803 i.mu.Lock()
5804 i.statusErr = "no auth manager configured"
5805 i.mu.Unlock()
5806 return
5807 }
5808 store := i.cfg.AuthManager.Store()
5809 if store == nil {
5810 i.mu.Lock()
5811 i.statusErr = "auth store is not available"
5812 i.mu.Unlock()
5813 return
5814 }
5815
5816 var providers []string
5817 switch target {
5818 case "", "all":
5819 providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
5820 case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
5821 providers = []string{target}
5822 default:
5823 known := false
5824 for _, p := range auth.APIKeyProviders() {
5825 if target == p {
5826 known = true
5827 break
5828 }
5829 }
5830 if !known {
5831 i.mu.Lock()
5832 i.statusErr = "unknown provider: " + target
5833 i.mu.Unlock()
5834 return
5835 }
5836 providers = []string{target}
5837 }
5838
5839 var errs []string
5840 clearedCurrent := false
5841 for _, p := range providers {
5842 var err error
5843 switch p {
5844 case "openai":
5845 err = store.ClearAPIKey("openai")
5846 case "openai-codex":
5847 err = store.ClearOAuth("openai")
5848 default:
5849 err = store.Clear(p)
5850 }
5851 if err != nil {
5852 errs = append(errs, p+": "+err.Error())
5853 continue
5854 }
5855 if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5856 if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
5857 errs = append(errs, p+": "+err.Error())
5858 continue
5859 }
5860 }
5861 if p == i.cfg.Provider {
5862 clearedCurrent = true
5863 }
5864 }
5865
5866 llamaConfigured := i.llamaConfigured
5867 if i.cfg.LlamaCPPConfig != nil {
5868 baseURL, _, err := i.cfg.LlamaCPPConfig()
5869 llamaConfigured = err == nil && baseURL != ""
5870 }
5871
5872 i.mu.Lock()
5873 defer i.mu.Unlock()
5874 i.llamaConfigured = llamaConfigured
5875 if len(errs) > 0 {
5876 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
5877 return
5878 }
5879 i.statusErr = ""
5880 if clearedCurrent {
5881 // The running agent was using a credential we just wiped. Drop
5882 // it so prompts can't go out with the stale client, and hint at
5883 // /login. Close any LSP processes owned by its tools first.
5884 if i.agent != nil {
5885 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
5886 }
5887 i.agent = nil
5888 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
5889 } else {
5890 i.statusOK = "logged out of " + strings.Join(providers, ", ")
5891 }
5892}
5893
5894func providerSetupInfo(provider string) (string, []string, bool) {
5895 const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
5896 switch provider {
5897 case "amazon-bedrock":
5898 return "Amazon Bedrock setup", []string{
5899 "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
5900 "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
5901 "",
5902 "For Bedrock API keys, set:",
5903 " AWS_BEARER_TOKEN_BEDROCK=...",
5904 " AWS_REGION=us-east-1",
5905 "",
5906 "Docs:",
5907 " " + docsURL,
5908 }, true
5909 case "google-vertex":
5910 return "Google Vertex AI setup", []string{
5911 "Google Vertex AI usually uses Google Cloud credentials and project settings.",
5912 "Set a Google API key, application-default credentials, or a service account.",
5913 "",
5914 "Common environment:",
5915 " GOOGLE_CLOUD_API_KEY=...",
5916 " GOOGLE_CLOUD_PROJECT=...",
5917 " GOOGLE_CLOUD_LOCATION=us-central1",
5918 "",
5919 "Docs:",
5920 " " + docsURL,
5921 }, true
5922 case "cloudflare-workers-ai":
5923 return "Cloudflare Workers AI setup", []string{
5924 "Cloudflare Workers AI needs both an API token and an account ID.",
5925 "",
5926 "Set:",
5927 " CLOUDFLARE_API_KEY=...",
5928 " CLOUDFLARE_ACCOUNT_ID=...",
5929 "",
5930 "Docs:",
5931 " " + docsURL,
5932 }, true
5933 case "cloudflare-ai-gateway":
5934 return "Cloudflare AI Gateway setup", []string{
5935 "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
5936 "",
5937 "Set:",
5938 " CLOUDFLARE_API_KEY=...",
5939 " CLOUDFLARE_ACCOUNT_ID=...",
5940 " CLOUDFLARE_GATEWAY_ID=...",
5941 "",
5942 "Docs:",
5943 " " + docsURL,
5944 }, true
5945 case "azure-openai-responses":
5946 return "Azure OpenAI Responses setup", []string{
5947 "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
5948 "",
5949 "Set:",
5950 " AZURE_OPENAI_API_KEY=...",
5951 " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
5952 " AZURE_OPENAI_API_VERSION=2024-02-01",
5953 "",
5954 "Docs:",
5955 " " + docsURL,
5956 }, true
5957 default:
5958 return "", nil, false
5959 }
5960}
5961
5962func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
5963 if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
5964 i.dialog.ShowResult(false, "auth store is unavailable")
5965 return
5966 }
5967 if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
5968 i.dialog.ShowResult(false, err.Error())
5969 return
5970 }
5971 i.mu.Lock()
5972 i.llamaConfigured = true
5973 i.statusErr = ""
5974 i.statusOK = "configured llama.cpp router"
5975 i.mu.Unlock()
5976 i.dialog.ShowResult(true, "")
5977}
5978
5979func (i *Interactive) startAPIKeyFlow(provider string) {
5980 if title, lines, ok := providerSetupInfo(provider); ok {
5981 i.dialog.ShowInfo(title, lines)
5982 return
5983 }
5984 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5985 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5986 }
5987 url, err := i.cfg.AuthManager.StartAPIKey(provider)
5988 if err != nil {
5989 i.dialog.ShowResult(false, err.Error())
5990 return
5991 }
5992 i.dialog.ShowWaiting(url)
5993}
5994
5995func (i *Interactive) startOAuthFlow(provider string) {
5996 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5997 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5998 }
5999 // Device-code providers already support headless login and must only
6000 // start one polling flow.
6001 if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
6002 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
6003 if err != nil {
6004 i.dialog.ShowResult(false, err.Error())
6005 return
6006 }
6007 i.dialog.ShowWaiting(loginURL)
6008 return
6009 }
6010 // Always run the manual/copy-code flow in parallel with the local
6011 // callback server so headless environments (docker, SSH) can paste
6012 // the authorization code directly without first pressing 'p'.
6013 _, err := i.cfg.AuthManager.StartOAuth(provider)
6014 if err != nil {
6015 i.dialog.ShowResult(false, err.Error())
6016 return
6017 }
6018 manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
6019 if mErr == nil {
6020 i.dialog.ShowWaiting(manualURL)
6021 } else {
6022 i.dialog.ShowResult(false, mErr.Error())
6023 }
6024}
6025
6026func (i *Interactive) startManualOAuthFlow(provider string) {
6027 if i.cfg.AuthManager == nil {
6028 return
6029 }
6030 i.cfg.AuthManager.CancelOAuth()
6031 url, err := i.cfg.AuthManager.StartManualOAuth(provider)
6032 if err != nil {
6033 i.dialog.ShowResult(false, err.Error())
6034 return
6035 }
6036 i.dialog.url = url
6037 i.invalidate()
6038}
6039
6040func (i *Interactive) submitManualOAuthCode(code string) {
6041 if i.cfg.AuthManager == nil {
6042 return
6043 }
6044 go func() {
6045 if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
6046 i.dialog.ShowResult(false, err.Error())
6047 i.invalidate()
6048 }
6049 }()
6050}
6051
6052// applyModelSelection switches the active model (and provider, if the
6053// new model belongs to a different one). It rebuilds the underlying
6054// client when needed so the provider wire-protocol matches.
6055// cancelAndWaitForIdle cancels the active turn (if any) and blocks
6056// briefly until the turn goroutine has updated i.busy = false. Used
6057// before destructive slash commands so transcript-mutating work
6058// (/clear, /compact, /logout, /login completion, cross-provider
6059// /model swap) doesn't race with the still-running stream.
6060//
6061// The wait is bounded; if the turn doesn't release within the timeout
6062// we proceed anyway. Worst case is a brief overlap that the agent's
6063// own mutex protects against.
6064func (i *Interactive) cancelAndWaitForIdle() {
6065 i.mu.Lock()
6066 busy := i.busy
6067 cancel := i.cancelTurn
6068 i.mu.Unlock()
6069 if !busy {
6070 return
6071 }
6072 if cancel != nil {
6073 cancel()
6074 }
6075 // ConfirmToolCall waits on its response channel rather than the turn
6076 // context, so cancellation must also resolve every pending prompt.
6077 i.confirmDialog.CancelAll("turn cancelled")
6078 deadline := time.Now().Add(2 * time.Second)
6079 for time.Now().Before(deadline) {
6080 i.mu.Lock()
6081 done := !i.busy
6082 i.mu.Unlock()
6083 if done {
6084 return
6085 }
6086 time.Sleep(10 * time.Millisecond)
6087 }
6088}
6089
6090// openBtwDialog opens the side-chat overlay with a frozen snapshot
6091// of the current main session. The optional argument is auto-
6092// submitted as the first question, so '/btw does X work?' fires the
6093// model call immediately instead of just opening an empty dialog.
6094func (i *Interactive) openBtwDialog(args []string) {
6095 i.agentMu.Lock()
6096 defer i.agentMu.Unlock()
6097 i.mu.Lock()
6098 ag := i.agent
6099 system := ""
6100 if ag != nil {
6101 system, _ = ag.PromptConfig()
6102 }
6103 theme := i.cfg.Theme
6104 model := i.cfg.Model
6105 cwd := i.cfg.CWD
6106 compact := i.compactModeEnabled()
6107 flatTools := i.cfg.FlatTools
6108 lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
6109 i.mu.Unlock()
6110 if ag == nil {
6111 i.mu.Lock()
6112 i.statusErr = "not logged in. type /login first."
6113 i.mu.Unlock()
6114 return
6115 }
6116 seed := strings.TrimSpace(strings.Join(args, " "))
6117 i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6118 i.invalidate()
6119}
6120
6121// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
6122// or queues it behind the active turn using the normal text-only queue.
6123func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6124 i.mu.Lock()
6125 if i.agent == nil {
6126 i.statusErr = "not logged in. type /login first."
6127 i.statusOK = ""
6128 i.mu.Unlock()
6129 i.invalidate()
6130 return
6131 }
6132 if i.busy {
6133 // Keep the mutex held while enqueueing so the turn-completion
6134 // goroutine cannot publish idle and inspect an empty queue between
6135 // this check and the append.
6136 if i.compacting {
6137 i.queued = append(i.queued, prompt)
6138 } else {
6139 i.agent.QueueMessage(prompt)
6140 }
6141 i.mu.Unlock()
6142 i.invalidate()
6143 return
6144 }
6145 i.mu.Unlock()
6146 i.startTurn(ctx, prompt)
6147}
6148
6149// openSkillsDialog opens the skill inspector. The picker reflects
6150// whatever SkillSnapshot returns at call time, so edits to a
6151// SKILL.md made during a session show up on the next /skills.
6152func (i *Interactive) openSkillsDialog() {
6153 var list []*skills.Skill
6154 if i.cfg.SkillSnapshot != nil {
6155 list = i.cfg.SkillSnapshot()
6156 }
6157 i.skillsDialog.Open(list)
6158 i.invalidate()
6159}
6160
6161// openJumpDialog builds a /jump picker from the current transcript.
6162// If the user typed "/jump foo" with a filter and it matches exactly
6163// one turn, jump there directly without showing the dialog.
6164func (i *Interactive) openJumpDialog(args []string) {
6165 if i.view == nil || len(i.view.Messages) == 0 {
6166 i.mu.Lock()
6167 i.statusErr = "nothing to jump to \u2014 the session is empty"
6168 i.mu.Unlock()
6169 return
6170 }
6171 filter := strings.TrimSpace(strings.Join(args, " "))
6172 i.jumpDialog.Open(i.view.Messages, filter)
6173 // Shortcut: with a filter argument that matches exactly one turn,
6174 // jump immediately and skip the picker.
6175 if filter != "" {
6176 if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6177 t := tgts[0]
6178 i.jumpDialog.Close()
6179 i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6180 }
6181 }
6182}
6183
6184// applyJumpSelection scrolls the chat viewport so the user message at
6185// msgIdx is visible at (or near) the top of the chat area. Uses the
6186// anchor slice returned by view.BuildWithAnchors so the mapping from
6187// message index to row is exact, regardless of variable-height tool
6188// blocks above the target.
6189func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6190 cols := i.lastCols()
6191 chat, anchors := i.view.BuildWithAnchors(cols)
6192 var row int
6193 found := false
6194 for _, a := range anchors {
6195 if a.MessageIdx == msgIdx {
6196 row = a.Row
6197 found = true
6198 break
6199 }
6200 }
6201 if !found {
6202 i.mu.Lock()
6203 i.statusErr = "could not resolve jump target"
6204 i.mu.Unlock()
6205 return
6206 }
6207
6208 chatLen := len(chat)
6209 page := i.chatPage()
6210 if page < 1 {
6211 page = 1
6212 }
6213 // scrollOffset is measured from the bottom of the chat slice, so
6214 // to place `row` at the top of the viewport we want:
6215 // chatLen - scrollOffset - page == row
6216 // Solve for scrollOffset and clamp to [0, chatLen-page].
6217 offset := chatLen - (row + page)
6218 if offset < 0 {
6219 offset = 0
6220 }
6221 maxOffset := chatLen - page
6222 if maxOffset < 0 {
6223 maxOffset = 0
6224 }
6225 if offset > maxOffset {
6226 offset = maxOffset
6227 }
6228
6229 i.mu.Lock()
6230 i.scrollOffset = offset
6231 i.parkedTurn = turnNo
6232 i.parkedTotal = totalTurnsLocked(i.view.Messages)
6233 i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6234 i.statusErr = ""
6235 i.mu.Unlock()
6236}
6237
6238// totalTurnsLocked counts user messages in the transcript. Caller is
6239// assumed to hold i.mu (the name is a mild reminder; this function
6240// itself doesn't touch shared state beyond the slice it's handed).
6241func totalTurnsLocked(msgs []provider.Message) int {
6242 n := 0
6243 for _, m := range msgs {
6244 if m.Role == provider.RoleUser {
6245 n++
6246 }
6247 }
6248 return n
6249}
6250
6251// applySessionSelection loads the given session via the cli-provided
6252// callback and snaps the viewport to the bottom (the latest message)
6253// so the user lands at the live tail of the resumed conversation.
6254func (i *Interactive) applySessionSelection(path string) {
6255 if i.cfg.LoadSession == nil {
6256 i.mu.Lock()
6257 i.statusErr = "session loading is not wired in this build"
6258 i.mu.Unlock()
6259 return
6260 }
6261 i.mu.Lock()
6262 if i.sessionLoading {
6263 i.statusErr = "already resuming a session"
6264 i.mu.Unlock()
6265 i.invalidate()
6266 return
6267 }
6268 i.sessionLoading = true
6269 i.statusOK = "resuming session: " + path
6270 i.statusErr = ""
6271 i.mu.Unlock()
6272 i.invalidate()
6273 i.markSessionTitleSwitching()
6274
6275 go func() {
6276 err := i.cfg.LoadSession(path)
6277 if err != nil {
6278 i.restoreFailedSessionTitle()
6279 i.mu.Lock()
6280 i.sessionLoading = false
6281 i.statusErr = err.Error()
6282 i.statusOK = ""
6283 i.mu.Unlock()
6284 i.invalidate()
6285 return
6286 }
6287 i.restoreLoadedSessionTitle()
6288 i.mu.Lock()
6289 i.sessionLoading = false
6290 i.statusOK = "resumed session: " + path
6291 i.statusErr = ""
6292 i.parkedTurn = 0
6293 i.parkedTotal = 0
6294 i.scrollOffset = 0
6295 // Fresh transcript swapped in: drop the auto-follow baseline so
6296 // the next render's follow guard doesn't see the wholesale
6297 // length change as a delta and jump the viewport.
6298 i.prevChatLen = 0
6299 i.prevChatCols = 0
6300 i.extNotes = nil
6301 i.view.InvalidateRenderCache()
6302 if i.agent != nil {
6303 i.view.Messages = i.agent.Messages()
6304 i.cumUsage = i.agent.Cost()
6305 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6306 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6307 } else {
6308 i.lastCtxInput = 0
6309 }
6310 // Snap to the tail again — the swap brought in a fresh
6311 // transcript whose markdown / chroma cost we don't want
6312 // blocking the redraw.
6313 if len(i.view.Messages) > initialResumeTailLimit {
6314 i.view.TailLimit = initialResumeTailLimit
6315 } else {
6316 i.view.TailLimit = 0
6317 }
6318 }
6319 i.mu.Unlock()
6320 i.invalidate()
6321 }()
6322}
6323
6324func (i *Interactive) applyModelSelection(prov, model string) {
6325 i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6326}
6327
6328// applyRescueModelSelection is like applyModelSelection but routes
6329// through BuildAgentForRescue so launch-time --api-key / --base-url
6330// overrides are dropped before the new agent is built. Falls back to
6331// the regular builder when the host doesn't wire a rescue builder.
6332func (i *Interactive) applyRescueModelSelection(prov, model string) {
6333 builder := i.cfg.BuildAgentForRescue
6334 if builder == nil {
6335 builder = i.cfg.BuildAgentFor
6336 }
6337 i.swapModel(prov, model, builder, true)
6338}
6339
6340// swapModel applies a /model selection (or a rescue selection) using
6341// the supplied builder. rescue=true tags the success message so the
6342// user can see that launch-time overrides were ignored.
6343func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6344 if i.cfg.SessionTransition != nil {
6345 i.cfg.SessionTransition(func() {
6346 i.swapModelUnserialized(prov, model, builder, rescue)
6347 })
6348 return
6349 }
6350 i.swapModelUnserialized(prov, model, builder, rescue)
6351}
6352
6353func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6354 if model == "" {
6355 return
6356 }
6357 m, err := provider.FindModel(prov, model)
6358 if err != nil {
6359 i.mu.Lock()
6360 i.statusErr = err.Error()
6361 i.mu.Unlock()
6362 return
6363 }
6364 // Same provider AND not a rescue retry: just swap the model on
6365 // the existing agent. Mixed-API providers dispatch from model metadata,
6366 // so the client remains reusable. Rescue retries always rebuild so a stale
6367 // auth header / base URL can't carry over.
6368 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6369 i.mu.Lock()
6370 i.cfg.Model = m.ID
6371 i.agent.Model = m.ID
6372 i.statusOK = "model: " + m.ID
6373 i.statusErr = ""
6374 i.mu.Unlock()
6375 if i.cfg.PersistModel != nil {
6376 i.cfg.PersistModel(i.cfg.Provider, m.ID)
6377 }
6378 return
6379 }
6380 if builder == nil {
6381 i.mu.Lock()
6382 i.statusErr = "cannot switch provider: no builder configured"
6383 i.mu.Unlock()
6384 return
6385 }
6386 // Snapshot the current transcript and cumulative usage BEFORE we
6387 // build the replacement agent so we can hand them off. Without
6388 // this the user perceives the entire session as wiped on a
6389 // cross-provider /model swap.
6390 var carryMsgs []provider.Message
6391 var carryCost provider.Usage
6392 if i.agent != nil {
6393 carryMsgs = i.agent.Messages()
6394 carryCost = i.agent.Cost()
6395 }
6396
6397 ag, p, md, err := builder(m.Provider, m.ID)
6398 if err != nil {
6399 i.mu.Lock()
6400 i.statusErr = err.Error()
6401 i.mu.Unlock()
6402 return
6403 }
6404
6405 // Replay the transcript and seed the cost on the freshly-built
6406 // agent. Messages travel cleanly between providers because they
6407 // use the same provider.Message shape; tool-call ids are local
6408 // to a turn so cross-provider continuation never confuses the
6409 // new model (it just sees the assistant's reply, no orphan
6410 // tool_use blocks because /model swaps are gated to idle state).
6411 if len(carryMsgs) > 0 {
6412 ag.SetMessages(carryMsgs)
6413 }
6414 ag.SeedCost(carryCost)
6415
6416 if i.agent != nil && i.agent != ag {
6417 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6418 }
6419 i.mu.Lock()
6420 i.agent = ag
6421 i.cfg.Provider = p
6422 i.cfg.Model = md
6423 if rescue {
6424 i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6425 } else {
6426 i.statusOK = "switched to " + p + " / " + md
6427 }
6428 i.statusErr = ""
6429 // Render cache keys are width+content based, so the new agent's
6430 // identical messages will reuse the existing entries. Nothing
6431 // to invalidate.
6432 i.mu.Unlock()
6433 // The new agent was built off the base tool registry, so any
6434 // dynamically-registered tools need to be reattached. The apply
6435 // helpers are no-ops when their feature is inactive, so the
6436 // cross-provider path still works on a vanilla setup.
6437 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6438 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6439 if i.cfg.PersistModel != nil {
6440 i.cfg.PersistModel(p, md)
6441 }
6442}
6443
6444func (i *Interactive) handleAuthEvent(ev auth.Event) {
6445 switch ev.Kind {
6446 case "started":
6447 i.dialog.ShowWaiting(ev.URL)
6448 case "browser_open":
6449 // no-op
6450 case "error":
6451 i.dialog.ShowResult(false, ev.Message)
6452 case "success":
6453 // Rebuild the agent with the fresh credential.
6454 ag, prov, model, err := i.cfg.BuildAgent()
6455 if err != nil {
6456 i.dialog.ShowResult(false, err.Error())
6457 return
6458 }
6459 commitLogin := func() {
6460 i.mu.Lock()
6461 oldAgent := i.agent
6462 i.agent = ag
6463 i.cfg.Provider = prov
6464 i.cfg.Model = model
6465 i.statusErr = ""
6466 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6467 i.mu.Unlock()
6468 if oldAgent != nil {
6469 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6470 }
6471 i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6472 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6473 // Authentication can change the provider used by the live
6474 // agent. Persist it on the active session just like /model.
6475 if i.cfg.PersistModel != nil {
6476 i.cfg.PersistModel(prov, model)
6477 }
6478 }
6479 if i.cfg.SessionTransition != nil {
6480 i.cfg.SessionTransition(commitLogin)
6481 } else {
6482 commitLogin()
6483 }
6484 i.dialog.ShowResult(true, "")
6485 }
6486}
6487
6488// clearPendingCompactTurnLocked drops work that only makes sense after the
6489// current compaction. The caller must hold i.mu.
6490func (i *Interactive) clearPendingCompactTurnLocked() {
6491 i.pendingCompactPrompt = ""
6492 i.pendingCompactImages = nil
6493 i.hasPendingCompactPrompt = false
6494 i.continueAfterCompact = false
6495 i.pendingPostCompactNote = ""
6496}
6497
6498const 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.`
6499
6500// startAutoCompactContinuation adds an internal user turn that tells the
6501// model to resume the task after threshold compaction. Provider requests
6502// cannot continue from an assistant tail on every supported API, so the
6503// continuation must be represented as a user message. It is hidden from
6504// the rendered transcript but remains in the persisted/model context.
6505func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6506 i.mu.Lock()
6507 ag := i.agent
6508 i.mu.Unlock()
6509 if ag == nil {
6510 i.mu.Lock()
6511 i.busy = false
6512 pendingIdleWork := i.takePendingIdleWorkLocked()
6513 i.mu.Unlock()
6514 runPendingIdleWork(pendingIdleWork)
6515 i.invalidate()
6516 return
6517 }
6518 if ag.QueuedMessageCount() == 0 {
6519 ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
6520 autoCompactContinueMetaKey: "true",
6521 })
6522 }
6523 i.startTurnRequest(parent, "", nil, true, false)
6524}
6525
6526// runCompact invokes core.Agent.Compact and reflects the progress in
6527// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
6528// cancel via the same cancelTurn channel used for normal turns.
6529//
6530// When auto is true the spinner message is pinned to "condensing
6531// history" and the status bar surfaces "(auto)" next to the context
6532// percentage so it's obvious the system triggered this, not the user.
6533func (i *Interactive) runCompact(parent context.Context, auto bool) {
6534 if i.agent == nil {
6535 i.mu.Lock()
6536 i.statusErr = "not logged in. type /login first."
6537 i.mu.Unlock()
6538 return
6539 }
6540 ctx, cancel := context.WithCancel(parent)
6541 i.mu.Lock()
6542 i.busy = true
6543 i.compacting = true
6544 i.spin.Start()
6545 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6546 if auto {
6547 i.activity.kind = activityCondensingHistory
6548 i.autoCompacting = true
6549 } else {
6550 i.activity.kind = activityCompactingHistory
6551 }
6552 i.cancelTurn = cancel
6553 i.statusErr = ""
6554 i.statusOK = ""
6555 // Do NOT set streamOn: the summary text should not be visible
6556 // in the chat while compacting. The user just sees the spinner
6557 // and can keep typing / queue prompts.
6558 i.scrollOffset = 0
6559 i.helpBlock = nil
6560 i.mu.Unlock()
6561 i.invalidate()
6562
6563 go func() {
6564 // Sink discards deltas — we don't stream the summary to the UI.
6565 sink := func(delta string) {}
6566 msgsBefore := i.agent.Messages()
6567 autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
6568 // Keep the usual recent tail when possible, but never let it cover
6569 // the whole transcript. A short session can still be at 70–90% of
6570 // a model's window because one prompt or tool result is large; the
6571 // automatic path must summarize that session instead of failing
6572 // before it reaches the compaction provider.
6573 keepTail := 4
6574 if n := len(msgsBefore); n > 0 && keepTail >= n {
6575 keepTail = n - 1
6576 }
6577 summary, err := i.agent.Compact(ctx, keepTail, sink)
6578 _ = summary
6579 i.mu.Lock()
6580 // Keep busy/compacting asserted while cleanup and queue selection run.
6581 // Completion updates can arrive in this window; clearing busy before
6582 // inspecting the queues lets them race a new turn or be stranded.
6583 i.resetStreamingStateLocked()
6584 i.cancelTurn = nil
6585 i.autoCompacting = false
6586 pendingIdleWork := i.takePendingIdleWorkLocked()
6587
6588 // Drain pending work after the transcript is clean. Overflow recovery
6589 // continues the user message already in the transcript. Pre-turn
6590 // compaction preserves its complete text-and-images request. Regular
6591 // prompts typed during compaction remain in the host queue.
6592 var next string
6593 var nextImages []provider.ImageBlock
6594 var hasNext bool
6595 var continueExisting bool
6596 var continueAutomatically bool
6597
6598 switch {
6599 case err != nil && ctx.Err() != nil:
6600 i.statusErr = ""
6601 if auto {
6602 i.statusOK = "auto-condense cancelled"
6603 } else {
6604 i.statusOK = "compaction cancelled"
6605 }
6606 i.queued = nil // drop queue on cancel
6607 i.clearPendingCompactTurnLocked()
6608 if i.agent != nil {
6609 i.agent.DrainQueuedMessages()
6610 }
6611 case err != nil:
6612 i.statusErr = "compaction failed: " + err.Error()
6613 i.statusOK = ""
6614 i.queued = nil // drop queue on error
6615 i.clearPendingCompactTurnLocked()
6616 if i.agent != nil {
6617 i.agent.DrainQueuedMessages()
6618 }
6619 default:
6620 i.statusErr = ""
6621 // Read token count from the compaction message meta.
6622 tokens := ""
6623 msgs := i.agent.Messages()
6624 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
6625 tokens = msgs[0].Meta["tokens_before"]
6626 }
6627 switch {
6628 case i.pendingPostCompactNote != "":
6629 i.statusOK = i.pendingPostCompactNote
6630 case tokens != "":
6631 i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
6632 default:
6633 i.statusOK = "compacted (ctrl+o to expand)"
6634 }
6635 i.pendingPostCompactNote = ""
6636 i.extNotes = stripAutoCompactNotes(i.extNotes)
6637 i.lastCtxInput = 0
6638 i.toolCalls = map[string]*tui.ToolCallView{}
6639 i.toolOrder = nil
6640 i.toolGate = map[string]int{}
6641 i.resetTranscriptRenderLocked()
6642 switch {
6643 case i.continueAfterCompact:
6644 continueExisting = true
6645 i.continueAfterCompact = false
6646 case i.hasPendingCompactPrompt:
6647 next = i.pendingCompactPrompt
6648 nextImages = i.pendingCompactImages
6649 i.pendingCompactPrompt = ""
6650 i.pendingCompactImages = nil
6651 i.hasPendingCompactPrompt = false
6652 hasNext = true
6653 case len(i.queued) > 0:
6654 next, i.queued = i.queued[0], i.queued[1:]
6655 hasNext = true
6656 case autoContinue:
6657 // A successful threshold compaction must hand control back to
6658 // an unfinished model turn. Completed text answers should settle
6659 // without an invented extra request; the user can still prompt
6660 // again normally.
6661 continueAutomatically = true
6662 }
6663 }
6664 // Keep the host busy until the hand-off decision is committed under
6665 // the mutex. A completion update arriving after the compaction result
6666 // but before this assignment is then queued for the selected follow-up
6667 // instead of starting a competing turn.
6668 i.busy = hasNext || continueExisting || continueAutomatically
6669 i.compacting = false
6670 i.autoCompacting = false
6671 i.mu.Unlock()
6672 runPendingIdleWork(pendingIdleWork)
6673 i.invalidate()
6674
6675 if hasNext || continueExisting || continueAutomatically {
6676 p := i.runCtx
6677 if p == nil {
6678 p = context.Background()
6679 }
6680 switch {
6681 case continueExisting:
6682 i.startTurnRequest(p, "", nil, true, true)
6683 case hasNext:
6684 i.startTurnWithImages(p, next, nextImages)
6685 case continueAutomatically:
6686 i.startAutoCompactContinuation(p)
6687 }
6688 }
6689 }()
6690}
6691
6692// shellEscapeCommand reports whether text is a "!command" shell
6693// escape and, if so, returns the command with the leading '!' (and
6694// surrounding whitespace) stripped. A bare "!" with no command is
6695// treated as not an escape so it falls through to the normal prompt
6696// path rather than running an empty shell.
6697func shellEscapeCommand(text string) (string, bool) {
6698 return ShellEscapeCommand(text)
6699}
6700
6701// ShellEscapeCommand reports whether text is a "!command" shell
6702// escape and, if so, returns the command with the leading '!' (and
6703// surrounding whitespace) stripped. A bare "!" with no command is
6704// treated as not an escape so it falls through to the normal prompt
6705// path rather than running an empty shell.
6706func ShellEscapeCommand(text string) (string, bool) {
6707 trimmed := strings.TrimLeft(text, " \t")
6708 if !strings.HasPrefix(trimmed, "!") {
6709 return "", false
6710 }
6711 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
6712 if cmd == "" {
6713 return "", false
6714 }
6715 return cmd, true
6716}
6717
6718// startShellEscape runs a "!command" in the same shell the bash tool
6719// uses, in the session working directory, honoring the /jail sandbox.
6720// It shares the busy/cancel state with the agent: esc cancels it, and
6721// it refuses to start while a turn or another shell escape is already
6722// in flight. Its terminal-log output is appended to the transcript as
6723// user context without automatically starting a model turn.
6724func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6725 i.mu.Lock()
6726 if i.busy || i.shellRunning {
6727 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
6728 i.statusOK = ""
6729 i.mu.Unlock()
6730 i.invalidate()
6731 return
6732 }
6733 if parent == nil {
6734 parent = i.runCtx
6735 }
6736 if parent == nil {
6737 parent = context.Background()
6738 }
6739 ctx, cancel := context.WithCancel(parent)
6740 i.busy = true
6741 i.shellRunning = true
6742 i.shellLive = "$ " + cmd + "\n\n"
6743 i.cancelTurn = cancel
6744 i.statusErr = ""
6745 i.statusOK = ""
6746 i.spin.Start()
6747 i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
6748 // Clear stale extension notes the same way a new turn would so the
6749 // screen doesn't accumulate transient state.
6750 i.extNotes = nil
6751 i.scrollOffset = 0
6752 i.parkedTurn = 0
6753 i.parkedTotal = 0
6754 i.helpBlock = nil
6755 sandbox := i.cfg.Sandbox
6756 cwd := i.cfg.CWD
6757 i.mu.Unlock()
6758 i.invalidate()
6759
6760 go func() {
6761 defer cancel()
6762 raw, _ := json.Marshal(map[string]any{"command": cmd})
6763 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
6764 progress := func(chunk string) {
6765 i.mu.Lock()
6766 i.shellLive += chunk
6767 i.mu.Unlock()
6768 i.invalidate()
6769 }
6770 res, err := bash.Execute(ctx, raw, progress)
6771
6772 var out string
6773 if err != nil {
6774 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
6775 } else {
6776 for _, c := range res.Content {
6777 if tb, ok := c.(provider.TextBlock); ok {
6778 out += tb.Text
6779 }
6780 }
6781 }
6782 cancelled := ctx.Err() != nil
6783 failed := err != nil || res.IsError || cancelled
6784 if cancelled {
6785 out += "\n\n[cancelled]"
6786 }
6787
6788 if i.agent != nil {
6789 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
6790 }
6791
6792 i.mu.Lock()
6793 i.shellRunning = false
6794 i.shellLive = ""
6795 i.busy = false
6796 i.cancelTurn = nil
6797 pendingIdleWork := i.takePendingIdleWorkLocked()
6798 awaitingPre := i.awaitingStartupPre
6799 if failed {
6800 if cancelled {
6801 i.statusErr = "shell command cancelled"
6802 } else {
6803 i.statusErr = "shell command failed"
6804 }
6805 i.statusOK = ""
6806 } else {
6807 i.statusOK = "shell command finished"
6808 i.statusErr = ""
6809 }
6810 i.mu.Unlock()
6811 runPendingIdleWork(pendingIdleWork)
6812 i.invalidate()
6813 if awaitingPre {
6814 i.completeStartupPre()
6815 }
6816 }()
6817}
6818
6819func (i *Interactive) startTurn(parent context.Context, prompt string) {
6820 i.startTurnWithImages(parent, prompt, nil)
6821}
6822
6823func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
6824 i.startTurnRequest(parent, prompt, images, false, false)
6825}
6826
6827// startTurnRequest starts a new prompt or continues an existing user turn.
6828// continueExisting selects Agent.Continue so callers can retry a transcript
6829// message without appending it twice. overflowRecoveryAttempted suppresses
6830// the pre-turn threshold guard and the one-shot overflow recovery path; a
6831// normal post-compaction continuation leaves it false so a still-too-large
6832// rebuilt context can recover once more.
6833func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
6834 if i.agent == nil {
6835 // Text startup pre cannot run without credentials; continue so
6836 // deferred InitialInput (pre-fill or auto-submit) still applies.
6837 i.mu.Lock()
6838 awaitingPre := i.awaitingStartupPre
6839 i.mu.Unlock()
6840 if awaitingPre {
6841 i.completeStartupPre()
6842 }
6843 return
6844 }
6845 // Pre-turn safety: if the most recent context measurement is
6846 // already past the auto-compact threshold, condense before
6847 // sending so the next outbound request stays under the limit.
6848 // The condense flow re-fires the user's queued prompt for us, so
6849 // we just hand it off and exit.
6850 i.mu.Lock()
6851 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
6852 if needsPreCompact {
6853 // Reserve the compaction hand-off before releasing i.mu. A completion
6854 // update arriving in the small gap before runCompact acquires the
6855 // mutex must join the host queue rather than starting a competing turn.
6856 i.busy = true
6857 i.compacting = true
6858 i.autoCompacting = true
6859 if continueExisting {
6860 i.continueAfterCompact = true
6861 } else {
6862 i.pendingCompactPrompt = prompt
6863 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6864 i.hasPendingCompactPrompt = true
6865 }
6866 i.statusErr = ""
6867 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
6868 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
6869 i.mu.Unlock()
6870 i.invalidate()
6871 i.runCompact(parent, true)
6872 return
6873 }
6874 i.mu.Unlock()
6875
6876 ctx, cancel := context.WithCancel(parent)
6877 i.mu.Lock()
6878 i.busy = true
6879 i.spin.Start()
6880 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6881 i.cancelTurn = cancel
6882 i.statusErr = ""
6883 i.statusOK = ""
6884 i.streaming.Reset()
6885 i.streamOn = true
6886 i.pendingAlert = nil
6887 i.toolCalls = map[string]*tui.ToolCallView{}
6888 i.toolOrder = nil
6889 i.toolGate = map[string]int{}
6890 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
6891 i.scrollOffset = 0 // jump back to the bottom on new turn
6892 // Lift the resume tail cap once the user starts interacting. The
6893 // cap is purely a first-paint optimization (don't markdown the
6894 // whole history before showing anything). Keeping it active during
6895 // a turn makes the rendered chat a sliding window: appended
6896 // messages push older ones off the TOP of the buffer, which the
6897 // renderer must treat as a change above the viewport and repaint
6898 // fully, snapping the terminal's native scrollback to the bottom on
6899 // every streamed chunk. A fresh session has no cap (append-only),
6900 // which is why the jump only shows up in resumed sessions. Dropping
6901 // the cap here makes resumed turns append-only too.
6902 i.view.TailLimit = 0
6903 // Reset the auto-follow baseline so the very next render at
6904 // interactive.go:1053 doesn't see a synthetic shrink between
6905 // "last frame had the previous turn's tool overlay" and
6906 // "this frame had it cleared above". Without this, the guard
6907 // reads delta = -(rows in cleared overlay) and decrements
6908 // scrollOffset, which on terminals that mirror zut's pane
6909 // scroll into the host scrollbar visibly yanks the viewport.
6910 // See autofollow_shrink_test.go for the exact arithmetic.
6911 i.prevChatLen = 0
6912 i.prevChatCols = 0
6913 i.parkedTurn = 0 // starting a turn clears the /jump parked state
6914 i.parkedTotal = 0
6915 i.helpBlock = nil // hide the help block once the user asks something
6916 i.mu.Unlock()
6917 i.invalidate()
6918
6919 var (
6920 lastStop provider.StopReason
6921 lastTurnErr error
6922 )
6923 sink := func(ev core.AgentEvent) {
6924 if e, ok := ev.(core.EvTurnEnd); ok {
6925 lastStop = e.Stop
6926 lastTurnErr = e.Err
6927 }
6928 i.handleEvent(ev)
6929 i.invalidate()
6930 }
6931
6932 go func() {
6933 var err error
6934 if continueExisting {
6935 err = i.agent.Continue(ctx, sink)
6936 } else {
6937 err = i.agent.Prompt(ctx, prompt, images, sink)
6938 }
6939 i.mu.Lock()
6940 // Keep busy asserted through final cleanup and queue selection. A
6941 // watcher may enqueue a completion summary concurrently with the
6942 // provider's final event; publishing idle before inspecting queues can
6943 // strand that summary in the core agent queue or start an overlapping
6944 // turn.
6945 // Don't touch streamPending / streamFlushPending here — the
6946 // pacer may still be draining the final deltas and needs to
6947 // paint them even though Prompt has returned. It will reset
6948 // streamOn on its own once the buffer empties.
6949 if len(i.streamPending) == 0 {
6950 i.streamOn = false
6951 }
6952 i.cancelTurn = nil
6953 pendingIdleWork := i.takePendingIdleWorkLocked()
6954 if err != nil && ctx.Err() == nil {
6955 i.statusErr = err.Error()
6956 }
6957 // Decide whether to offer a model rescue picker for recoverable
6958 // provider failures (auth/rate/temporary). The picker opens after
6959 // the mutex is released so it can take its own locks freely.
6960 var (
6961 offer bool
6962 rescueWhy string
6963 rescueImgs []provider.ImageBlock
6964 rescueModel string
6965 rescueProv string
6966 rescueFprov string
6967 )
6968 if err != nil && ctx.Err() == nil {
6969 if ok, reason := classifyRescueError(err); ok {
6970 offer = true
6971 rescueWhy = reason
6972 rescueImgs = images
6973 rescueModel = i.cfg.Model
6974 rescueProv = i.cfg.Provider
6975 rescueFprov = extractFailedProvider(err)
6976 if rescueFprov == "" {
6977 rescueFprov = i.cfg.Provider
6978 }
6979 // Suppress the red banner — the rescue dialog already
6980 // surfaces the failure.
6981 i.statusErr = ""
6982 }
6983 }
6984 // Detect responses that reject the current context, either as an
6985 // HTTP 413 payload limit or a model context-window error. Token-
6986 // based auto-compact can miss both when metadata is stale or the
6987 // limit is measured in raw bytes. Compact once, then continue the
6988 // user message already present in the transcript.
6989 contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
6990 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
6991 if recoverContextOverflow {
6992 i.statusErr = ""
6993 i.continueAfterCompact = true
6994 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
6995 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
6996 }
6997 // Persist the assistant's reply (and every tool row before
6998 // it) to the session file while the turn memory is hot.
6999 // Without this, WriteNewTranscript only fires at zut exit,
7000 // meaning a crash or ungraceful kill drops the whole
7001 // conversation. FlushSession is idempotent (it advances the
7002 // baseline so subsequent flushes only write new rows).
7003 flush := i.cfg.FlushSession
7004 i.mu.Unlock()
7005 runPendingIdleWork(pendingIdleWork)
7006 if flush != nil {
7007 flush()
7008 }
7009 i.mu.Lock()
7010 awaitingPre := i.awaitingStartupPre
7011 // Pop the next queued message, if any, and relaunch.
7012 var next string
7013 var hasNext bool
7014 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
7015 next, i.queued = i.queued[0], i.queued[1:]
7016 hasNext = true
7017 }
7018 // If the turn was cancelled or errored, drop the queue so the
7019 // user isn't bombarded with stale messages after an interrupt.
7020 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
7021 i.queued = nil
7022 if i.agent != nil {
7023 i.agent.DrainQueuedMessages()
7024 }
7025 }
7026 // Decide whether the next thing to do is an auto-compaction.
7027 // Only fires when the turn completed cleanly AND no host-side
7028 // or agent-side queued messages are waiting (otherwise a queued
7029 // message would race the condense).
7030 agentQueued := 0
7031 if i.agent != nil {
7032 agentQueued = i.agent.QueuedMessageCount()
7033 }
7034 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7035 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7036 // The agent run can finish before the paced final text reaches the
7037 // transcript. A compaction replaces that transcript, so it must never
7038 // race the still-live stream frame; otherwise stale deltas can repaint
7039 // after the replacement and corrupt the scrollback renderer's model.
7040 if recoverContextOverflow || shouldAutoCompact {
7041 i.resetStreamingStateLocked()
7042 }
7043 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7044 i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact
7045 i.mu.Unlock()
7046 if alertReason != "" {
7047 i.scheduleMainAlert(alertReason)
7048 }
7049 i.invalidate()
7050 parent := i.runCtx
7051 if parent == nil {
7052 parent = context.Background()
7053 }
7054 if awaitingPre {
7055 i.completeStartupPre()
7056 return
7057 }
7058 switch {
7059 case hasNext:
7060 i.startTurn(parent, next)
7061 case continueQueued:
7062 i.startTurnRequest(parent, "", nil, true, false)
7063 case offer:
7064 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7065 case recoverContextOverflow:
7066 i.runCompact(parent, true)
7067 case shouldAutoCompact:
7068 i.runCompact(parent, true)
7069 }
7070 }()
7071}
7072
7073func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
7074 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
7075 return ""
7076 }
7077 if rescue {
7078 return "rescue_required"
7079 }
7080 if stop == provider.StopAborted {
7081 return ""
7082 }
7083 if err != nil || turnErr != nil || stop == provider.StopError {
7084 return "agent_error"
7085 }
7086 if stop == provider.StopLength {
7087 return "response_truncated"
7088 }
7089 return "agent_done"
7090}
7091
7092// openRescueDialog surfaces the rescue model picker after a recoverable
7093// provider failure. The pending prompt + images are stashed on the
7094// Interactive so a later applyRescueSelection can re-run the same turn
7095// against the freshly-picked model. activeProvider/failedProvider are
7096// usually the same, but some clients embed different prefixes in their
7097// errors than the configured provider id, so we accept both.
7098func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
7099 if i.rescueDialog == nil {
7100 return
7101 }
7102 loggedIn := []string{}
7103 if i.cfg.LoggedInProviders != nil {
7104 loggedIn = i.cfg.LoggedInProviders()
7105 }
7106 fprov := failedProvider
7107 if fprov == "" {
7108 fprov = activeProvider
7109 }
7110 i.mu.Lock()
7111 i.pendingRescuePrompt = prompt
7112 i.pendingRescueImages = images
7113 i.mu.Unlock()
7114 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
7115 i.invalidate()
7116}
7117
7118// applyRescueSelection switches model (cross-provider if needed) and
7119// re-runs the same prompt+images that just failed. Mirrors
7120// applyModelSelection's transcript-carry logic so the user keeps full
7121// session continuity across the swap.
7122func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
7123 if model == "" {
7124 return
7125 }
7126 i.applyRescueModelSelection(prov, model)
7127 i.mu.Lock()
7128 images := i.pendingRescueImages
7129 if prompt == "" {
7130 prompt = i.pendingRescuePrompt
7131 }
7132 i.pendingRescuePrompt = ""
7133 i.pendingRescueImages = nil
7134 i.mu.Unlock()
7135 parent := i.runCtx
7136 if parent == nil {
7137 parent = context.Background()
7138 }
7139 i.startTurnWithImages(parent, prompt, images)
7140}
7141
7142func stripAutoCompactNotes(notes []string) []string {
7143 if len(notes) == 0 {
7144 return notes
7145 }
7146 out := notes[:0]
7147 for _, n := range notes {
7148 if strings.Contains(n, "condensing history") {
7149 continue
7150 }
7151 out = append(out, n)
7152 }
7153 return out
7154}
7155
7156// autoCompactNoteLine returns a styled chat-area note for the
7157// inline auto-compact heads-up. Lives in extNotes so it survives
7158// the busy-spinner overwrite of the status row.
7159func autoCompactNoteLine(th tui.Theme, msg string) string {
7160 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7161}
7162
7163const defaultAutoCompactThreshold = 85
7164
7165func normalizeAutoCompactThreshold(threshold *int) int {
7166 if threshold == nil {
7167 return defaultAutoCompactThreshold
7168 }
7169 switch *threshold {
7170 case 0, 70, 80, 85, 90:
7171 return *threshold
7172 default:
7173 return defaultAutoCompactThreshold
7174 }
7175}
7176
7177func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7178 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
7179 return false
7180 }
7181 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
7182}
7183
7184// shouldAutoContinueAfterCompaction reports whether the transcript tail looks
7185// unfinished. A complete assistant text answer should not cause an extra
7186// model turn merely because its context was compacted; tool calls, reasoning-
7187// only replies, and non-assistant tails still need the model to resume.
7188func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
7189 if len(msgs) == 0 {
7190 return false
7191 }
7192 last := msgs[len(msgs)-1]
7193 if last.Role != provider.RoleAssistant {
7194 return true
7195 }
7196 hasText := false
7197 for _, content := range last.Content {
7198 switch block := content.(type) {
7199 case provider.ToolCallBlock:
7200 return true
7201 case provider.TextBlock:
7202 if strings.TrimSpace(block.Text) != "" {
7203 hasText = true
7204 }
7205 case provider.ReasoningBlock:
7206 // Reasoning without a visible answer is not terminal.
7207 default:
7208 return true
7209 }
7210 }
7211 return !hasText
7212}
7213
7214// shouldAutoCompactLocked reports whether the last turn pushed context
7215// usage past the auto-compact threshold. Must be called with i.mu
7216// held; it reads lastCtxInput and the current model's context window.
7217func (i *Interactive) shouldAutoCompactLocked() bool {
7218 if i.agent == nil {
7219 return false
7220 }
7221 if i.autoCompacting {
7222 return false
7223 }
7224 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
7225 if err != nil || m.ContextWindow <= 0 {
7226 return false
7227 }
7228 threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
7229 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
7230}
7231
7232func (i *Interactive) handleEvent(ev core.AgentEvent) {
7233 i.mu.Lock()
7234 defer i.mu.Unlock()
7235 i.activity.apply(ev)
7236 switch e := ev.(type) {
7237 case core.EvAssistantStart:
7238 // Fires at the top of every oneTurn, including follow-up
7239 // turns after tool use. Without this, the streaming buffer
7240 // is still marked off from the previous assistant message
7241 // and the final summary text pops in all at once instead
7242 // of typewriter-streaming delta by delta.
7243 i.streaming.Reset()
7244 i.streamPending = i.streamPending[:0]
7245 i.streamFlushPending = false
7246 i.streamOn = true
7247 // Clear the live tool-call overlay. Any tools from the
7248 // previous round are now fully folded into the transcript
7249 // (assistant tool_use block + tool role message with the
7250 // result), so keeping them in the overlay would duplicate
7251 // them in the view — once inside the finalised transcript
7252 // and once below the streaming block, with the streaming
7253 // summary sandwiched in between. The next EvToolUseStart
7254 // will populate fresh entries for this turn's tools.
7255 i.toolCalls = map[string]*tui.ToolCallView{}
7256 i.toolOrder = nil
7257 i.toolGate = map[string]int{}
7258 case core.EvTextDelta:
7259 // Buffer into streamPending; the paintPace ticker drains
7260 // it into i.streaming a few runes at a time for a smooth
7261 // typewriter effect independent of upstream chunk size.
7262 i.streamPending = append(i.streamPending, []rune(e.Delta)...)
7263 i.streamOn = true
7264 case core.EvAssistantMessage:
7265 // OnAssistant + telegram mirroring always fire on message
7266 // arrival — they read the FINAL message content, which is
7267 // complete regardless of what's still in the pacer.
7268 i.assistantMessageSideEffects(e.Message)
7269 // If the pacer still has characters to drain, keep streamOn
7270 // true and mark flush pending; the paintPace ticker will
7271 // drain the remainder and reset streaming state when done.
7272 // Otherwise (rare: full-replay sessions, abort paths) clear
7273 // synchronously so a later render doesn't show stale text.
7274 if len(i.streamPending) > 0 {
7275 i.streamFlushPending = true
7276 return
7277 }
7278 i.resetStreamingStateLocked()
7279 case core.EvToolUseStart:
7280 // Live streaming: pre-create the view so the user sees the
7281 // tool call being composed in real time. Any subsequent
7282 // EvToolCall for the same ID updates the same struct (the
7283 // final parsed args + name are already known here).
7284 if _, exists := i.toolCalls[e.ID]; !exists {
7285 i.toolCalls[e.ID] = &tui.ToolCallView{
7286 ID: e.ID,
7287 Name: e.Name,
7288 Streaming: true,
7289 }
7290 i.toolOrder = append(i.toolOrder, e.ID)
7291 i.gateToolLocked(e.ID)
7292 }
7293 case core.EvToolUseArgs:
7294 if tc, ok := i.toolCalls[e.ID]; ok {
7295 tc.RawJSONBuf += e.Delta
7296 // Refresh the live path as soon as it parses; used in
7297 // the header (write /Users/example/Desktop/demo.ts)
7298 // while the content is still streaming.
7299 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
7300 tc.LivePath = p
7301 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
7302 tc.LivePath = p
7303 }
7304 }
7305 case core.EvToolUseEnd:
7306 if tc, ok := i.toolCalls[e.ID]; ok {
7307 tc.Streaming = false
7308 }
7309 case core.EvToolCall:
7310 // If we already pre-created the view during streaming, just
7311 // refresh the final Args summary. Otherwise create a new one
7312 // (non-streaming providers or legacy paths).
7313 if tc, ok := i.toolCalls[e.ID]; ok {
7314 tc.Args = tui.ShortArgs(e.Name, e.Args)
7315 if tc.RawJSONBuf == "" {
7316 tc.RawJSONBuf = string(e.Args)
7317 }
7318 tc.Streaming = false
7319 } else {
7320 i.toolCalls[e.ID] = &tui.ToolCallView{
7321 ID: e.ID,
7322 Name: e.Name,
7323 Args: tui.ShortArgs(e.Name, e.Args),
7324 RawJSONBuf: string(e.Args),
7325 }
7326 i.toolOrder = append(i.toolOrder, e.ID)
7327 i.gateToolLocked(e.ID)
7328 }
7329 case core.EvToolResult:
7330 if tc, ok := i.toolCalls[e.ID]; ok {
7331 tc.Done = true
7332 tc.Error = e.Result.IsError
7333 tc.Preview = ""
7334 var text strings.Builder
7335 for _, c := range e.Result.Content {
7336 if tb, ok := c.(provider.TextBlock); ok {
7337 if text.Len() > 0 {
7338 text.WriteString("\n")
7339 }
7340 text.WriteString(tb.Text)
7341 }
7342 }
7343 tc.Result = text.String()
7344 }
7345 if i.cfg.OnToolResult != nil {
7346 i.cfg.OnToolResult(e.ID, e.Result)
7347 }
7348 case core.EvUsage:
7349 i.cumUsage = e.Cumulative
7350 if e.Usage.InputTokens > 0 {
7351 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
7352 }
7353 case core.EvTurnEnd:
7354 if e.Stop == provider.StopAborted {
7355 i.resetStreamingStateLocked()
7356 i.statusErr = ""
7357 i.statusOK = "cancelled"
7358 return
7359 }
7360 if e.Stop == provider.StopLength {
7361 // The model hit its output-token cap mid-response, so the
7362 // reply (often a long write/edit) is truncated. Surface it
7363 // explicitly, otherwise the turn just ends and reads like
7364 // the UI gave up. The agent already requests the model's
7365 // full MaxOutput budget, so this means the response genuinely
7366 // exceeded that ceiling; ask the user to continue.
7367 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
7368 i.statusOK = ""
7369 return
7370 }
7371 // Don't surface mid-loop stream errors as a red banner here.
7372 // EvTurnEnd fires after every step in a multi-step tool loop,
7373 // so a transient 503 / network blip would briefly paint a red
7374 // banner over the still-streaming chat before the agent loop
7375 // either retries or exits. The final error (if any) is set by
7376 // startTurnWithImages once Prompt() returns, and recoverable
7377 // failures are routed to the rescue picker instead — which
7378 // keeps the chat clean while the agent is working.
7379 _ = e.Err
7380 }
7381}
7382
7383// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7384// ag is still the live interactive agent. It serializes the identity check
7385// with agent replacement and swaps both values atomically in core.Agent.
7386func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7387 if ag == nil {
7388 return nil, false
7389 }
7390 // Match the lock order used by dynamic tool mutations so a refresh
7391 // cannot race a snapshot-and-replace operation that would reintroduce
7392 // the old registry after the atomic prompt swap.
7393 i.agentMu.Lock()
7394 defer i.agentMu.Unlock()
7395 i.mu.Lock()
7396 defer i.mu.Unlock()
7397 if i.agent != ag {
7398 return nil, false
7399 }
7400 return ag.SetPromptConfig(system, tools), true
7401}
7402
7403// DeferUntilIdle runs work immediately when the interactive agent is idle,
7404// or after the current turn/compaction/shell operation releases the busy
7405// state. Callbacks run outside i.mu.
7406func (i *Interactive) DeferUntilIdle(fn func()) {
7407 if fn == nil {
7408 return
7409 }
7410 i.mu.Lock()
7411 if i.busy {
7412 i.pendingIdleWork = append(i.pendingIdleWork, fn)
7413 i.mu.Unlock()
7414 return
7415 }
7416 i.mu.Unlock()
7417 fn()
7418}
7419
7420func (i *Interactive) takePendingIdleWorkLocked() []func() {
7421 work := i.pendingIdleWork
7422 i.pendingIdleWork = nil
7423 return work
7424}
7425
7426func runPendingIdleWork(work []func()) {
7427 for _, fn := range work {
7428 if fn != nil {
7429 fn()
7430 }
7431 }
7432}
7433
7434// Agent returns the current agent, if any. Used by cli.go to flush the
7435// final transcript to the session file.
7436func (i *Interactive) Agent() *core.Agent {
7437 i.mu.Lock()
7438 defer i.mu.Unlock()
7439 return i.agent
7440}
7441
7442// silence unused import in some build configs
7443var _ = fmt.Sprintf
7444
7445// runReloadExt triggers a live reload of every extension (discovered
7446// + explicit). Runs on a goroutine so the TUI stays responsive; the
7447// Manager.Reload takes a couple of hundred ms to shut down subprocs
7448// and respawn them. Shows a temporary status line throughout.
7449func (i *Interactive) runReloadExt(ctx context.Context) {
7450 if i.cfg.Extensions == nil {
7451 i.mu.Lock()
7452 i.statusErr = "no extension manager in this build"
7453 i.mu.Unlock()
7454 i.invalidate()
7455 return
7456 }
7457 i.mu.Lock()
7458 i.statusOK = "Reloading extensions..."
7459 i.statusErr = ""
7460 i.mu.Unlock()
7461 i.invalidate()
7462
7463 go func() {
7464 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
7465 msg, failed := formatReloadStatus(stats)
7466 seq := i.setReloadStatus(msg, failed)
7467 i.invalidate()
7468 go i.dismissReloadStatus(ctx, seq, msg, failed)
7469 }()
7470}
7471
7472// Confirm implements core.Confirmer. The agent goroutine calls
7473// this synchronously before every tool invocation when --no-yolo is
7474// active. We push the request onto the confirmDialog queue, trigger
7475// a redraw, and block the caller until the user answers.
7476//
7477// If the session is cancelled or the TUI exits mid-prompt, any
7478// pending request is refused via CancelAll so the agent doesn't
7479// deadlock.
7480func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
7481 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
7482}
7483
7484// ConfirmToolCall attaches a side-effect-free preview to the matching live
7485// tool panel, then blocks until the user approves or refuses the call.
7486func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
7487 resp := make(chan core.ConfirmDecision, 1)
7488 if isBtwOrigin(call.Origin) {
7489 req := &confirmRequest{
7490 toolName: call.Name,
7491 preview: call.Summary,
7492 resp: resp,
7493 returnToChild: true,
7494 }
7495 if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7496 i.confirmDialog.Enqueue(req)
7497 }) {
7498 return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7499 }
7500 i.invalidate()
7501 return <-resp
7502 }
7503 if call.ID != "" {
7504 i.mu.Lock()
7505 if tc, ok := i.toolCalls[call.ID]; ok {
7506 tc.Args = call.Summary
7507 tc.Preview = call.Content
7508 i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
7509 }
7510 i.mu.Unlock()
7511 }
7512 i.confirmDialog.Enqueue(&confirmRequest{
7513 toolName: call.Name,
7514 preview: call.Summary,
7515 resp: resp,
7516 })
7517 i.invalidate()
7518 return <-resp
7519}
7520
7521// openTelegramDialog shows the picker for `/telegram` with no arg.
7522// Items depend on current state: disconnect + status when running,
7523// connect + status when stopped.
7524func (i *Interactive) openTelegramDialog() {
7525 items := i.telegramMenuItems()
7526 if len(items) == 0 {
7527 i.mu.Lock()
7528 i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
7529 i.mu.Unlock()
7530 i.invalidate()
7531 return
7532 }
7533 i.telegramDialog.Open(items)
7534 i.invalidate()
7535}
7536
7537// telegramMenuItems builds the dialog entries for the current
7538// bridge state. Returns empty when no bot.json exists so the
7539// caller can show a helpful status line instead of an empty menu.
7540func (i *Interactive) telegramMenuItems() []telegramItem {
7541 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
7542 if err != nil || cfg.BotToken == "" {
7543 return nil
7544 }
7545 var items []telegramItem
7546 if i.telegramBridge != nil && i.telegramBridge.Active() {
7547 items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
7548 st := i.telegramBridge.State()
7549 hint := "active"
7550 if st.Username != "" {
7551 hint += " as @" + st.Username
7552 }
7553 items = append(items, telegramItem{label: "status", action: "status", hint: hint})
7554 } else {
7555 label := "connect"
7556 hint := "start mirroring dms into this session"
7557 if cfg.AllowedUserID == 0 {
7558 hint = "awaiting pairing (send /start to the bot once connected)"
7559 }
7560 items = append(items, telegramItem{label: label, action: "connect", hint: hint})
7561 items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
7562 }
7563 return items
7564}
7565
7566// doTelegram dispatches one of the three explicit actions. Called
7567// from /telegram <action> or after the picker selects a row.
7568func (i *Interactive) doTelegram(action string) {
7569 switch action {
7570 case "connect":
7571 i.telegramConnect()
7572 case "disconnect":
7573 i.telegramDisconnect()
7574 case "status":
7575 i.telegramStatus()
7576 default:
7577 i.mu.Lock()
7578 i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
7579 i.mu.Unlock()
7580 i.invalidate()
7581 }
7582}
7583
7584// telegramConnect starts the bridge. Refuses if it's already
7585// running or if the on-disk bot.json is missing a token.
7586func (i *Interactive) telegramConnect() {
7587 if i.telegramBridge != nil && i.telegramBridge.Active() {
7588 i.mu.Lock()
7589 i.statusOK = "telegram already connected"
7590 i.statusErr = ""
7591 i.mu.Unlock()
7592 i.invalidate()
7593 return
7594 }
7595 cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
7596 if err != nil {
7597 i.mu.Lock()
7598 i.statusErr = "telegram: " + err.Error()
7599 i.mu.Unlock()
7600 i.invalidate()
7601 return
7602 }
7603 if cfg.BotToken == "" {
7604 i.mu.Lock()
7605 i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
7606 i.mu.Unlock()
7607 i.invalidate()
7608 return
7609 }
7610 // Refuse to start when a background daemon is already polling
7611 // the same bot. Two concurrent long-poll consumers race each
7612 // update and one always loses, so DMs get half-delivered. The
7613 // user can `zut telegram-bot stop` first, then /telegram connect.
7614 if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
7615 i.mu.Lock()
7616 i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
7617 i.mu.Unlock()
7618 i.invalidate()
7619 return
7620 }
7621 i.telegramBridge = &telegram.Bridge{
7622 Client: telegram.NewClient(cfg.BotToken),
7623 Config: cfg,
7624 Save: func(next telegram.Config) error {
7625 return telegram.SaveConfig(i.cfg.ZutHome, next)
7626 },
7627 Host: &telegramHost{iv: i},
7628 }
7629 if err := i.telegramBridge.Start(i.runCtx); err != nil {
7630 i.telegramBridge = nil
7631 i.mu.Lock()
7632 i.statusErr = "telegram connect failed: " + err.Error()
7633 i.mu.Unlock()
7634 i.invalidate()
7635 return
7636 }
7637 i.applyTelegramTools(true)
7638 state := i.telegramBridge.State()
7639 label := "telegram connected"
7640 if state.Username != "" {
7641 label += " as @" + state.Username
7642 }
7643 if state.PairedID == 0 {
7644 label += " — send /start to the bot from your phone to claim it"
7645 }
7646 i.mu.Lock()
7647 i.statusOK = label
7648 i.statusErr = ""
7649 i.mu.Unlock()
7650 i.invalidate()
7651}
7652
7653// telegramDisconnect stops the bridge. No-op when already stopped.
7654func (i *Interactive) telegramDisconnect() {
7655 if i.telegramBridge == nil || !i.telegramBridge.Active() {
7656 i.mu.Lock()
7657 i.statusOK = "telegram already disconnected"
7658 i.statusErr = ""
7659 i.mu.Unlock()
7660 i.invalidate()
7661 return
7662 }
7663 i.telegramBridge.Stop()
7664 i.applyTelegramTools(false)
7665 i.mu.Lock()
7666 i.statusOK = "telegram disconnected"
7667 i.statusErr = ""
7668 i.mu.Unlock()
7669 i.invalidate()
7670}
7671
7672// telegramSenderAdapter wraps the bridge so the tools package can
7673// drive it without importing telegram directly. The Active() check
7674// is forwarded to the bridge so the tool can fail clearly with a
7675// model-readable error when the user disconnected mid-turn.
7676type telegramSenderAdapter struct {
7677 bridge *telegram.Bridge
7678}
7679
7680func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
7681 if a.bridge == nil {
7682 return fmt.Errorf("telegram bridge is not connected")
7683 }
7684 return a.bridge.SendImage(ctx, path, caption)
7685}
7686
7687func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
7688 if a.bridge == nil {
7689 return fmt.Errorf("telegram bridge is not connected")
7690 }
7691 return a.bridge.SendDocument(ctx, path, caption)
7692}
7693
7694func (a telegramSenderAdapter) Active() bool {
7695 return a.bridge != nil && a.bridge.Active()
7696}
7697
7698// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
7699// delegated turn's outcome separately from the long-lived daemon status.
7700type subagentWatchEntry struct {
7701 agent *subagents.Agent
7702 task string
7703 done bool
7704 outcome string
7705 err string
7706}
7707
7708// TrackSubagentWorker is the exported entry point used by the cli to
7709// hand a freshly-spawned auto-subagents agent off to the watcher.
7710func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
7711 i.trackSubagentWorker(a, task)
7712}
7713
7714// trackSubagentWorker records a freshly-spawned auto-subagents agent and
7715// subscribes to both ways its delegated task can finish. Successful
7716// long-lived agents report a prompt-level turn_end and keep listening;
7717// startup failures and unexpected daemon exits only unblock Agent.Wait.
7718// Whichever signal arrives first completes the entry exactly once.
7719//
7720// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
7721// subagent is enabled, so this is a no-op when the feature is off.
7722func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
7723 if i == nil || a == nil {
7724 return
7725 }
7726 entry := &subagentWatchEntry{agent: a, task: task}
7727 i.subagentWatchMu.Lock()
7728 i.subagentWatch = append(i.subagentWatch, entry)
7729 i.subagentWatchMu.Unlock()
7730 i.invalidate()
7731
7732 a.SetOnTurnEnd(func(step int, errMsg string) {
7733 outcome := "completed"
7734 if errMsg != "" {
7735 outcome = "failed"
7736 }
7737 i.completeSupervisorWatchEntry(entry, outcome, errMsg)
7738 })
7739 go func() {
7740 a.Wait()
7741 snap := a.Snapshot()
7742 outcome := string(snap.Status)
7743 if snap.Status == subagents.StatusDone {
7744 outcome = "completed"
7745 }
7746 i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
7747 }()
7748}
7749
7750func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
7751 i.subagentWatchMu.Lock()
7752 if entry.done {
7753 i.subagentWatchMu.Unlock()
7754 return
7755 }
7756 entry.done = true
7757 entry.outcome = outcome
7758 entry.err = errMsg
7759 allDone := true
7760 for _, e := range i.subagentWatch {
7761 if !e.done {
7762 allDone = false
7763 break
7764 }
7765 }
7766 var batch []*subagentWatchEntry
7767 if allDone {
7768 batch = i.subagentWatch
7769 i.subagentWatch = nil
7770 }
7771 i.subagentWatchMu.Unlock()
7772 if len(batch) != 0 {
7773 i.flushSupervisorSummary(batch)
7774 }
7775}
7776
7777// flushSupervisorSummary composes a synthetic user turn describing every
7778// sub-agent's outcome and injects it via SubmitOrQueue so the main
7779// agent picks it up at the next safe boundary. The summary is
7780// phrased as a system update ("Auto-subagents finished: ...") so the
7781// model treats it as observed state, not as a fresh user request.
7782func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
7783 if len(batch) == 0 {
7784 return
7785 }
7786 var sb strings.Builder
7787 fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
7788 for idx, e := range batch {
7789 snap := e.agent.Snapshot()
7790 status := e.outcome
7791 if status == "" {
7792 status = string(snap.Status)
7793 }
7794 task := snap.Task
7795 if task == "" {
7796 task = e.task
7797 }
7798 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
7799 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
7800 if snap.Err != "" {
7801 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
7802 } else if e.err != "" {
7803 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
7804 }
7805 if snap.LastAssistant != "" {
7806 sb.WriteString(" final response:\n")
7807 sb.WriteString(snap.LastAssistant)
7808 sb.WriteString("\n")
7809 } else if tail := strings.TrimSpace(snap.Tail); tail != "" {
7810 fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
7811 }
7812 sb.WriteString("\n")
7813 }
7814 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.")
7815 i.SubmitOrQueue(sb.String(), nil)
7816}
7817
7818func truncateForSummary(s string, n int) string {
7819 s = strings.TrimSpace(s)
7820 if len(s) <= n {
7821 return s
7822 }
7823 return s[:n-3] + "..."
7824}
7825
7826// autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
7827// toggle, in the same order Resolve appends them to a new agent.
7828func autoSubagentsAddenda(cfg InteractiveConfig) []string {
7829 addenda := make([]string, 0, 2)
7830 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
7831 addenda = append(addenda, addendum)
7832 }
7833 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
7834 addenda = append(addenda, addendum)
7835 }
7836 return addenda
7837}
7838
7839func containsAutoSubagentsAddendum(addenda []string, want string) bool {
7840 for _, addendum := range addenda {
7841 if addendum == want {
7842 return true
7843 }
7844 }
7845 return false
7846}
7847
7848// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
7849// have been appended by auto-subagents. Resolve appends owned blocks at the
7850// end, so removing the last occurrence preserves an identical base block.
7851func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
7852 idx := strings.LastIndex(system, addendum)
7853 if idx < 0 {
7854 return system, false
7855 }
7856 return system[:idx] + system[idx+len(addendum):], true
7857}
7858
7859// applyAutoSubagentsSystemPrompt appends (active=true) or strips
7860// (active=false) the auto-subagents prompt blocks on the running agent.
7861// The profile manifest and delegation guidance are managed together so
7862// toggling auto-subagents never leaves the model with names but no tool.
7863func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7864 // i.mu is sufficient here: this path updates the existing agent's system
7865 // prompt and managed addenda in place; it does not replace the agent or
7866 // its tool registry, so agentMu is not required.
7867 i.mu.Lock()
7868 defer i.mu.Unlock()
7869 if i.agent == nil {
7870 return
7871 }
7872 addenda := autoSubagentsAddenda(i.cfg)
7873 if active {
7874 sys, _ := i.agent.PromptConfig()
7875 for _, addendum := range addenda {
7876 if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
7877 continue
7878 }
7879 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
7880 sys += "\n\n"
7881 }
7882 sys += addendum
7883 i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
7884 }
7885 i.agent.SetSystemPrompt(sys)
7886 return
7887 }
7888
7889 sys, _ := i.agent.PromptConfig()
7890 changed := false
7891 for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
7892 var removed bool
7893 sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
7894 changed = changed || removed
7895 }
7896 i.managedAutoSubagentsAddenda = nil
7897 if changed {
7898 i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
7899 }
7900}
7901
7902// applyAutoSubagentsTool registers (active=true) or removes (active=false)
7903// the auto-subagent tools on the running agent so the model only sees them
7904// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
7905// snapshot+mutate pattern so extension tools and /reload-ext additions
7906// survive a toggle.
7907func (i *Interactive) autoSubagentsAvailable() bool {
7908 return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
7909}
7910
7911func (i *Interactive) autoSubagentsUnavailableHint() string {
7912 var hints []string
7913 if i.cfg.Supervisor == nil {
7914 hints = append(hints, "subagent supervisor not available in this mode")
7915 }
7916 if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
7917 hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
7918 }
7919 return strings.Join(hints, "; ")
7920}
7921
7922func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
7923 return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
7924}
7925
7926func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
7927 if cfg.AutoSubagentsStatusToolAllowed != nil {
7928 return *cfg.AutoSubagentsStatusToolAllowed
7929 }
7930 // Preserve the old single-boolean embedding contract: when callers have
7931 // not supplied the new field, status follows the existing delegation gate.
7932 return autoSubagentsToolAllowedConfig(cfg)
7933}
7934
7935func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
7936 return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
7937}
7938
7939func (i *Interactive) autoSubagentsToolAllowed() bool {
7940 return autoSubagentsToolAllowedConfig(i.cfg)
7941}
7942
7943func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
7944 return autoSubagentsStatusToolAllowedConfig(i.cfg)
7945}
7946
7947func (i *Interactive) autoSubagentsEnabled() bool {
7948 i.mu.Lock()
7949 defer i.mu.Unlock()
7950 return i.autoSubagentsEnabledLocked()
7951}
7952
7953func (i *Interactive) autoSubagentsEnabledLocked() bool {
7954 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
7955}
7956
7957func (i *Interactive) applyAutoSubagentsTool(active bool) {
7958 i.agentMu.Lock()
7959 defer i.agentMu.Unlock()
7960 i.mu.Lock()
7961 defer i.mu.Unlock()
7962 if i.agent == nil {
7963 return
7964 }
7965 current := i.agent.ToolsSnapshot()
7966 next := core.Registry{}
7967 for name, t := range current {
7968 if name == "subagent_spawn" || name == "subagent_status" {
7969 continue
7970 }
7971 next[name] = t
7972 }
7973 if active && i.autoSubagentsAvailable() {
7974 if i.autoSubagentsToolAllowed() {
7975 canonical := &tools.SubagentSpawnTool{
7976 Supervisor: i.cfg.Supervisor,
7977 Enabled: func() bool { return i.autoSubagentsEnabled() },
7978 DefaultModel: func() string { return i.cfg.Model },
7979 DefaultProvider: func() string { return i.cfg.Provider },
7980 DefaultReasoning: func() string { return i.cfg.Reasoning },
7981 ResolveSubagent: i.cfg.ResolveSubagent,
7982 OnSpawned: i.trackSubagentWorker,
7983 }
7984 next[canonical.Name()] = canonical
7985 }
7986 if i.autoSubagentsStatusToolAllowed() {
7987 statusTool := &tools.SubagentStatusTool{
7988 Supervisor: i.cfg.Supervisor,
7989 Enabled: func() bool { return i.autoSubagentsEnabled() },
7990 }
7991 next[statusTool.Name()] = statusTool
7992 }
7993 }
7994 i.agent.SetTools(next)
7995}
7996
7997// applyTelegramTools registers (active=true) or removes (active=false)
7998// the telegram_send_image and telegram_send_file tools on the running
7999// agent so the model only sees them while the bridge is connected.
8000// Snapshots and mutates the live tool registry so any extension or
8001// /reload-ext additions made while Telegram is connected survive a
8002// later /telegram disconnect (we only add or strip the two telegram
8003// entries, never the rest).
8004func (i *Interactive) applyTelegramTools(active bool) {
8005 i.agentMu.Lock()
8006 defer i.agentMu.Unlock()
8007 i.mu.Lock()
8008 defer i.mu.Unlock()
8009 if i.agent == nil {
8010 return
8011 }
8012 current := i.agent.ToolsSnapshot()
8013 next := core.Registry{}
8014 for name, t := range current {
8015 if name == "telegram_send_image" || name == "telegram_send_file" {
8016 continue
8017 }
8018 next[name] = t
8019 }
8020 if active {
8021 sender := telegramSenderAdapter{bridge: i.telegramBridge}
8022 next["telegram_send_image"] = &tools.TelegramSendImageTool{
8023 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
8024 }
8025 next["telegram_send_file"] = &tools.TelegramSendFileTool{
8026 CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
8027 }
8028 }
8029 i.agent.SetTools(next)
8030}
8031
8032// telegramStatus writes a one-liner describing the bridge state.
8033// Reports on both the in-tui bridge and the background daemon so
8034// the user isn't confused when the daemon owns the poll loop.
8035func (i *Interactive) telegramStatus() {
8036 var msg string
8037 if i.telegramBridge != nil && i.telegramBridge.Active() {
8038 s := i.telegramBridge.State()
8039 msg = "telegram: connected (tui bridge)"
8040 if s.Username != "" {
8041 msg += " as @" + s.Username
8042 }
8043 if s.PairedID != 0 {
8044 msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
8045 } else {
8046 msg += " - awaiting pairing"
8047 }
8048 } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8049 msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
8050 } else {
8051 cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
8052 if cfg.BotToken == "" {
8053 msg = "telegram: not configured. run `zut telegram-bot setup` first."
8054 } else {
8055 msg = "telegram: disconnected"
8056 if cfg.BotUsername != "" {
8057 msg += " (@" + cfg.BotUsername + " ready to connect)"
8058 }
8059 }
8060 }
8061 i.mu.Lock()
8062 i.statusOK = msg
8063 i.statusErr = ""
8064 i.mu.Unlock()
8065 i.invalidate()
8066}
8067
8068// telegramHost adapts *Interactive to telegram.Host so the bridge
8069// can call back into the TUI without importing modes directly.
8070type telegramHost struct{ iv *Interactive }
8071
8072func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
8073 h.iv.SubmitOrQueue(prompt, images)
8074}
8075
8076func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
8077
8078func (h *telegramHost) Status() string {
8079 h.iv.mu.Lock()
8080 providerName := h.iv.cfg.Provider
8081 model := h.iv.cfg.Model
8082 cwd := h.iv.cfg.CWD
8083 usage := h.iv.cumUsage
8084 subscription := h.iv.cfg.AuthMethod == "oauth"
8085 ctxUsed := h.iv.lastCtxInput
8086 busy := h.iv.busy
8087 queued := len(h.iv.queued)
8088 h.iv.mu.Unlock()
8089
8090 ctxMax := 0
8091 if m, err := provider.FindModel(providerName, model); err == nil {
8092 ctxMax = m.ContextWindow
8093 }
8094 return telegram.FormatStatus(telegram.StatusSnapshot{
8095 Provider: providerName,
8096 Model: model,
8097 CWD: cwd,
8098 Usage: usage,
8099 Subscription: subscription,
8100 ContextUsed: ctxUsed,
8101 ContextMax: ctxMax,
8102 Busy: busy,
8103 Queued: queued,
8104 })
8105}
8106
8107func (h *telegramHost) Notify(level, message string) {
8108 h.iv.mu.Lock()
8109 switch level {
8110 case "error", "warn":
8111 h.iv.statusErr = message
8112 h.iv.statusOK = ""
8113 default:
8114 h.iv.statusOK = message
8115 h.iv.statusErr = ""
8116 }
8117 h.iv.mu.Unlock()
8118 h.iv.invalidate()
8119}
8120
8121// openSessionOpsDialog shows the picker for `/session` with no arg.
8122// Always offers export, import, fork, tree; the handlers bail with
8123// a clear status message when the precondition isn't met (empty
8124// transcript on fork; no parent/siblings on tree).
8125func (i *Interactive) openSessionOpsDialog() {
8126 items := []sessionOpsItem{
8127 {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
8128 {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
8129 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
8130 {label: "tree", action: "tree", hint: "switch between branches in this directory"},
8131 }
8132 i.sessionOpsDialog.Open(items)
8133 i.invalidate()
8134}
8135
8136// doSessionOp dispatches export, import, fork, or tree. arg is the
8137// optional positional argument from e.g. /session export <path>
8138// or /session import <path>; fork and tree ignore it.
8139func (i *Interactive) doSessionOp(action, arg string) {
8140 switch action {
8141 case "export":
8142 i.doSessionExport(arg)
8143 case "import":
8144 i.doSessionImport(arg)
8145 case "fork":
8146 i.doSessionFork()
8147 case "tree":
8148 i.doSessionTree()
8149 default:
8150 i.mu.Lock()
8151 i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
8152 i.mu.Unlock()
8153 i.invalidate()
8154 }
8155}
8156
8157// doSessionExport writes the live session file to destination path
8158// dst. When dst is empty we default to ~/Downloads (falling back to
8159// the user's home directory if it doesn't exist). The helper
8160// expands a leading `~` and creates any missing parent directories.
8161func (i *Interactive) doSessionExport(dst string) {
8162 src := i.currentSessionPath()
8163 if src == "" {
8164 i.mu.Lock()
8165 i.statusErr = "export: no session is active (running with --no-session?)"
8166 i.mu.Unlock()
8167 i.invalidate()
8168 return
8169 }
8170 // Persist any in-memory agent messages to the session file so
8171 // the export carries the full conversation. Without this, the
8172 // default lazy-flush-at-exit strategy leaves most of a running
8173 // session unwritten and the export ends up with only the meta.
8174 if i.cfg.FlushSession != nil {
8175 i.cfg.FlushSession()
8176 }
8177 dst = unquotePath(dst)
8178 if dst == "" {
8179 dst = defaultExportDir()
8180 } else {
8181 dst = expandTilde(dst)
8182 }
8183 out, err := core.ExportSession(src, dst)
8184 if err != nil {
8185 i.mu.Lock()
8186 i.statusErr = "export: " + err.Error()
8187 i.mu.Unlock()
8188 i.invalidate()
8189 return
8190 }
8191 i.mu.Lock()
8192 i.statusOK = "exported session to " + friendlyPath(out)
8193 i.statusErr = ""
8194 i.mu.Unlock()
8195 i.invalidate()
8196}
8197
8198// doSessionImport copies the .zutsession file at src into the
8199// running cwd's sessions directory and loads it as the active
8200// session, same as `/sessions` -> pick. When src is empty we ask
8201// the user to pass a path (no usable default here).
8202func (i *Interactive) doSessionImport(src string) {
8203 src = unquotePath(src)
8204 if src == "" {
8205 i.mu.Lock()
8206 i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
8207 i.mu.Unlock()
8208 i.invalidate()
8209 return
8210 }
8211 src = expandTilde(src)
8212 if _, err := os.Stat(src); err != nil {
8213 i.mu.Lock()
8214 i.statusErr = "import: " + err.Error()
8215 i.mu.Unlock()
8216 i.invalidate()
8217 return
8218 }
8219 newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
8220 if err != nil {
8221 i.mu.Lock()
8222 i.statusErr = "import: " + err.Error()
8223 i.mu.Unlock()
8224 i.invalidate()
8225 return
8226 }
8227 if i.cfg.LoadSession == nil {
8228 i.mu.Lock()
8229 i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
8230 i.statusErr = ""
8231 i.mu.Unlock()
8232 i.invalidate()
8233 return
8234 }
8235 i.markSessionTitleSwitching()
8236 if err := i.cfg.LoadSession(newPath); err != nil {
8237 i.restoreFailedSessionTitle()
8238 i.mu.Lock()
8239 i.statusErr = "import: load failed: " + err.Error()
8240 i.mu.Unlock()
8241 i.invalidate()
8242 return
8243 }
8244 i.restoreLoadedSessionTitle()
8245 i.mu.Lock()
8246 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
8247 i.statusErr = ""
8248 i.mu.Unlock()
8249 i.invalidate()
8250}
8251
8252// defaultExportDir returns ~/Downloads when it exists, or ~ as a
8253// fallback, or /tmp on exotic machines with no home dir.
8254func defaultExportDir() string {
8255 home, err := os.UserHomeDir()
8256 if err != nil || home == "" {
8257 return os.TempDir()
8258 }
8259 downloads := filepath.Join(home, "Downloads")
8260 if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
8261 return downloads
8262 }
8263 return home
8264}
8265
8266// expandTilde turns a leading ~ into the user's home directory.
8267// Returns the input unchanged when there's no tilde or no home.
8268func expandTilde(p string) string {
8269 if p == "" || p[0] != '~' {
8270 return p
8271 }
8272 home, err := os.UserHomeDir()
8273 if err != nil || home == "" {
8274 return p
8275 }
8276 if len(p) == 1 {
8277 return home
8278 }
8279 if p[1] == '/' || p[1] == filepath.Separator {
8280 return filepath.Join(home, p[2:])
8281 }
8282 return p
8283}
8284
8285// unquotePath strips a matching pair of surrounding single or
8286// double quotes. Drag-drop paste in the tui auto-quotes dropped
8287// file paths so the shell-like `/session import 'foo bar.zs'`
8288// stays well-formed; when the TUI's own slash handler consumes
8289// the arg, we want the raw path back.
8290func unquotePath(p string) string {
8291 p = strings.TrimSpace(p)
8292 if len(p) >= 2 {
8293 first := p[0]
8294 last := p[len(p)-1]
8295 if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
8296 p = p[1 : len(p)-1]
8297 }
8298 }
8299 return p
8300}
8301
8302// friendlyPath collapses the user's home directory to a leading ~
8303// so status messages read cleanly. Falls back to the raw path when
8304// the home dir is unknown.
8305func friendlyPath(p string) string {
8306 home, err := os.UserHomeDir()
8307 if err != nil || home == "" {
8308 return p
8309 }
8310 if strings.HasPrefix(p, home+string(filepath.Separator)) {
8311 return "~" + p[len(home):]
8312 }
8313 return p
8314}
8315
8316// doSessionFork opens the /jump turn picker in "fork mode". The
8317// next selection branches the current session at that user turn
8318// instead of scrolling the viewport to it.
8319func (i *Interactive) doSessionFork() {
8320 path := i.currentSessionPath()
8321 if path == "" {
8322 i.mu.Lock()
8323 i.statusErr = "fork: no session is active (running with --no-session?)"
8324 i.mu.Unlock()
8325 i.invalidate()
8326 return
8327 }
8328 i.mu.Lock()
8329 busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
8330 queued := len(i.queued) != 0
8331 ag := i.agent
8332 i.mu.Unlock()
8333 if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
8334 i.mu.Lock()
8335 i.statusErr = "fork: wait until the current turn and queued messages finish"
8336 i.mu.Unlock()
8337 i.invalidate()
8338 return
8339 }
8340 if i.cfg.FlushSession != nil {
8341 i.cfg.FlushSession()
8342 }
8343 snapshot, err := core.ReadSessionSnapshot(path)
8344 if err != nil {
8345 i.mu.Lock()
8346 i.statusErr = "fork: " + err.Error()
8347 i.mu.Unlock()
8348 i.invalidate()
8349 return
8350 }
8351 msgs := snapshot.Messages
8352 if len(msgs) == 0 {
8353 i.mu.Lock()
8354 i.statusErr = "fork: transcript is empty; nothing to fork from"
8355 i.mu.Unlock()
8356 i.invalidate()
8357 return
8358 }
8359 i.pendingFork = true
8360 i.jumpDialog.Open(msgs, "")
8361 i.invalidate()
8362}
8363
8364// doSessionTree opens the current session family after the shared
8365// fail-closed gate succeeds. It intentionally does not fall back to the
8366// in-memory transcript: a tree that cannot be persisted and reloaded cannot
8367// safely create a navigation branch.
8368func (i *Interactive) doSessionTree() {
8369 i.openSessionTree()
8370}
8371
8372// applySessionTreeMessageSelection keeps the old scalar integration
8373// source-compatible for package consumers. New dialog selections use the
8374// structured sessionTreeTarget path below.
8375func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
8376 target := sessionTreeTarget{
8377 SourcePath: src,
8378 EffectiveIndex: msgIdx,
8379 SelectionBoundary: msgIdx + 1,
8380 Role: role,
8381 UserDraft: prompt,
8382 Boundary: sessionTreeMessageBoundary,
8383 }
8384 if role == provider.RoleUser {
8385 target.SelectionBoundary = msgIdx
8386 }
8387 i.applySessionTreeTarget(target, turnNo)
8388}
8389
8390// applySessionTreeTarget checks out a structured dialog target. The target's
8391// source and effective indices come from the same preflight snapshot the
8392// dialog rendered, while the live read below preserves compatibility with
8393// the existing LoadSession(path) error callback.
8394func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
8395 // Selection is a state-changing operation too. Use the selection gate so
8396 // a queued/busy turn cannot race the hidden branch or accidentally submit
8397 // a provider turn while the session is being swapped. The open gate cannot
8398 // be reused here because the tree dialog itself owns the keyboard.
8399 if !i.canCommitSessionTreeSelection() {
8400 i.setSessionTreeError("tree: session branching is not available in this build")
8401 return
8402 }
8403 src := target.SourcePath
8404 if src == "" {
8405 src = i.currentSessionPath()
8406 }
8407 if src == "" {
8408 i.setSessionTreeError("tree: no session is active")
8409 return
8410 }
8411
8412 // Read and validate the selected row before flushing or creating anything.
8413 // This gives tool-call/result pairs an atomic boundary and prevents a
8414 // malformed selection from changing the active session. Historical rows
8415 // come from a pre-compaction segment; current rows use the effective
8416 // snapshot that the running agent resumes from.
8417 var msgs []provider.Message
8418 var historicalSegment core.SessionHistorySegment
8419 if target.Historical {
8420 history, err := core.ReadSessionHistory(src)
8421 if err != nil {
8422 i.setSessionTreeError("tree: read selection: " + err.Error())
8423 return
8424 }
8425 if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
8426 i.setSessionTreeError("tree: selected history segment is unavailable")
8427 return
8428 }
8429 historicalSegment = history.Segments[target.HistorySegment]
8430 msgs = historicalSegment.Messages
8431 } else {
8432 sess, current, err := core.OpenSession(src)
8433 if err != nil {
8434 i.setSessionTreeError("tree: read selection: " + err.Error())
8435 return
8436 }
8437 _ = sess.Close()
8438 msgs = current
8439 }
8440 selection, err := sessionTreeSelection(msgs, target)
8441 if err != nil {
8442 i.setSessionTreeError("tree: " + err.Error())
8443 return
8444 }
8445 if i.cfg.FlushSession != nil {
8446 i.cfg.FlushSession()
8447 }
8448 var newPath string
8449 if target.Historical {
8450 newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
8451 } else {
8452 newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
8453 }
8454 if err != nil {
8455 i.setSessionTreeError("tree: " + err.Error())
8456 return
8457 }
8458 i.markSessionTitleSwitching()
8459 if err := i.cfg.LoadSession(newPath); err != nil {
8460 i.restoreFailedSessionTitle()
8461 i.setSessionTreeError("tree: checkout failed: " + err.Error())
8462 return
8463 }
8464
8465 // LoadSession is intentionally the old func(string) error callback: the
8466 // CLI and embedders already own agent/session swapping. Refresh the view
8467 // from the swapped agent here instead of requiring a new callback result.
8468 i.resetSessionTitleForFreshBranch()
8469 i.scrollToBottom()
8470 i.mu.Lock()
8471 i.lastCtxInput = 0
8472 if i.agent != nil {
8473 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
8474 i.cumUsage = i.agent.Cost()
8475 last := i.agent.LastTurnUsage()
8476 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
8477 i.view.InvalidateRenderCache()
8478 }
8479 i.toolCalls = map[string]*tui.ToolCallView{}
8480 i.toolOrder = nil
8481 i.toolGate = map[string]int{}
8482 i.extNotes = nil
8483 if selection.restoreDraft {
8484 i.ed.SetValue(selection.draftText)
8485 i.clipboardImages = selection.images
8486 i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
8487 } else {
8488 i.ed.Clear()
8489 i.clipboardImages = nil
8490 i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
8491 }
8492 i.statusErr = ""
8493 i.mu.Unlock()
8494 i.scrollToBottom()
8495 // Selection only changes local/session state. In particular, do not call
8496 // Submit, startTurn, or queue a message here; the restored user draft is
8497 // submitted only by a later explicit Enter.
8498 i.invalidate()
8499}
8500
8501// applyForkSelection branches the current session at msgIdx+1 (so
8502// the selected user message and everything before it is included
8503// in the new branch), then switches the running agent to the new
8504// file. Called from the jump-dialog handler when pendingFork=true.
8505func (i *Interactive) applyForkSelection(msgIdx int) {
8506 i.pendingFork = false
8507 src := i.currentSessionPath()
8508 if src == "" {
8509 i.mu.Lock()
8510 i.statusErr = "fork: no session is active"
8511 i.mu.Unlock()
8512 i.invalidate()
8513 return
8514 }
8515 if i.cfg.FlushSession != nil {
8516 i.cfg.FlushSession()
8517 }
8518 // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
8519 // the selected user message is included.
8520 upTo := msgIdx + 1
8521 newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
8522 if err != nil {
8523 i.mu.Lock()
8524 i.statusErr = "fork: " + err.Error()
8525 i.mu.Unlock()
8526 i.invalidate()
8527 return
8528 }
8529 if i.cfg.LoadSession == nil {
8530 i.mu.Lock()
8531 i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
8532 i.statusErr = ""
8533 i.mu.Unlock()
8534 i.invalidate()
8535 return
8536 }
8537 i.markSessionTitleSwitching()
8538 if err := i.cfg.LoadSession(newPath); err != nil {
8539 i.restoreFailedSessionTitle()
8540 i.mu.Lock()
8541 i.statusErr = "fork: switch failed: " + err.Error()
8542 i.mu.Unlock()
8543 i.invalidate()
8544 return
8545 }
8546 i.resetSessionTitleForFreshBranch()
8547 i.mu.Lock()
8548 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
8549 i.statusErr = ""
8550 i.mu.Unlock()
8551 i.invalidate()
8552}
8553
8554// formatInt is a tiny strconv.Itoa shim; keeps the handler above
8555// from needing a strconv import just for one call.
8556func formatInt(n int) string {
8557 return fmt.Sprintf("%d", n)
8558}
8559
8560// assistantText returns the concatenated text of every TextBlock in
8561// m. Used by the streaming-view dedupe guard to tell when a live
8562// streamed reply has already been promoted into the transcript.
8563func assistantText(m provider.Message) string {
8564 var sb strings.Builder
8565 for _, c := range m.Content {
8566 if tb, ok := c.(provider.TextBlock); ok {
8567 if sb.Len() > 0 {
8568 sb.WriteByte('\n')
8569 }
8570 sb.WriteString(tb.Text)
8571 }
8572 }
8573 return sb.String()
8574}
8575
8576// resetTranscriptRenderLocked invalidates every render cache that assumes
8577// the previous transcript remains structurally intact. Compaction is a
8578// replacement, not an append: the flow renderer must repaint from the new
8579// transcript rather than diff it against scrollback rows from the old one.
8580// Must be called with i.mu held.
8581func (i *Interactive) resetTranscriptRenderLocked() {
8582 i.view.InvalidateRenderCache()
8583 i.chatCacheValid = false
8584 i.prevChatLen = 0
8585 i.prevChatCols = 0
8586 i.prevChatRows = 0
8587 i.prevScrollOffset = 0
8588 if i.rend != nil {
8589 i.rend.Invalidate()
8590 }
8591}
8592
8593// resetStreamingStateLocked clears every piece of streaming state
8594// in one shot. Used by abort paths (turn cancel, compact hand-off,
8595// queue drain) so the pacer doesn't keep draining stale runes from
8596// a prior turn. Must be called with i.mu held.
8597func (i *Interactive) resetStreamingStateLocked() {
8598 i.streaming.Reset()
8599 i.streamPending = i.streamPending[:0]
8600 i.streamFlushPending = false
8601 i.streamOn = false
8602 i.openAllToolGatesLocked()
8603}
8604
8605// openAllToolGatesLocked drops every pending tool gate so that any
8606// tool registered during this turn renders unconditionally from now
8607// on. Called when streaming finalizes (the paced text has fully
8608// drained and `streaming` is about to reset to length 0): without
8609// this, the gate comparison against a freshly-reset streaming buffer
8610// would wrongly re-hide tools that had already cleared their gate.
8611// Must be called with i.mu held.
8612func (i *Interactive) openAllToolGatesLocked() {
8613 for id := range i.toolGate {
8614 i.toolGate[id] = 0
8615 }
8616}
8617
8618// gateToolLocked records the stream position at which a tool call may
8619// become visible. The gate is the total length the streaming buffer
8620// will reach once the pacer has drained everything currently queued
8621// (already painted + still pending). Holding the tool block back
8622// until the pacer crosses that mark guarantees the prose emitted
8623// before the tool call finishes typing out above it, instead of the
8624// tool block snapping in while the paragraph is still filling in.
8625//
8626// We only gate while text is actively streaming. If no stream is in
8627// flight (gate 0), the tool shows immediately, which is the correct
8628// behaviour for tool-only turns and replayed sessions. First
8629// registration wins so a later EvToolCall can't move an existing
8630// gate. Must be called with i.mu held.
8631func (i *Interactive) gateToolLocked(id string) {
8632 if _, ok := i.toolGate[id]; ok {
8633 return
8634 }
8635 if !i.streamOn {
8636 i.toolGate[id] = 0
8637 return
8638 }
8639 i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
8640}
8641
8642// toolGateOpenLocked reports whether the gated tool block may render
8643// yet, i.e. the pacer has drained enough text to reach the position
8644// recorded when the tool call arrived. Must be called with i.mu held.
8645func (i *Interactive) toolGateOpenLocked(id string) bool {
8646 gate, ok := i.toolGate[id]
8647 if !ok || gate == 0 {
8648 return true
8649 }
8650 return i.streaming.Len() >= gate
8651}
8652
8653// assistantMessageSideEffects runs the non-visual hooks attached to
8654// EvAssistantMessage: the host-provided OnAssistant callback and the
8655// telegram-bridge mirror. Called with i.mu held.
8656//
8657// Factored out of handleEvent because the streaming pacer may defer
8658// visual reset until after the last buffered rune has painted, but
8659// the callbacks themselves must fire on message arrival so
8660// downstream observers (session persistence, telegram, cost panels)
8661// don't wait on a UI animation to catch up.
8662func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
8663 if i.cfg.OnAssistant != nil {
8664 i.cfg.OnAssistant(m)
8665 }
8666 if i.telegramBridge != nil && i.telegramBridge.Active() {
8667 var sb strings.Builder
8668 for _, c := range m.Content {
8669 if tb, ok := c.(provider.TextBlock); ok {
8670 if sb.Len() > 0 {
8671 sb.WriteString("\n")
8672 }
8673 sb.WriteString(tb.Text)
8674 }
8675 }
8676 if text := sb.String(); strings.TrimSpace(text) != "" {
8677 go i.telegramBridge.OnAssistantText(text)
8678 }
8679 }
8680}
8681
8682// paintPaceRate is how many runes the streaming pacer releases per
8683// tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
8684// that a 500-rune summary finishes in ~1.3s, slow enough to look
8685// like a human typing. Empirically matches the feel of provider
8686// paths that already drip-stream natively.
8687const paintPaceRate = 6
8688
8689// paintPaceInterval is the tick interval for the streaming pacer.
8690// 16ms lines up with the redraw throttle so we never paint faster
8691// than the terminal can keep up.
8692const paintPaceInterval = 16 * time.Millisecond
8693
8694// runStreamPacer drains buffered deltas from streamPending into
8695// streaming a small batch per tick, invalidating after each move.
8696// It stops when the context cancels (tui shutdown).
8697//
8698// Why a pacer: providers differ wildly in how they chunk their
8699// text_delta events. The API-key path on Anthropic emits ~30 drips
8700// for a 400-token summary; the OAuth path can coalesce the same
8701// summary into 3 fat chunks, visually indistinguishable from "the
8702// whole reply just appeared". The pacer normalizes that so every
8703// path looks the same on screen.
8704func (i *Interactive) runStreamPacer(ctx context.Context) {
8705 t := time.NewTicker(paintPaceInterval)
8706 defer t.Stop()
8707 for {
8708 select {
8709 case <-ctx.Done():
8710 return
8711 case <-t.C:
8712 i.mu.Lock()
8713 if len(i.streamPending) == 0 {
8714 // EvAssistantMessage already fired but the pacer
8715 // was still draining a tick ago. Everything is now
8716 // painted; clear the streaming flags so the next
8717 // redraw shows the finalised transcript message
8718 // and hides the streaming overlay.
8719 if i.streamFlushPending {
8720 i.streamFlushPending = false
8721 i.streaming.Reset()
8722 i.streamOn = false
8723 i.openAllToolGatesLocked()
8724 i.mu.Unlock()
8725 i.invalidate()
8726 continue
8727 }
8728 i.mu.Unlock()
8729 continue
8730 }
8731 n := paintPaceRate
8732 if n > len(i.streamPending) {
8733 n = len(i.streamPending)
8734 }
8735 i.streaming.WriteString(string(i.streamPending[:n]))
8736 i.streamPending = i.streamPending[n:]
8737 i.mu.Unlock()
8738 i.invalidate()
8739 }
8740 }
8741}