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