返回 DeepSeek-Reasonix
chat_tui.go
根目录 / internal / cli / chat_tui.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "runtime"
12 "strconv"
13 "strings"
14 "time"
15 "unicode/utf8"
16
17 "charm.land/bubbles/v2/key"
18 "charm.land/bubbles/v2/spinner"
19 "charm.land/bubbles/v2/textarea"
20 "charm.land/bubbles/v2/viewport"
21 tea "charm.land/bubbletea/v2"
22 "charm.land/lipgloss/v2"
23 "github.com/charmbracelet/x/ansi"
24
25 "reasonix/internal/agent"
26 "reasonix/internal/billing"
27 "reasonix/internal/boot"
28 "reasonix/internal/command"
29 "reasonix/internal/config"
30 "reasonix/internal/control"
31 "reasonix/internal/event"
32 "reasonix/internal/hook"
33 "reasonix/internal/i18n"
34 "reasonix/internal/memory"
35 "reasonix/internal/migration"
36 "reasonix/internal/outputstyle"
37 "reasonix/internal/plugin"
38 "reasonix/internal/provider"
39 "reasonix/internal/recovery"
40 "reasonix/internal/sandbox"
41 "reasonix/internal/sessioninbox"
42 "reasonix/internal/skill"
43 "reasonix/internal/tool"
44 )
45
46 // chatTUI is a bubbletea Model that normally owns the terminal with an
47 // alt-screen transcript viewport. Termux is the exception: it stays in the
48 // normal buffer and commits finalized output to native scrollback via
49 // tea.Println so taps can still focus the soft keyboard.
50 type chatTUI struct {
51 turnSettingsIntent *controllerTurnIntent
52 ctrl control.SessionAPI
53 shutdownErr error // final save's failure; reported after terminal release
54 label string
55 missing string // missing-key warning surfaced once in the banner, "" when ready
56 webHandoffState
57 // diagnostics is the process-owned TUI log/watchdog started before terminal
58 // takeover. Nil in unit tests that construct chatTUI without chatREPL.
59 diagnostics *tuiDiagnostics
60 firstFrameLogged bool
61
62 width int
63 height int
64 // themeSweep freezes the frame while a /theme switch wipes across it.
65 themeSweep *themeSweep
66 // nativeScrollback keeps Termux out of alt-screen mode so taps still focus
67 // the textarea and raise the soft keyboard.
68 nativeScrollback bool
69 // mouseCaptureOff releases mouse ownership back to the terminal (View() sets
70 // tea.MouseModeNone instead of MouseModeCellMotion) so its native
71 // click-drag selection and right-click context menu work again. Toggled by
72 // "/mouse" or REASONIX_DISABLE_MOUSE at startup; trades away in-app
73 // drag-select, the transcript scrollbar, and wheel-scroll while it's on,
74 // since the terminal no longer forwards those events to Reasonix.
75 mouseCaptureOff bool
76
77 input textarea.Model
78 composerSel composerSelection
79 composerMap composerLayoutCache
80 // composerScrollOffset is an independent view offset used after the user
81 // wheels inside an overflowing composer. The textarea keeps ownership of the
82 // real insertion cursor; a subsequent edit or cursor key reattaches the view
83 // to that cursor without the wheel having moved it.
84 composerScrollOffset int
85 composerScrollDetached bool
86 spinner spinner.Model
87
88 submittedInputs []string
89 submittedInputCursor int
90 submittedInputDraft string
91 pastedBlocks []pastedBlock
92 nextPasteID int
93 usedPasteIDs map[int]struct{}
94
95 state tuiState
96 runStart time.Time
97 elapsed int
98 elapsedTickGeneration uint64
99 // Recovery state is cleared by progress or completion.
100 retryAttempt int
101 retryMax int
102 recovery *event.RecoveryStatus
103 // Host turn phase, cleared on TurnDone.
104 turnPhase string
105 readStatusState
106 // turnTokens accumulates this turn's output tokens (summed from per-step Usage
107 // events) for the live "↓N" readout in the running status line.
108 turnTokens int
109 // showTurnUsage controls whether completed per-request token/cost receipts are
110 // retained in transcript scrollback. Usage accounting remains active either way.
111 showTurnUsage bool
112 // sessionCostQuote is the incrementally aggregated canonical quote seen on
113 // Usage events. It powers the persistent footer without re-running pricing.
114 sessionCostQuote *billing.CostQuote
115
116 // balance is the last-fetched wallet-balance readout (e.g. "¥110.00"), "" when
117 // the provider declares no balance_url or a fetch failed. Refreshed async on
118 // startup and after each turn so the status line stays roughly current without
119 // blocking the event loop.
120 balance string
121
122 // todos is copied only from a successful semantic todo result. The separate
123 // dismissal bit is a mounted-view preference and never changes host state.
124 // Both reset at the host's real turn_started boundary.
125 todos []event.Todo
126 todosDismissed bool
127 searchSources []provider.ServerSearchHit // post-answer footnotes; cleared when the turn settles
128
129 // marker rides in outgoing user messages so the cache-stable prompt prefix is
130 // left untouched.
131 planMode bool
132 // yoloRestoreToolApprovalMode remembers the safe permission preset that
133 // Ctrl+Y should restore after toggling the canonical danger-full-access
134 // preset under the user-facing YOLO label.
135 yoloRestoreToolApprovalMode string
136 // legacyScrollClear keeps the per-offset ClearScreen workaround only for Warp.
137 legacyScrollClear bool
138 // sessionSwitch suppresses that workaround during a transcript rebuild (#5441).
139 sessionSwitch bool
140 // inboxSelectedID is the currently highlighted durable inbox item while
141 // browsing the queue in tuiRunning. Empty means "not browsing". Full bodies
142 // are never cached here — only the selected ID and the snapshot metadata.
143 inboxSelectedID string
144 // queueEditCursor tracks which queued message the user is currently
145 // browsing/editing via ↑/↓ during tuiRunning. -1 means "not browsing".
146 queueEditCursor int
147 // queueEditDraft saves the in-progress input text when the user first
148 // presses ↑ to browse the queue, so it can be restored when the cursor
149 // moves past the end.
150 queueEditDraft string
151 // queueConfirmDelete, when true, the next 'd' confirms deletion of the
152 // selected inbox item.
153 queueConfirmDelete bool
154
155 // history is a resumed session's messages, committed to scrollback once on
156 // the first WindowSizeMsg so a reopened chat shows its prior transcript.
157 history []provider.Message
158
159 // reasoning accumulates the in-progress thinking stream (dim); pending
160 // accumulates the in-progress answer (raw markdown). They are committed to
161 // scrollback (reasoning collapsed by default, answer markdown-rendered) when they
162 // finalize — at a tool/usage boundary or turn end — not previewed live, so
163 // the bottom region stays a stable height. pendingCommit queues finalized
164 // lines so a single Update emits exactly one ordered tea.Println.
165 reasoning *strings.Builder
166 pending *strings.Builder
167 pendingCommit *[]string
168 showReasoning bool // Ctrl+O / /verbose: show raw thinking text in the CLI
169 cfg *config.Config
170 // reasoningLineIdx is the transcript index of the live "▎ thinking…" marker
171 // while a reasoning block streams; it's rewritten to "▎ thought for Ns" when
172 // the block closes. -1 when no block is open. transcriptDirty forces a
173 // viewport re-feed after that in-place rewrite (length is unchanged).
174 reasoningLineIdx int
175 // reasoningTextIdx is the transcript index of the live reasoning text block
176 // (the block right after the marker), streamed in as the model thinks and
177 // removed when the block collapses (kept only in verbose mode). -1 when none.
178 reasoningTextIdx int
179 // reasoningView is a bounded trailing window (≤ reasoningViewMax bytes) of the
180 // streaming thought, rendered live; the full text stays in reasoning for verbose.
181 reasoningView []byte
182 // reasoningNative is the Termux/native-scrollback path: reasoning is buffered
183 // without a live transcript block, then appended once as a final summary.
184 reasoningNative bool
185 thinkStart time.Time
186 // answerIdx is the transcript index of the streaming answer block (rewritten in
187 // place as completed paragraphs arrive); -1 when none is open. answerFlushed is
188 // how many bytes of pending have already been rendered into it, so a Text packet
189 // that doesn't close a new paragraph re-renders nothing.
190 answerIdx int
191 answerFlushed int
192 // toolStreamIdx is the transcript index of a running tool's live-output block
193 // (streamed via ToolProgress under the tool card); -1 when none. toolStreamID
194 // is the call ID it belongs to. Only a bounded tail is kept — the last few
195 // complete lines (toolTail) plus the in-progress one (toolPartial) — so a
196 // high-output command can't balloon memory or cost O(n²) re-splitting;
197 // toolLineCount feeds the collapse summary.
198 toolStreamIdx int
199 toolStreamID string
200 toolTail []string
201 toolPartial string
202 toolLineCount int
203 // shellOutputs stores the full accumulated output of each shell command
204 // (tool IDs with "shell-" prefix), so the first 10 lines can be shown after
205 // collapse and Ctrl+B can toggle the complete output.
206 shellOutputs map[string]string
207 shellExpanded map[string]bool
208 // shellTranscriptIdx maps a shell tool ID to the transcript index of its
209 // collapsed output block, so Ctrl+B can rewrite it in place.
210 shellTranscriptIdx map[string]int
211 // toolLineCountByID keeps a switched-away tool's last line count so a late
212 // ToolResult can still render "⎿ N lines" (shellOutputs only tracks "shell-" ids).
213 toolLineCountByID map[string]int
214 // toolStreamStart / toolStreamFrame drive the "⎿ working · Ns" line shown
215 // under a dispatched tool that hasn't produced output yet, so a slow tool
216 // reads as making progress rather than frozen.
217 toolStreamStart time.Time
218 toolStreamFrame int
219 // Sub-agent progress previews (reserved ToolProgress channels) render per
220 // child into their own fixed transcript slot, keyed by the namespaced call
221 // ID — independent of the single live toolStreamID. subagentProgress keeps
222 // the bounded live state (phase, elapsed, recent activity, verbose tails).
223 subagentProgressIdx map[string]int
224 subagentProgress map[string]*cliSubagentProgress
225 transcriptDirty bool
226 // forceGotoBottom is set by replayActiveBranch and resetFreshContextView to
227 // pin the viewport to the bottom after a session / branch / clear switch
228 // regardless of the previous wasAtBottom state (#4584).
229 forceGotoBottom bool
230 // scrollMode is the explicit followTail / userScrolled state machine.
231 // Prefer this over a raw wasAtBottom snapshot so modal height changes
232 // (approval, chooser, pickers) never silently disable tail-follow (#6430).
233 scrollMode scrollFollowMode
234 eventCh chan event.Event
235 started bool // banner + resumed history committed once
236
237 // transcript holds every finalized line commitLine emits; the viewport
238 // renders a scrollable window of it (alt-screen owns the grid, so there's no
239 // native terminal scrollback). sel is the live left-drag text selection.
240 transcript []string
241 // transcriptSources runs parallel to transcript and retains raw, semantic
242 // content for blocks whose layout depends on terminal width. Fixed blocks
243 // keep their already-rendered text; markdown, user bubbles, reasoning, tool
244 // cards, and replay bundles are regenerated after a resize.
245 transcriptSources []transcriptSource
246 // wrappedLines is the viewport line cache; wrapBlockLines / wrapWidth /
247 // wrapBlockCount support append-only updates without re-wrapping the full
248 // history on every streaming commit (#6978).
249 wrappedLines []string
250 wrapBlockLines [][]string
251 wrapWidth int
252 wrapBlockCount int
253 // lastMouseReenable rate-limits ConPTY mouse re-enable sequences (#7583).
254 // mouseReenablePending + timer cover trailing-edge fires after a resize storm.
255 lastMouseReenable time.Time
256 mouseReenablePending bool
257 mouseReenableTimerArmed bool
258 // wantMouseReenable is set by TurnDone (and similar settle points) and
259 // consumed once in Update so the raw enable sequence is batched with the
260 // frame that paints the settled state.
261 wantMouseReenable bool
262 viewport viewport.Model
263 sel selection
264 // autoScroll drives edge-drag scrolling: -1 up, +1 down, 0 off. dragX is the
265 // column the drag is held at, so the ticker can extend the selection head.
266 autoScroll int
267 dragX int
268 // scrollbarDrag owns left-button drags that start on the transcript scrollbar
269 // column. It is separate from text selection so the visual thumb is not a
270 // dead target and dragging it never leaves a transcript selection behind.
271 scrollbarDrag bool
272 scrollbarGrabOffset int
273 // copyNoticeText is a transient "copied to clipboard" hint shown on the status
274 // line after a mouse-drag, right-click, or Ctrl+C selection copy; "" when none
275 // is showing. copyNoticeSeq guards its expiry tick so an older copy's timer
276 // can't clear a newer notice — each copy bumps the sequence and only a tick
277 // carrying the current sequence clears the text.
278 copyNoticeText string
279 copyNoticeSeq int
280 // clipboardImagePending keeps the footer honest while the platform clipboard
281 // is being decoded. clipboardImageRequests counts shortcuts coalesced into the
282 // probe: an image attaches once, while a text fallback preserves every press.
283 clipboardImagePending bool
284 clipboardImageRequests int
285
286 // terminalPasteSeq counts bracketed pastes delivered by the terminal.
287 // clipboardImageTerminalPasteSeq snapshots it when an image probe starts, so a
288 // terminal that pastes text itself is never pasted into twice.
289 terminalPasteSeq uint64
290 clipboardImageTerminalPasteSeq uint64
291
292 // The user bubble is echoed to scrollback immediately on Enter (bubbleStartIdx
293 // marks where in the transcript it landed). It stays "un-sendable" until the
294 // first response packet arrives: pressing Esc/Ctrl+C before then pops those
295 // lines back off the transcript and restores the text to the input box, leaving
296 // no trace. bubblePending is true from startTurn until the first packet confirms
297 // the send or it's un-sent; turnDiscarded then swallows the turn's
298 // already-buffered events until its TurnDone settles.
299 pendingRestore string
300 pendingPastes []string
301 bubbleStartIdx int
302 bubblePending bool
303 turnDiscarded bool
304
305 // pendingApproval holds the tool-call approval currently shown in the banner
306 // (nil when none). While set, the controller's run goroutine is blocked
307 // awaiting ctrl.Approve and key input is captured to answer it.
308 pendingApproval *event.Approval
309 approvalSelection int
310
311 // chooser holds the `ask` tool's question card (nil when none). While set, the
312 // run goroutine is blocked awaiting ctrl.AnswerQuestion and keys drive the card.
313 chooser *chooser
314 // elicit holds the pending MCP elicitation card (nil when none).
315 elicit *elicitCard
316
317 // rewind holds the Esc-Esc / "/rewind" picker (nil when closed); while set,
318 // keys drive it and it renders as an overlay. lastEsc times the double-Esc
319 // gesture that opens it on an empty composer.
320 rewind *rewindPicker
321 // resumePick is the interactive "/resume" session picker overlay. Non-nil
322 // while the user browses saved sessions with ↑/↓ and confirms with Enter.
323 resumePick *resumePicker
324 // reclaimState groups the flags a remote take-back sets and clears together.
325 reclaimState
326 // pendingTakeoverPath remembers the last /resume target refused because a
327 // resident serve on this machine holds its lease; "/takeover" force-takes
328 // that session back.
329 pendingTakeoverPath string
330 // quickPick owns searchable single-choice overlays such as /model and
331 // /provider. It never invokes a raw-mode prompt inside Bubble Tea.
332 quickPick *quickPicker
333 setup *connectionSetup
334 copyPick *copyPicker
335 lastEsc time.Time
336
337 // mcp is the interactive "/mcp" manager overlay. mcpDisabled tracks servers
338 // turned off only for this chat session, matching the desktop connector
339 // toggle's non-persistent semantics.
340 mcp *mcpManager
341 mcpDisabled map[string]bool
342
343 // clearConfirm is the destructive "/clear" confirmation overlay. It is separate
344 // from /new because /clear discards the current transcript instead of saving it.
345 clearConfirm *clearConfirm
346
347 // lastCtrlCAt records when Ctrl+C was pressed while idle on an empty
348 // composer, enabling a "press again to quit" confirmation pattern (1.5s
349 // window). Reset when Ctrl+C clears non-empty input instead.
350 lastCtrlCAt time.Time
351
352 // mcpImport holds the interactive cc-switch MCP import picker (nil when
353 // closed). It writes selected servers to config and hot-connects the ones that
354 // can start successfully.
355 mcpImport *mcpImportPicker
356
357 // host is the running MCP servers (nil when no plugins). The TUI reads
358 // prompts (slash commands), resources (@-references), and server status
359 // (/mcp) from it.
360 host *plugin.Host
361
362 // commands are custom slash commands loaded from .reasonix/commands; each renders
363 // its template with the typed args and sends the result as a turn.
364 commands []command.Command
365
366 // skills are the discoverable skills (built-in + user/project); each is offered
367 // in the slash menu as "/<name>" and managed via /skills.
368 skills []skill.Skill
369
370 // slashCache holds the immutable slash catalog and the arg-completion data
371 // snapshot, rebuilt only on explicit invalidation — never on keystrokes
372 // (#6417, #7090, #9503).
373 slashCache *slashCompletionCache
374
375 // skillPick is the interactive skill picker overlay for /skills. nil when closed.
376 skillPick *skillPicker
377
378 // buildController builds a fresh controller for a model choice, carrying
379 // prior history across and pinning auto-save to resumePath so the continued
380 // conversation stays in one file (set by chatREPL; it must NOT touch this
381 // model — the swap happens on the running copy). nil disables runtime
382 // rebuild commands. modelRef is the active "provider/model" ref, marked
383 // current in the picker. oldCtrl is the
384 // outgoing controller, passed through so the replacement can carry forward
385 // same-session tool grants and Plan-mode read-only command trust that
386 // don't travel through carry/resumePath (see Controller.RestoreSessionAuthorizations).
387 buildController func(spec controllerBuildSpec, carry []provider.Message, resumePath string, oldCtrl control.SessionAPI) (*control.Controller, error)
388 // rebuildRuntime builds the /reload replacement through boot.Rebuild:
389 // same model/profile/effort, but tools, skills, commands, hooks, MCP
390 // servers, and providers are discovered fresh and the session state
391 // migrates inside the boot layer. Set by chatREPL (it must NOT touch
392 // this model — the swap happens on the running copy); nil disables
393 // /reload.
394 rebuildRuntime runtimeRebuilder
395 lastBuildResult *boot.BuildResult
396 // pendingReload coalesces /reload requests made while a turn or a runtime
397 // switch is in flight; the TurnDone drain runs it once the TUI is idle.
398 pendingReload bool
399 modelRef string
400 effortLevel string // "" when the current provider/model has no configurable effort
401
402 // leases owns the session lease guarding the TUI's active session file (set
403 // by chatREPL; nil in tests and when persistence is disabled). Every in-TUI
404 // operation that rebinds the controller to another session file must move
405 // the lease first — see rebindSessionLease / followSessionLease.
406 leases *control.SessionLeaseKeeper
407 // takeover mirrors a session acquired from a resident Serve and blocks
408 // admission while that Serve is reclaiming it.
409 takeover *cliTakeoverManager
410
411 // outputStyle is the active output-style name (config agent.output_style),
412 // shown as the current entry in the /output-style listing. "" = default.
413 outputStyle string
414
415 // diffMaxLines controls the max lines shown in a diff view. 0 = show all;
416 // non-zero = fold at that many lines. Toggled by /diff-fold.
417 diffMaxLines int
418
419 // statuslineCmd is the user's custom status-line command (config
420 // [statusline].command); "" disables it. statuslineOut caches its latest
421 // one-line stdout, refreshed at startup and after each turn and rendered in
422 // place of the built-in data row.
423 statuslineCmd string
424 statuslineOut string
425 gitStatus gitStatus
426
427 // statusLineCount is the number of terminal rows the status block occupies
428 // (wrapped working line + wrapped status line + wrapped data line). Updated
429 // each frame via computeStatusLineCount so bottomRows can reserve the correct
430 // height; starts at 2 (unwrapped) until first render.
431 statusLineCount int
432
433 // modelSwitchPending is true while any async controller rebuild is in flight.
434 modelSwitchPending bool
435 // pendingModelSwitch holds the tea.Cmd that triggers the async build. The
436 // historical field name is retained because model, effort, skill refresh,
437 // and work-mode changes all share the same atomic swap path.
438 pendingModelSwitch tea.Cmd
439 // oldControllers accumulates controllers retired by runtime switches.
440 // They cannot be closed during the switch (Close runs SessionEnd hooks
441 // and kills plugin subprocesses, both of which corrupt the terminal's
442 // raw mode). Instead they are closed at process exit when the terminal
443 // is already being restored.
444 oldControllers []control.SessionAPI
445
446 // completion is the live autocomplete menu (slash commands; @-refs later).
447 completion completion
448 // fileSearchCache memoizes fileref.Search by query so the bounded walk runs
449 // once per @token fragment, not on every keystroke that re-renders the menu.
450 fileSearchCache map[string][]string
451 }
452
453 type tuiState int
454
455 const (
456 tuiIdle tuiState = iota
457 tuiRunning
458 )
459
460 type controllerBuildSpec struct {
461 ModelRef string
462 ToolApprovalMode string
463 PlanMode bool
464 EffortOverride *string
465 }
466
467 func (m *chatTUI) runtimeSwitchBusy() bool {
468 if m == nil || m.ctrl == nil {
469 return false
470 }
471 status := m.ctrl.RuntimeStatus()
472 return status.Running || status.PendingPrompt || status.BackgroundJobs > 0 || m.pendingApproval != nil || m.chooser != nil
473 }
474
475 // agentEventMsg is one typed event from the agent's run loop.
476 type agentEventMsg event.Event
477
478 // maxEventDrain caps how many buffered events one Update coalesces before
479 // yielding to render, so a sustained output flood still shows live progress.
480 const maxEventDrain = 512
481
482 const resetMouseTracking = ansi.ResetModeMouseX10 +
483 ansi.ResetModeMouseNormal +
484 ansi.ResetModeMouseHighlight +
485 ansi.ResetModeMouseButtonEvent +
486 ansi.ResetModeMouseAnyEvent +
487 ansi.ResetModeMouseExtSgr +
488 ansi.ResetModeMouseExtUtf8 +
489 ansi.ResetModeMouseExtUrxvt +
490 ansi.ResetModeMouseExtSgrPixel
491
492 // compactDoneMsg reports that an async /compact pass returned. The card was
493 // already drawn from the CompactionDone event; this only surfaces a failure and
494 // snapshots on success.
495 type compactDoneMsg struct{ err error }
496
497 // tuiShutdownMsg asks the live TUI model to persist its current controller and
498 // quit. It is injected from the signal handler so shutdown does not snapshot a
499 // stale controller captured before an in-TUI rebuild.
500 type tuiShutdownMsg struct {
501 completion *tuiShutdownCompletion
502 userInitiated bool
503 }
504
505 // shutdownNow is the tea.Cmd every in-TUI quit gesture returns instead of
506 // tea.Quit. Routing through tuiShutdownMsg gives all exits the same
507 // finalization (Snapshot + lease follow); quitting directly would drop
508 // whatever the controller holds beyond the last snapshot (#5879).
509 func shutdownNow() tea.Msg { return tuiShutdownMsg{userInitiated: true} }
510
511 // elapsedTickMsg fires once a second while a turn runs, driving the "thinking
512 // Ns" counter in the status line. generation rejects a prior turn's timer.
513 type elapsedTickMsg struct{ generation uint64 }
514
515 // balanceMsg carries the result of an async wallet-balance fetch; text is the
516 // formatted readout ("" when none/failed).
517 type balanceMsg struct{ text string }
518
519 // statuslineMsg carries the latest custom status-line output (one line, ""
520 // when none/failed).
521 type statuslineMsg struct{ out string }
522
523 // gitStatusMsg carries the latest lightweight git readout for the built-in
524 // status line. Empty means "not a git worktree" or "git unavailable".
525 type gitStatusMsg struct{ status gitStatus }
526
527 // runStatusline runs the user's custom status-line command off the event loop,
528 // feeding it a small JSON context on stdin and returning its first stdout line.
529 // A no-op (nil) when no command is configured. Tight timeout so a slow script
530 // can't stall the UI; failures collapse to an empty line rather than an error.
531 func (m chatTUI) runStatusline() tea.Cmd {
532 cmd := m.statuslineCmd
533 if cmd == "" {
534 return nil
535 }
536 used, window := m.ctrl.ContextSnapshot()
537 cwd, _ := os.Getwd()
538 payload, _ := json.Marshal(map[string]any{
539 "model": m.label,
540 "contextUsed": used,
541 "contextWindow": window,
542 "cwd": cwd,
543 })
544 return func() tea.Msg { return statuslineMsg{out: runStatuslineCmd(cmd, string(payload))} }
545 }
546
547 const statuslineCommandTimeout = 2 * time.Second
548
549 // runStatuslineCmd runs a status-line command with the JSON context on stdin and
550 // returns its first stdout line (status lines are a single row). A tight timeout
551 // keeps a slow script from stalling the UI; any failure collapses to "".
552 func runStatuslineCmd(cmd, stdinPayload string) string {
553 return runStatuslineCmdWithTimeout(cmd, stdinPayload, statuslineCommandTimeout)
554 }
555
556 func runStatuslineCmdWithTimeout(cmd, stdinPayload string, timeout time.Duration) string {
557 res := hook.DefaultSpawner(context.Background(), hook.SpawnInput{
558 Command: cmd,
559 Stdin: stdinPayload + "\n",
560 Timeout: timeout,
561 })
562 out := strings.TrimSpace(res.Stdout)
563 if i := strings.IndexByte(out, '\n'); i >= 0 {
564 out = strings.TrimSpace(out[:i])
565 }
566 return out
567 }
568
569 func (m chatTUI) refreshGitStatus() tea.Cmd {
570 if m.statuslineCmd != "" {
571 return nil
572 }
573 return fetchGitStatus()
574 }
575
576 // modelSwitchMsg carries the result of an async /model switch. A nil err means
577 // the new controller is ready in ctrl; label/commands/skills/host mirror the
578 // fields that runModelSubcommand used to set synchronously. oldCtrl is the
579 // previous controller that must be closed after the switch — its cleanup
580 // (SessionEnd hooks, plugin subprocess kill) is deferred to a tea.Cmd so it
581 // runs after the render completes, avoiding corruption of the terminal's raw
582 // mode that would occur if Close() were called from the build goroutine.
583 type modelSwitchMsg struct {
584 resumeTurn *controllerTurnIntent
585 ref string
586 ctrl control.SessionAPI
587 oldCtrl control.SessionAPI
588 label string
589 commands []command.Command
590 skills []skill.Skill
591 host *plugin.Host
592 failurePrefix string
593 successNotice string
594 err error
595 }
596
597 // fetchBalance queries the provider's wallet balance off the event loop. It's a
598 // no-op readout ("") when the provider declares no balance_url or the fetch
599 // fails, so the status line stays quiet rather than surfacing an error.
600 // Wallets are displayed in their original currencies; no conversion or sum is
601 // attempted when more than one currency is returned.
602 func fetchBalance(ctrl control.Status) tea.Cmd {
603 return func() tea.Msg {
604 b, err := ctrl.Balance(context.Background())
605 if err != nil || b == nil {
606 return balanceMsg{}
607 }
608 displayCurrency := ""
609 if cfg, err := config.LoadForRootReadOnly("."); err == nil && cfg != nil {
610 displayCurrency = cfg.ExplicitDisplayCurrency()
611 }
612 return balanceMsg{text: b.DisplayForCurrency(displayCurrency)}
613 }
614 }
615
616 // promptResolvedMsg carries the result of fetching an MCP prompt (an async
617 // prompts/get). display is the command line echoed as the user bubble; sent is
618 // the rendered prompt text that becomes the model turn.
619 type promptResolvedMsg struct {
620 display string
621 sent string
622 err error
623 }
624
625 // extensionActionMsg carries the result of invoking one extension UI action
626 // (an async extension/ui/action round-trip to the sidecar). The extension's
627 // (already redacted) message surfaces as a transcript notice.
628 type extensionActionMsg struct {
629 message string
630 err error
631 }
632
633 // refsResolvedMsg carries the result of resolving the @references in a
634 // submitted line (async file reads / MCP resources/read).
635 type refsResolvedMsg struct {
636 sent string
637 display string
638 restore string
639 block string
640 errs []string
641 }
642
643 type clipboardImageMsg struct {
644 path string
645 err error
646 }
647
648 // newChatTUI assembles the initial model. The controller has already been wired
649 // with an event sink that feeds eventCh; the TUI issues commands to it and
650 // renders the events it emits. Model identity, label, history, host, and commands
651 // are read from the controller, so explicit selections and resumed sessions stay
652 // authoritative.
653 func newChatTUI(ctrl control.SessionAPI, missing string, eventCh chan event.Event, termW int) chatTUI {
654 ti := textarea.New()
655 configureChatTextarea(&ti)
656
657 sp := spinner.New()
658 sp.Spinner = spinner.Dot
659 sp.Style = themeStyle(activeCLITheme.accent)
660
661 commitBuf := []string{}
662 nativeScrollback := detectTermuxTerminal()
663 history := chatUIDisplayHistory(ctrl)
664 nextPasteID, usedPasteIDs := pasteIDStateForHistory(history)
665 return chatTUI{
666 ctrl: ctrl,
667 label: ctrl.Label(),
668 modelRef: ctrl.ModelRef(),
669 missing: missing,
670 nativeScrollback: nativeScrollback,
671 legacyScrollClear: useLegacyViewportScrollClear(runtime.GOOS, os.Environ()),
672 mouseCaptureOff: mouseCaptureOffByDefault(),
673 input: ti,
674 spinner: sp,
675 submittedInputCursor: -1,
676 queueEditCursor: -1,
677 nextPasteID: nextPasteID,
678 usedPasteIDs: usedPasteIDs,
679 reasoningLineIdx: -1,
680 reasoningTextIdx: -1,
681 answerIdx: -1,
682 toolStreamIdx: -1,
683 reasoning: &strings.Builder{},
684 pending: &strings.Builder{},
685 pendingCommit: &commitBuf,
686 diffMaxLines: diffFoldLimit,
687 showReasoning: nativeScrollback,
688 showTurnUsage: true,
689 shellOutputs: make(map[string]string),
690 shellExpanded: make(map[string]bool),
691 shellTranscriptIdx: make(map[string]int),
692 toolLineCountByID: make(map[string]int),
693 subagentProgressIdx: make(map[string]int),
694 subagentProgress: make(map[string]*cliSubagentProgress),
695 eventCh: eventCh,
696 history: history,
697 host: ctrl.Host(),
698 commands: ctrl.Commands(),
699 skills: ctrl.SlashSkills(),
700 viewport: viewport.New(viewport.WithWidth(termW)),
701 statusLineCount: 3,
702 }
703 }
704
705 func transcriptContentWidth(termW int, nativeScrollback bool) int {
706 if !nativeScrollback {
707 termW-- // reserve the last column for the transcript scrollbar
708 }
709 return max(termW, 1)
710 }
711
712 func configureChatTextarea(ti *textarea.Model) {
713 // Keep a stable two-cell input affordance, matching the prompt treatment in
714 // other coding TUIs. Continuation rows receive two spaces so text and the
715 // real terminal cursor stay aligned without repeating the arrow.
716 ti.SetPromptFunc(composerPromptWidth, func(info textarea.PromptInfo) string {
717 if info.LineNumber != 0 {
718 return ""
719 }
720 if info.Focused {
721 return accent("❯ ")
722 }
723 return dim("❯ ")
724 })
725 ti.CharLimit = 16384
726 // The prompt and real terminal cursor already show where typing starts. Keep
727 // the idle composer quiet; modal free-text questions set their own temporary
728 // placeholder through refreshInputPlaceholder.
729 ti.Placeholder = ""
730 ti.DynamicHeight = true
731 ti.MinHeight = 1
732 ti.MaxHeight = maxInputRows
733 ti.MaxContentHeight = ti.CharLimit
734 ti.SetHeight(1)
735 ti.ShowLineNumbers = false
736 applyTextareaTheme(ti)
737 // Use the real terminal cursor (not a styled virtual one) so View can place
738 // it at the insertion point and IME candidate windows anchor to the input.
739 ti.SetVirtualCursor(false)
740 // Plain Enter submits (the chatTUI handler intercepts it), so the textarea's
741 // own InsertNewline binding moves to Alt+Enter / Ctrl+J / Shift+Enter.
742 ti.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter", "ctrl+j", "shift+enter"))
743 // bubbles binds word motion to Alt+arrows (the macOS convention); Windows and
744 // Linux terminals send Ctrl+arrows for the same intent.
745 ti.KeyMap.WordForward = key.NewBinding(key.WithKeys("alt+right", "alt+f", "ctrl+right"))
746 ti.KeyMap.WordBackward = key.NewBinding(key.WithKeys("alt+left", "alt+b", "ctrl+left"))
747 // Linux terminals send Ctrl+Backspace to delete the word behind the cursor.
748 ti.KeyMap.DeleteWordBackward = key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w", "ctrl+backspace"))
749 ti.Focus()
750 }
751
752 func (m *chatTUI) refreshInputPlaceholder() {
753 if m.chooserTyping() {
754 m.input.Placeholder = i18n.M.AskTypeSomething
755 return
756 }
757 m.input.Placeholder = ""
758 }
759
760 func isTermuxTerminal() bool {
761 if os.Getenv("TERMUX_VERSION") != "" || os.Getenv("TERMUX_APP_PID") != "" || os.Getenv("TERMUX__PREFIX") != "" {
762 return true
763 }
764 return strings.Contains(os.Getenv("PREFIX"), "/com.termux/")
765 }
766
767 var detectTermuxTerminal = isTermuxTerminal
768
769 func (m *chatTUI) rememberSubmittedInput(input string) {
770 if strings.TrimSpace(input) == "" {
771 return
772 }
773 if len(m.submittedInputs) == 0 || m.submittedInputs[len(m.submittedInputs)-1] != input {
774 m.submittedInputs = append(m.submittedInputs, input)
775 }
776 m.submittedInputCursor = -1
777 m.submittedInputDraft = ""
778 }
779
780 func (m *chatTUI) recallSubmittedInput(delta int) bool {
781 if len(m.submittedInputs) == 0 {
782 return false
783 }
784 cursor := m.submittedInputCursor
785 if cursor < 0 {
786 if delta > 0 {
787 return false
788 }
789 if m.input.Line() != 0 {
790 return false // first-line Up enters history; lower lines navigate the draft
791 }
792 m.submittedInputDraft = m.input.Value()
793 cursor = len(m.submittedInputs) - 1
794 } else {
795 cursor += delta
796 }
797
798 if cursor < 0 {
799 cursor = 0
800 }
801 if cursor >= len(m.submittedInputs) {
802 m.submittedInputCursor = -1
803 m.input.SetValue(m.submittedInputDraft)
804 m.growInputToFit()
805 return true
806 }
807 m.submittedInputCursor = cursor
808 m.input.SetValue(m.submittedInputs[cursor])
809 m.growInputToFit()
810 return true
811 }
812
813 func (m *chatTUI) resetSubmittedInputRecall() {
814 m.submittedInputCursor = -1
815 m.submittedInputDraft = ""
816 }
817
818 // navigateQueue moves through the durable inbox during tuiRunning.
819 // delta < 0 means ↑ (older), delta > 0 means ↓ (newer). Returns true if the
820 // input was updated. Bodies are loaded by ID only for the selected row.
821 func (m *chatTUI) navigateQueue(delta int) bool {
822 items := m.inboxPreviews()
823 if len(items) == 0 {
824 return false
825 }
826 cursor := m.queueEditCursor
827 if cursor < 0 {
828 if delta > 0 {
829 return false // already at "new draft" — nothing newer
830 }
831 // First ↑: save the current draft and jump to the last queued item.
832 m.queueEditDraft = m.input.Value()
833 cursor = len(items) - 1
834 } else {
835 cursor += delta
836 }
837
838 if cursor < 0 {
839 cursor = 0
840 }
841 if cursor >= len(items) {
842 // Past the end: restore the draft the user was composing.
843 m.queueEditCursor = -1
844 m.inboxSelectedID = ""
845 m.input.SetValue(m.queueEditDraft)
846 m.growInputToFit()
847 return true
848 }
849 m.queueEditCursor = cursor
850 m.inboxSelectedID = items[cursor].ID
851 // Load body only for the selected item (edit path).
852 if _, env, err := m.ctrl.ReadInboxItem(items[cursor].ID); err == nil {
853 m.input.SetValue(env.SubmitText)
854 } else {
855 m.input.SetValue(items[cursor].Preview)
856 }
857 m.growInputToFit()
858 return true
859 }
860
861 // resetQueueNavigation resets the queue browsing cursor so the user returns to
862 // normal input mode. Any in-progress edit is discarded (the queued item keeps
863 // its previous value).
864 func (m *chatTUI) resetQueueNavigation() {
865 m.queueEditCursor = -1
866 m.queueEditDraft = ""
867 m.inboxSelectedID = ""
868 m.queueConfirmDelete = false
869 }
870
871 // renderQueueIndicator renders up to three bounded inbox previews above the
872 // input when instructions are queued. Full bodies are never materialised here.
873 func (m chatTUI) renderQueueIndicator() string {
874 items := m.inboxPreviews()
875 if len(items) == 0 {
876 return ""
877 }
878 queueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // dim grey
879 highlightStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
880 var lines []string
881 // Ordinary status: at most three rows; full list via /queue.
882 limit := min(len(items), 3)
883 for i := range limit {
884 it := items[i]
885 preview := it.Preview
886 if preview == "" {
887 preview = "(empty)"
888 }
889 // Already bounded by sessioninbox.PreviewText; cap display further.
890 if r := []rune(preview); len(r) > 50 {
891 preview = string(r[:47]) + "…"
892 }
893 cursor := " "
894 style := queueStyle
895 if m.queueEditCursor == i || m.inboxSelectedID == it.ID {
896 cursor = "▸"
897 style = highlightStyle
898 }
899 mark := ""
900 switch it.State {
901 case sessioninbox.StateUncertain:
902 mark = " ?"
903 case sessioninbox.StateBlocked:
904 mark = " !"
905 case sessioninbox.StateRunning, sessioninbox.StateSteerAccepted, sessioninbox.StateSteerConsumed:
906 mark = " …"
907 }
908 lines = append(lines, style.Render(fmt.Sprintf(" %s [%d]%s %s", cursor, it.Pos, mark, preview)))
909 }
910 if more := len(items) - limit; more > 0 {
911 lines = append(lines, queueStyle.Render(fmt.Sprintf(" … +%d more (/queue list)", more)))
912 }
913 if m.inboxSnap().Paused {
914 lines = append(lines, queueStyle.Render(" ⏸ inbox paused (space to resume)"))
915 }
916 return strings.Join(lines, "\n")
917 }
918
919 // prompts returns the MCP prompts discovered at startup (nil when no plugins).
920 func (m *chatTUI) prompts() []plugin.Prompt {
921 if m.host == nil {
922 return nil
923 }
924 return m.host.Prompts()
925 }
926
927 func (m chatTUI) Init() tea.Cmd {
928 return tea.Batch(
929 textarea.Blink, forceSyncOutputCmd(),
930 waitForAgentEvent(m.eventCh), fetchBalance(m.ctrl),
931 m.runStatusline(), // nil (no-op) unless a custom status line is configured
932 m.refreshGitStatus(),
933 )
934 }
935
936 func suspendWithMouseReset() tea.Cmd {
937 return tea.Sequence(tea.Raw(resetMouseTracking), tea.Suspend)
938 }
939
940 func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
941 // Confirm booting → idle on the first Update. User input (keys/mouse/focus)
942 // must NOT refresh the active-turn heartbeat; only elapsedTick and work
943 // events do, so interaction cannot mask a stuck event loop (#7809).
944 if m.diagnostics != nil {
945 m.diagnostics.NoteBooted()
946 }
947 logFirstFrame := false
948 if m.diagnostics != nil && !m.firstFrameLogged {
949 if _, ok := msg.(tea.WindowSizeMsg); ok {
950 logFirstFrame = true
951 }
952 }
953 // Prefer explicit scrollMode over a raw AtBottom snapshot so opening an
954 // approval/chooser (height-only change) does not disable tail-follow (#6430).
955 followTail := m.shouldFollowTail()
956 prevLines := len(m.transcript)
957 prevWidth := m.width
958 prevHeight := m.height
959 prevYOff := m.viewport.YOffset()
960 var resizeAnchor transcriptResizeAnchor
961 if size, ok := msg.(tea.WindowSizeMsg); ok && size.Width != m.width && !followTail {
962 resizeAnchor = captureTranscriptResizeAnchor(m.transcript, m.viewport.Width(), prevYOff)
963 }
964
965 next, cmd := m.update(msg)
966 cm := next.(chatTUI)
967 if logFirstFrame {
968 cm.firstFrameLogged = true
969 if cm.diagnostics != nil {
970 cm.diagnostics.Milestone("first_frame")
971 }
972 }
973
974 contentW := transcriptContentWidth(cm.width, cm.nativeScrollback)
975 cm.viewport.SetWidth(contentW)
976 // Recompute the wrapped status-line count so bottomRows reserves the right
977 // height for the viewport. Use cm.width (same as boxW in View()) so the
978 // wrapping width matches what View() actually renders.
979 cm.statusLineCount = cm.computeStatusLineCount(cm.width)
980 // Keep the composer proportional to the live terminal instead of letting its
981 // absolute row cap crowd the transcript and fixed status rows on short
982 // windows. Textarea remains the owner of the scroll offset and caret reveal.
983 cm.syncInputHeightLimit()
984 cm.viewport.SetHeight(cm.transcriptHeight())
985 widthChanged := cm.width != prevWidth
986 if widthChanged {
987 cm.reflowTranscript(cm.width)
988 // Selection coordinates are visual-line based and cannot survive a
989 // semantic reflow without selecting unrelated text.
990 cm.sel = selection{}
991 }
992 // Wrap sync: full rebuild only on width change or history shrink. Streaming
993 // answer/tool rewrites use invalidateWrapFrom → suffix-only re-wrap; the
994 // transcriptDirty flag alone must never force a full-history rebuild (#6978).
995 forceFullWrap := widthChanged || len(cm.transcript) < prevLines
996 wrapBehind := cm.wrapWidth != contentW || cm.wrapBlockCount != len(cm.transcript)
997 if forceFullWrap || wrapBehind || len(cm.transcript) != prevLines {
998 if cm.syncWrappedLines(contentW, forceFullWrap) {
999 cm.feedViewportContent()
1000 }
1001 if followTail || cm.shouldFollowTail() {
1002 cm.viewport.GotoBottom() // tail-follow: stay pinned to newest output
1003 cm.markFollowTail()
1004 } else if widthChanged && resizeAnchor.valid {
1005 cm.viewport.SetYOffset(resizeAnchor.yOffset(cm.transcript, contentW))
1006 }
1007 } else if followTail && (cm.forceGotoBottom || cm.height != prevHeight) {
1008 // Height-only change (modal open/close, status wrap) must still pin
1009 // when we are in followTail — without waiting for new transcript.
1010 cm.viewport.GotoBottom()
1011 }
1012 if cm.forceGotoBottom {
1013 cm.viewport.GotoBottom()
1014 cm.markFollowTail()
1015 cm.forceGotoBottom = false
1016 }
1017 cm.transcriptDirty = false
1018
1019 // Rate-limited mouse re-enable after real resize, focus regain, or turn
1020 // settle so Windows ConPTY keeps wheel → MouseWheelMsg (#7583). Trailing
1021 // timer msgs are handled here too. Same-size WindowSizeMsg (session-switch
1022 // rebuilds) must not force a spurious Raw cmd.
1023 var mouseCmd tea.Cmd
1024 switch v := msg.(type) {
1025 case tea.WindowSizeMsg:
1026 if cm.width != prevWidth || cm.height != prevHeight {
1027 mouseCmd = cm.maybeReenableMouse()
1028 }
1029 case tea.FocusMsg:
1030 mouseCmd = cm.maybeReenableMouse()
1031 case mouseReenableMsg:
1032 mouseCmd = cm.handleMouseReenableMsg(v)
1033 }
1034 if cm.wantMouseReenable {
1035 cm.wantMouseReenable = false
1036 if c := cm.maybeReenableMouse(); c != nil {
1037 mouseCmd = batchCmds(mouseCmd, c)
1038 }
1039 }
1040
1041 // Keep the legacy full redraw only where Warp's scroll optimization can
1042 // strand stale rows. Every other terminal relies on Bubble Tea's renderer.
1043 if cm.legacyScrollClear && cm.viewport.YOffset() != prevYOff && !cm.nativeScrollback && !cm.sessionSwitch {
1044 cm.sessionSwitch = false
1045 return cm, batchCmds(tea.ClearScreen, mouseCmd, cmd)
1046 }
1047 cm.sessionSwitch = false
1048 return cm, batchCmds(mouseCmd, cmd)
1049 }
1050
1051 // batchCmds is tea.Batch that collapses an all-nil list to nil so callers can
1052 // assert "no work" without false positives from Batch(nil, nil).
1053 func batchCmds(cmds ...tea.Cmd) tea.Cmd {
1054 var out []tea.Cmd
1055 for _, c := range cmds {
1056 if c != nil {
1057 out = append(out, c)
1058 }
1059 }
1060 switch len(out) {
1061 case 0:
1062 return nil
1063 case 1:
1064 return out[0]
1065 default:
1066 return tea.Batch(out...)
1067 }
1068 }
1069
1070 // update runs the model's message handling. Update wraps it to keep the
1071 // transcript viewport sized, fed, and tail-following after every message.
1072 func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
1073 var cmds []tea.Cmd
1074 var inputBeforeSelection string
1075
1076 switch msg := msg.(type) {
1077 case tea.WindowSizeMsg:
1078 m.followComposerCursor()
1079 m.width = msg.Width
1080 m.height = msg.Height
1081 m.input.SetWidth(max(msg.Width-4, 1))
1082 // Commit the banner — and a resumed session's transcript — once, now
1083 // that the width is known.
1084 if !m.started {
1085 m.started = true
1086 history := append([]provider.Message(nil), m.history...)
1087 m.commitTranscriptSource(transcriptSource{
1088 kind: transcriptSourceReplayBundle, raw: m.missing, history: history,
1089 })
1090 m.history = nil
1091 }
1092
1093 case tea.FocusMsg:
1094 // Terminal regained focus — ConPTY may have dropped mouse tracking
1095 // while the pane was unfocused (#7583). Re-enable is issued from Update.
1096 return m, nil
1097
1098 case tea.MouseWheelMsg:
1099 if m.mouseOverComposer(msg.X, msg.Y) {
1100 delta := 0
1101 switch msg.Button {
1102 case tea.MouseWheelUp:
1103 delta = -composerWheelRows
1104 case tea.MouseWheelDown:
1105 delta = composerWheelRows
1106 }
1107 if delta != 0 && m.scrollComposer(delta) {
1108 return m, nil
1109 }
1110 }
1111 // Outside the composer, or once its internal viewport has reached the
1112 // requested edge, continue the gesture in the transcript. This mirrors
1113 // ordinary nested-scroll behavior and avoids a dead wheel at boundaries.
1114 switch msg.Button {
1115 case tea.MouseWheelUp:
1116 m.viewport.ScrollUp(3)
1117 case tea.MouseWheelDown:
1118 m.viewport.ScrollDown(3)
1119 }
1120 m.syncScrollModeAfterGesture()
1121 return m, nil
1122
1123 case tea.MouseClickMsg:
1124 // Match the complete terminal right-click convention while Reasonix owns
1125 // the mouse: copy an active selection, otherwise paste clipboard text into
1126 // the visible composer. Left-press begins a selection unless it lands on
1127 // the transcript scrollbar or a shell-output hint line.
1128 // Middle-click pastes tmux's current buffer when tmux owns the pane;
1129 // otherwise it follows the X11/Wayland PRIMARY-selection convention.
1130 if msg.Button == tea.MouseMiddle {
1131 if m.hideComposer() {
1132 return m, nil
1133 }
1134 cmds = append(cmds, pasteMiddleClick())
1135 return m, finalize(m, cmds)
1136 }
1137 if msg.Button == tea.MouseRight && m.validComposerSelection() && !m.composerSel.empty() {
1138 cmds = append(cmds, m.copySelectionWithNotice(m.selectedComposerText()))
1139 return m, finalize(m, cmds)
1140 }
1141 if msg.Button == tea.MouseRight && m.sel.active && !m.sel.empty() {
1142 text := m.selectedText()
1143 m.sel = selection{}
1144 cmds = append(cmds, m.copySelectionWithNotice(text))
1145 return m, finalize(m, cmds)
1146 }
1147 if msg.Button == tea.MouseRight && !m.hideComposer() {
1148 cmds = append(cmds, pasteClipboardText())
1149 return m, finalize(m, cmds)
1150 }
1151 if msg.Button == tea.MouseLeft {
1152 if at, ok := m.composerCaretAt(msg.X, msg.Y, false); ok {
1153 m.sel = selection{}
1154 m.autoScroll = 0
1155 m.setComposerCursor(at.offset)
1156 m.composerSel = composerSelection{
1157 active: true, anchor: at.offset, head: at.offset, value: m.input.Value(),
1158 }
1159 return m, nil
1160 }
1161 m.composerSel = composerSelection{}
1162 }
1163 if msg.Button == tea.MouseLeft && m.inScrollbar(msg.X, msg.Y) {
1164 m.sel = selection{}
1165 m.autoScroll = 0
1166 m.scrollbarDrag = true
1167 m.scrollbarGrabOffset = m.scrollbarGrabRowOffset(msg.Y)
1168 m.dragScrollbar(msg.Y)
1169 return m, nil
1170 }
1171 if msg.Button == tea.MouseLeft && msg.Y < m.viewport.Height() {
1172 // Check if the clicked line is a shell-output hint.
1173 lineIdx := m.viewport.YOffset() + msg.Y
1174 if lineIdx >= 0 && lineIdx < len(m.wrappedLines) {
1175 clicked := m.wrappedLines[lineIdx]
1176 if strings.Contains(clicked, "more lines") && strings.Contains(clicked, "Ctrl+B") {
1177 m.toggleShellOutput()
1178 return m, finalize(m, cmds)
1179 }
1180 }
1181 at := m.transcriptCaret(msg.X, msg.Y)
1182 m.sel = selection{active: true, anchor: at, head: at}
1183 m.autoScroll = 0
1184 }
1185 return m, nil
1186
1187 case tea.MouseMotionMsg:
1188 if m.validComposerSelection() {
1189 if at, ok := m.composerCaretAt(msg.X, msg.Y, true); ok {
1190 m.composerSel.head = at.offset
1191 }
1192 return m, nil
1193 }
1194 if m.scrollbarDrag {
1195 m.dragScrollbar(msg.Y)
1196 return m, nil
1197 }
1198 // Drag extends the live selection (CellMotion only reports motion while
1199 // a button is held, so this is a drag). A drag held against the top or
1200 // bottom edge starts an auto-scroll ticker so the selection can run past
1201 // the visible window.
1202 if m.sel.active {
1203 m.sel.head = m.transcriptCaret(msg.X, msg.Y)
1204 m.dragX = msg.X
1205 prev := m.autoScroll
1206 m.autoScroll = edgeScrollDir(msg.Y, m.viewport.Height())
1207 if m.autoScroll != 0 && prev == 0 {
1208 return m, autoScrollTick()
1209 }
1210 }
1211 return m, nil
1212
1213 case autoScrollMsg:
1214 // One edge-scroll step: scroll a single line, drag the selection head to
1215 // the edge row, and keep ticking until the drag ends, leaves the edge, or
1216 // the viewport can't scroll further (so it can't run away to the end).
1217 if !m.sel.active || m.autoScroll == 0 {
1218 return m, nil
1219 }
1220 edgeY := 0
1221 if m.autoScroll > 0 {
1222 m.viewport.ScrollDown(1)
1223 edgeY = m.viewport.Height() - 1
1224 } else {
1225 m.viewport.ScrollUp(1)
1226 }
1227 m.syncScrollModeAfterGesture()
1228 m.sel.head = m.transcriptCaret(m.dragX, edgeY)
1229 // Stop at the boundary so a held edge can't run away to the very end.
1230 if (m.autoScroll > 0 && m.viewport.AtBottom()) || (m.autoScroll < 0 && m.viewport.AtTop()) {
1231 m.autoScroll = 0
1232 return m, nil
1233 }
1234 return m, autoScrollTick()
1235
1236 case tea.MouseReleaseMsg:
1237 if msg.Button == tea.MouseLeft && m.validComposerSelection() {
1238 if at, ok := m.composerCaretAt(msg.X, msg.Y, true); ok {
1239 m.composerSel.head = at.offset
1240 m.setComposerCursor(at.offset)
1241 }
1242 if m.composerSel.empty() {
1243 m.composerSel = composerSelection{}
1244 return m, nil
1245 }
1246 // The terminal cannot see Reasonix's application-owned highlight, and
1247 // macOS commonly consumes Cmd+C before it reaches the TUI. Copy on drag
1248 // release just like transcript selection so the visible selection always
1249 // has a usable clipboard result.
1250 cmds = append(cmds, m.copySelectionWithNotice(m.selectedComposerText()))
1251 return m, finalize(m, cmds)
1252 }
1253 // Release finalizes the selection: a real drag auto-copies it (native
1254 // terminal convention), while the highlight stays on as the visual
1255 // "what's selected" cue and a right-click can still re-copy it. A plain
1256 // click (no drag) clears any prior selection.
1257 if m.scrollbarDrag {
1258 m.dragScrollbar(msg.Y) // already syncs scrollMode
1259 m.scrollbarDrag = false
1260 m.scrollbarGrabOffset = 0
1261 return m, nil
1262 }
1263 m.autoScroll = 0 // stop edge auto-scroll
1264 if msg.Button == tea.MouseLeft && m.sel.active {
1265 if m.sel.empty() {
1266 m.sel = selection{}
1267 } else {
1268 cmds = append(cmds, m.copySelectionWithNotice(m.selectedText()))
1269 }
1270 }
1271 return m, finalize(m, cmds)
1272
1273 case tea.PasteMsg:
1274 return m.applyComposerPaste(msg, true)
1275
1276 case tea.KeyPressMsg:
1277 // Any keystroke dismisses a finished selection (copy is a right-click),
1278 // with a few exceptions: Ctrl/Super/Meta+C and Ctrl+Insert copy the
1279 // selection, the paste shortcuts keep it so the async clipboard result
1280 // can replace it, and Left/Right collapse it to its ordered start/end.
1281 sel := m.sel
1282 m.sel = selection{}
1283 if m.validComposerSelection() && !m.composerSel.empty() {
1284 switch {
1285 case msg.String() == "ctrl+c" || msg.String() == "super+c" || msg.String() == "meta+c" || msg.String() == "ctrl+insert":
1286 cmds = append(cmds, m.copySelectionWithNotice(m.selectedComposerText()))
1287 return m, finalize(m, cmds)
1288 case imagePasteShortcut(msg.String(), runtime.GOOS):
1289 // The asynchronous image result replaces the still-active
1290 // selection. Terminal text paste arrives separately as PasteMsg.
1291 case msg.String() == "left":
1292 start, _ := m.composerSel.ordered()
1293 m.composerSel = composerSelection{}
1294 m.setComposerCursor(start)
1295 return m, finalize(m, cmds)
1296 case msg.String() == "right":
1297 _, end := m.composerSel.ordered()
1298 m.composerSel = composerSelection{}
1299 m.setComposerCursor(end)
1300 return m, finalize(m, cmds)
1301 default:
1302 inputBeforeSelection = m.input.Value()
1303 if composerSelectionDeletes(msg, m.input.KeyMap) {
1304 m.deleteComposerSelection()
1305 m.growInputToFit()
1306 m.updateCompletion()
1307 if shouldClearWideInputChange(inputBeforeSelection, m.input.Value()) {
1308 cmds = append(cmds, tea.ClearScreen)
1309 }
1310 return m, finalize(m, cmds)
1311 }
1312 if composerSelectionReplaces(msg, m.input.KeyMap) {
1313 m.deleteComposerSelection()
1314 } else {
1315 m.composerSel = composerSelection{}
1316 }
1317 }
1318 }
1319 // Transcript scroll keys work in any state (PgUp/PgDn are never text).
1320 switch msg.String() {
1321 case "pgup":
1322 m.viewport.PageUp()
1323 m.syncScrollModeAfterGesture()
1324 return m, finalize(m, cmds)
1325 case "pgdown":
1326 m.viewport.PageDown()
1327 m.syncScrollModeAfterGesture()
1328 return m, finalize(m, cmds)
1329 case "ctrl+home":
1330 m.viewport.GotoTop()
1331 m.markUserScrolled()
1332 return m, finalize(m, cmds)
1333 case "ctrl+end":
1334 m.viewport.GotoBottom()
1335 m.markFollowTail()
1336 return m, finalize(m, cmds)
1337 case "ctrl+z":
1338 return m, suspendWithMouseReset()
1339 }
1340 // From this point on the key belongs to the active control rather than
1341 // transcript navigation. Editing or moving the insertion cursor restores
1342 // the textarea's normal caret-following viewport.
1343 m.followComposerCursor()
1344 // A question card is modal: keys drive it. In its free-text ("Type
1345 // something") mode, the keystroke goes to the textarea — Enter confirms the
1346 // custom answer, Esc backs out of typing — so input/IME work as usual.
1347 if m.elicit != nil {
1348 if model, cmd, handled := m.elicitKey(msg, cmds); handled {
1349 return model, cmd
1350 }
1351 }
1352 if m.chooser != nil {
1353 if m.chooser.typing {
1354 switch msg.String() {
1355 case "enter":
1356 val := strings.TrimSpace(m.input.Value())
1357 m.resetComposerInput()
1358 m.chooser.typing = false
1359 m.refreshInputPlaceholder()
1360 if val == "" {
1361 return m, finalize(m, cmds)
1362 }
1363 m.chooser.custom[m.chooser.tab] = val
1364 m.chooser.sel[m.chooser.tab] = map[int]bool{}
1365 return m.chooserAdvance()
1366 case "esc":
1367 m.chooser.typing = false
1368 m.resetComposerInput()
1369 m.refreshInputPlaceholder()
1370 return m, finalize(m, cmds)
1371 }
1372 beforeInput := m.input.Value()
1373 var ic tea.Cmd
1374 m.input, ic = m.input.Update(msg)
1375 cmds = append(cmds, ic)
1376 m.growInputToFit()
1377 if shouldClearWideInputChange(beforeInput, m.input.Value()) {
1378 cmds = append(cmds, tea.ClearScreen)
1379 }
1380 return m, finalize(m, cmds)
1381 }
1382 return m.handleChooserKey(msg)
1383 }
1384 // The rewind picker is modal while open: keys navigate it.
1385 if m.rewind != nil {
1386 return m.handleRewindKey(msg)
1387 }
1388 // The MCP import picker is modal while open: keys select candidates.
1389 if m.mcpImport != nil {
1390 return m.handleMCPImportKey(msg)
1391 }
1392 // Copy picker is modal while open.
1393 if m.copyPick != nil {
1394 return m.handleCopyPickerKey(msg)
1395 }
1396 // The resume picker is modal while open: keys navigate it.
1397 if m.resumePick != nil {
1398 return m.handleResumePickerKey(msg)
1399 }
1400 // Searchable command pickers are modal while open.
1401 if m.quickPick != nil {
1402 return m.handleQuickPickerKey(msg)
1403 }
1404 if m.setup != nil {
1405 return m.handleConnectionSetupKey(msg)
1406 }
1407 // The MCP manager is modal while open: keys navigate it.
1408 if m.mcp != nil {
1409 return m.handleMCPManagerKey(msg)
1410 }
1411 // The destructive /clear confirmation is modal while open.
1412 if m.clearConfirm != nil {
1413 return m.handleClearConfirmKey(msg)
1414 }
1415 // The skill picker is modal while open: keys navigate it.
1416 if m.skillPick != nil {
1417 return m.handleSkillPickerKey(msg)
1418 }
1419 // A pending tool approval is modal: keystrokes answer it (y/a/n, Enter,
1420 // Esc) rather than reaching the input.
1421 if m.pendingApproval != nil {
1422 return m.handleApprovalKey(msg)
1423 }
1424 // While the autocomplete menu is open it captures navigation/accept keys
1425 // (↑/↓ move, Tab/Enter accept, Esc close); everything else falls through
1426 // to the textarea and re-filters the menu at the end of Update.
1427 if m.completion.active {
1428 switch msg.String() {
1429 case "up", "ctrl+p":
1430 m.moveCompletion(-1)
1431 return m, nil
1432 case "down", "ctrl+n":
1433 m.moveCompletion(1)
1434 return m, nil
1435 case "tab", "enter":
1436 if msg.String() == "enter" && (m.completionExactLabel() || m.completionBareOverlayCommand()) {
1437 m.dismissCompletion()
1438 break // fall through to regular Enter and submit the command
1439 }
1440 // When Enter is pressed and the selected completion is already fully
1441 // present in the input, close the menu and submit instead of accepting
1442 // the same item again (/resume 1 still has /resume 10 as a prefix match).
1443 if msg.String() == "enter" && m.completionSelectedInsertPresent() {
1444 m.dismissCompletion()
1445 break // fall through to regular Enter
1446 }
1447 m.acceptCompletion()
1448 return m, nil
1449 case "esc":
1450 m.dismissCompletion()
1451 if m.state == tuiRunning {
1452 break // a turn is running — also cancel it via the main Esc handler
1453 }
1454 return m, nil
1455 }
1456 }
1457 switch msg.String() {
1458 case "up":
1459 if m.state == tuiRunning {
1460 if m.navigateQueue(-1) {
1461 return m, nil
1462 }
1463 } else if m.recallSubmittedInput(-1) {
1464 return m, nil
1465 }
1466 case "down":
1467 if m.state == tuiRunning {
1468 if m.navigateQueue(1) {
1469 return m, nil
1470 }
1471 } else if m.recallSubmittedInput(1) {
1472 return m, nil
1473 }
1474 case "alt+up", "meta+up":
1475 if m.handleQueueReorder(-1) {
1476 return m, finalize(m, cmds)
1477 }
1478 case "alt+down", "meta+down":
1479 if m.handleQueueReorder(1) {
1480 return m, finalize(m, cmds)
1481 }
1482 case " ":
1483 if m.inboxQueuedCount() > 0 && m.input.Value() == "" {
1484 m.toggleInboxPaused()
1485 return m, finalize(m, cmds)
1486 }
1487 case "r":
1488 if m.queueEditCursor >= 0 && m.inboxSelectedID != "" {
1489 if err := m.ctrl.RetryInboxItem(m.inboxSelectedID); err != nil {
1490 m.notice("retry: " + err.Error())
1491 } else {
1492 m.notice("retry queued #" + shortID(m.inboxSelectedID))
1493 }
1494 return m, finalize(m, cmds)
1495 }
1496 case "d":
1497 if m.queueEditCursor >= 0 && m.inboxSelectedID != "" {
1498 if !m.queueConfirmDelete {
1499 m.queueConfirmDelete = true
1500 m.notice("press d again to delete #" + shortID(m.inboxSelectedID))
1501 return m, finalize(m, cmds)
1502 }
1503 if err := m.ctrl.DeleteInboxItem(m.inboxSelectedID); err != nil {
1504 m.notice("delete: " + err.Error())
1505 } else {
1506 m.notice("deleted #" + shortID(m.inboxSelectedID))
1507 }
1508 m.resetQueueNavigation()
1509 return m, finalize(m, cmds)
1510 }
1511 case "enter":
1512 // Don't reset queue navigation — the Enter handler below needs
1513 // queueEditCursor to decide whether to save an edit or enqueue.
1514 default:
1515 m.resetSubmittedInputRecall()
1516 // Preserve queue navigation while the user is editing a queued
1517 // item — only reset when they're not browsing the queue, so that
1518 // typing replacement text keeps queueEditCursor alive for the
1519 // Enter handler to save the edit in-place. (#4877)
1520 if m.queueEditCursor < 0 {
1521 m.resetQueueNavigation()
1522 } else {
1523 m.queueConfirmDelete = false
1524 }
1525 }
1526 if imagePasteShortcut(msg.String(), runtime.GOOS) {
1527 if m.state == tuiRunning {
1528 return m, nil
1529 }
1530 if cmd := m.beginClipboardImagePaste(); cmd != nil {
1531 cmds = append(cmds, cmd)
1532 }
1533 return m, finalize(m, cmds)
1534 }
1535 // Shift+Insert is the classic terminal paste key. Most terminals
1536 // intercept it themselves and deliver the clipboard text as bracketed
1537 // paste (tea.PasteMsg); some forward the key sequence instead (e.g. via
1538 // the kitty keyboard protocol). Bind it explicitly so paste works
1539 // either way — same native-clipboard read path as right-click, so SSH
1540 // sessions get the same remote hint and never read the remote host's
1541 // clipboard.
1542 if msg.String() == "shift+insert" {
1543 cmds = append(cmds, pasteClipboardText())
1544 return m, finalize(m, cmds)
1545 }
1546 // Mode shortcuts share one dispatcher so terminal-specific Shift+Tab
1547 // encodings and Ctrl+Y stay consistent without duplicating state logic.
1548 if m.handleModeShortcut(msg.String()) {
1549 return m, nil
1550 }
1551 switch m.endSlashArgSnapshotForKey(msg.String()) {
1552 case "esc":
1553 // "Back out" of the most specific in-progress state: un-send a just-sent
1554 // turn (server not yet replied), cancel a streaming turn, or clear
1555 // typed-but-unsent input. Mode switches (normal/plan/YOLO) are
1556 // exclusively driven by Shift+Tab — Esc must not silently flip a
1557 // session from plan or YOLO back to a less-permissive mode. PR #3051
1558 // removed the YOLO half of this; plan mode was missed and is fixed
1559 // here. Scrollback is the terminal's now, so there's no viewport to
1560 // dismiss.
1561 switch {
1562 case m.state == tuiRunning && m.bubblePending:
1563 m.unsendPending()
1564 case m.state == tuiRunning:
1565 m.ctrl.Cancel()
1566 // Defensive: if the controller is no longer running (cancel
1567 // completed synchronously, e.g. for shell commands), transition
1568 // to idle immediately instead of waiting for TurnDone.
1569 if !m.ctrl.Running() {
1570 m.state = tuiIdle
1571 m.confirmBubbleSent()
1572 m.noteWatchdogIdle()
1573 }
1574 default:
1575 // Idle (any mode): a double-Esc on an empty composer opens the
1576 // rewind picker (Claude Code's gesture); a first Esc just arms
1577 // it. Non-empty input clears as before.
1578 if strings.TrimSpace(m.input.Value()) == "" {
1579 if !m.lastEsc.IsZero() && time.Since(m.lastEsc) < 600*time.Millisecond {
1580 m.lastEsc = time.Time{}
1581 m.openRewind()
1582 } else {
1583 m.lastEsc = time.Now()
1584 }
1585 } else {
1586 m.resetComposerInput()
1587 m.pastedBlocks = nil
1588 }
1589 }
1590 return m, nil
1591 case "ctrl+insert":
1592 // Terminal-convention copy without Ctrl+C's destructive side
1593 // effects: copy an active selection if there is one, otherwise do
1594 // nothing (no clear-input, no cancel, no quit). The selection lives
1595 // in-app because Reasonix owns the mouse, so the terminal's own
1596 // Ctrl+Insert (which copies the terminal selection) would see an
1597 // empty one.
1598 if sel.active && !sel.empty() {
1599 m.sel = sel // restore so selectedText() can read it
1600 text := m.selectedText()
1601 m.sel = selection{}
1602 cmds = append(cmds, m.copySelectionWithNotice(text))
1603 return m, finalize(m, cmds)
1604 }
1605 return m, nil
1606 case "ctrl+c", "super+c", "meta+c":
1607 if m.state == tuiRunning {
1608 // Selection takes precedence: copy instead of cancel, same as idle.
1609 if sel.active && !sel.empty() {
1610 m.sel = sel
1611 text := m.selectedText()
1612 m.sel = selection{}
1613 cmds = append(cmds, m.copySelectionWithNotice(text))
1614 return m, finalize(m, cmds)
1615 }
1616 if m.bubblePending {
1617 m.unsendPending() // server not yet replied — restore text, leave no trace
1618 } else if m.cancelRequested() {
1619 m.ctrl.Cancel()
1620 return m, shutdownNow
1621 } else {
1622 m.ctrl.Cancel()
1623 }
1624 return m, nil
1625 }
1626 // Idle: an active text selection takes precedence over the
1627 // composer-clear / double-press-quit gestures. Standard terminal
1628 // convention is "Ctrl+C copies the selection" — the user can still
1629 // clear the input with a second Ctrl+C once the selection is gone.
1630 // Hoisting this branch above the clear branch also stops the
1631 // previous behaviour where Ctrl+C would dismiss a selection AND
1632 // wipe any draft text the user was typing — felt like the
1633 // selection was being silently lost.
1634 if sel.active && !sel.empty() {
1635 m.sel = sel // restore so selectedText() can read it
1636 text := m.selectedText()
1637 m.sel = selection{}
1638 cmds = append(cmds, m.copySelectionWithNotice(text))
1639 return m, finalize(m, cmds)
1640 }
1641 // No selection: if the composer has text, a single press clears it
1642 // (like Esc); on an empty composer a double-press within 1.5s quits.
1643 if strings.TrimSpace(m.input.Value()) != "" {
1644 m.resetComposerInput()
1645 m.pastedBlocks = nil
1646 m.lastCtrlCAt = time.Time{}
1647 return m, nil
1648 }
1649 if !m.lastCtrlCAt.IsZero() && time.Since(m.lastCtrlCAt) < 1500*time.Millisecond {
1650 return m, shutdownNow
1651 }
1652 m.lastCtrlCAt = time.Now()
1653 m.notice(i18n.M.CtrlCQuitHint)
1654 return m, finalize(m, nil)
1655 case "ctrl+d":
1656 // Compatible Ctrl+D: forward-delete when the composer has any
1657 // raw content (including whitespace-only); only quit when idle
1658 // with a truly empty composer (bash/readline-style EOF).
1659 if m.input.Value() != "" {
1660 // Delegate to textarea DeleteCharacterForward (bound to
1661 // ctrl+d by default) so mid-line forward delete works.
1662 var ic tea.Cmd
1663 m.input, ic = m.input.Update(msg)
1664 if ic != nil {
1665 cmds = append(cmds, ic)
1666 }
1667 m.growInputToFit()
1668 m.updateCompletion()
1669 return m, finalize(m, cmds)
1670 }
1671 if m.state == tuiIdle {
1672 return m, shutdownNow
1673 }
1674 return m, nil
1675 case "ctrl+l":
1676 if m.state != tuiRunning {
1677 m.finalizeStreamed()
1678 m.clearTranscriptDisplay()
1679 m.commitTranscriptSource(transcriptSource{kind: transcriptSourceBanner})
1680 m.transcriptDirty = true
1681 m.forceGotoBottom = true
1682 m.notice(i18n.M.SlashClsDone)
1683 }
1684 return m, finalize(m, cmds)
1685 case "ctrl+o":
1686 m.toggleVerboseReasoning(m.state != tuiRunning)
1687 return m, finalize(m, cmds)
1688 case "ctrl+b":
1689 m.toggleShellOutput()
1690 return m, finalize(m, cmds)
1691 case "ctrl+enter":
1692 // Durable mid-turn steer (terminals without modified Enter use /steer).
1693 if m.state == tuiRunning {
1694 line := strings.TrimSpace(m.input.Value())
1695 if line == "" {
1696 return m, nil
1697 }
1698 // Local /queue always, even while running.
1699 if handled, msg := m.handleQueueSlash(line); handled {
1700 m.notice(msg)
1701 m.resetComposerInput()
1702 m.pastedBlocks = nil
1703 return m, finalize(m, cmds)
1704 }
1705 body := m.expandPastedBlocks(line)
1706 rec, err := m.enqueueSteer(body, body)
1707 if err != nil {
1708 m.notice("steer: " + err.Error())
1709 // Keep composer text on durable failure.
1710 return m, finalize(m, cmds)
1711 }
1712 switch rec.Disposition {
1713 case sessioninbox.DispositionSteerAccepted:
1714 m.notice(fmt.Sprintf("steer accepted #%s", shortID(rec.ItemID)))
1715 case sessioninbox.DispositionQueuedFollowup:
1716 m.notice(fmt.Sprintf("steer rejected — durable follow-up #%s", shortID(rec.ItemID)))
1717 default:
1718 m.notice(fmt.Sprintf("queued #%s", shortID(rec.ItemID)))
1719 }
1720 m.resetComposerInput()
1721 m.pastedBlocks = nil
1722 m.resetQueueNavigation()
1723 return m, finalize(m, cmds)
1724 }
1725 case "enter":
1726 if m.state == tuiRunning {
1727 line := strings.TrimSpace(m.input.Value())
1728 if line == "" {
1729 m.viewport.GotoBottom()
1730 m.markFollowTail()
1731 return m, nil
1732 }
1733 // /queue and /steer are local commands even mid-turn.
1734 if handled, msg := m.handleQueueSlash(line); handled {
1735 m.notice(msg)
1736 m.resetComposerInput()
1737 m.pastedBlocks = nil
1738 return m, finalize(m, cmds)
1739 }
1740 body := m.expandPastedBlocks(line)
1741 items := m.inboxPreviews()
1742 if m.queueEditCursor >= 0 && m.queueEditCursor < len(items) {
1743 id := items[m.queueEditCursor].ID
1744 if _, err := m.ctrl.UpdateInboxItem(id, body, body, body); err != nil {
1745 m.notice("queue update: " + err.Error())
1746 return m, finalize(m, cmds)
1747 }
1748 m.notice(fmt.Sprintf("queue [%d] updated", m.queueEditCursor+1))
1749 m.resetQueueNavigation()
1750 } else {
1751 rec, err := m.enqueueFollowup(body, body)
1752 if err != nil {
1753 m.notice("queue: " + err.Error())
1754 // Keep composer text on durable failure / capacity.
1755 return m, finalize(m, cmds)
1756 }
1757 m.notice(fmt.Sprintf("durable follow-up queued #%s — will run when idle", shortID(rec.ItemID)))
1758 m.resetQueueNavigation()
1759 }
1760 m.resetComposerInput()
1761 m.pastedBlocks = nil
1762 return m, finalize(m, cmds)
1763 }
1764 if m.modelSwitchPending {
1765 return m, nil // ignore Enter while /model switch is building
1766 }
1767 line := strings.TrimSpace(m.input.Value())
1768
1769 if line == "" {
1770 m.viewport.GotoBottom()
1771 m.markFollowTail()
1772 return m, nil
1773 }
1774 if line == "exit" || line == "quit" || line == ":q" {
1775 return m, shutdownNow
1776 }
1777 if m.reclaimBlocksInput(line) {
1778 return m, finalize(m, cmds)
1779 }
1780 // /queue and /steer are local even when idle (never model-prompted).
1781 if handled, msg := m.handleQueueSlash(line); handled {
1782 m.notice(msg)
1783 m.resetComposerInput()
1784 m.pastedBlocks = nil
1785 return m, finalize(m, cmds)
1786 }
1787 m.rememberSubmittedInput(line)
1788
1789 // "# <note>" quick-adds a memory line locally, no model turn. The
1790 // space keeps "#7" / "#issue" prompts from being swallowed.
1791 if note, ok := control.MemoryQuickAddNote(line); ok {
1792 m.resetComposerInput()
1793 m.pastedBlocks = nil
1794 if note == "" {
1795 m.notice(i18n.M.QuickRememberEmpty)
1796 } else if path, err := m.ctrl.QuickAdd(memory.ScopeProject, note); err != nil {
1797 m.notice("memory: " + err.Error())
1798 } else {
1799 m.notice(fmt.Sprintf(i18n.M.QuickRememberDoneFmt, path))
1800 }
1801 return m, finalize(m, cmds)
1802 }
1803
1804 // "!<cmd>" runs a shell command directly, bypassing the model.
1805 if after, ok := strings.CutPrefix(line, "!"); ok {
1806 cmd := after
1807 if strings.TrimSpace(cmd) == "" {
1808 m.resetComposerInput()
1809 m.pastedBlocks = nil
1810 m.notice(i18n.M.ShellExecEmpty)
1811 return m, finalize(m, cmds)
1812 }
1813 m.resetComposerInput()
1814 m.pastedBlocks = nil
1815 m.state = tuiRunning
1816 m.runStart = time.Now()
1817 m.elapsed = 0
1818 m.turnTokens = 0
1819 m.pendingRestore = line
1820 m.bubbleStartIdx = len(m.transcript)
1821 m.commitLine("")
1822 m.commitTranscriptSource(transcriptSource{
1823 kind: transcriptSourceUser, raw: line, planMode: m.planMode,
1824 })
1825 m.bubblePending = true
1826 m.turnDiscarded = false
1827 m.confirmBubbleSent() // shell events arrive instantly
1828 m.noteWatchdogRunning()
1829 m.ctrl.RunShell(cmd)
1830 return m, m.startRunningTicks()
1831 }
1832
1833 // Slash commands run locally without going through the model. A
1834 // '/'-leading line that's actually a dragged file path is an attachment,
1835 // not a command, so it's rewritten to an @reference instead.
1836 if control.SlashCodeCommentLine(line) {
1837 // Slash-prefixed code comments are prompt text, not commands.
1838 // Not a command. Fall through to normal message path.
1839 } else if strings.HasPrefix(line, "/") {
1840 if ref, ok := control.FileRefLine(line); ok {
1841 line = ref
1842 } else {
1843 m.resetComposerInput()
1844 m.pastedBlocks = nil
1845 cmds = append(cmds, m.runSlashCommand(line))
1846 return m, finalize(m, cmds)
1847 }
1848 }
1849
1850 sentLine := m.expandPastedBlocks(line)
1851 m.resetComposerInput()
1852
1853 // @references (local files / MCP resources, including inline image
1854 // attachments) are resolved off the event loop by the controller; the turn
1855 // starts when they resolve (refsResolvedMsg).
1856 if m.ctrl.HasRefs(sentLine) {
1857 cmds = append(cmds, m.resolveRefs(sentLine, sentLine, line))
1858 return m, finalize(m, cmds)
1859 }
1860
1861 // Keep the expanded paste content as the raw turn, not the folded label,
1862 // so downstream consumers never see just the placeholder label.
1863 cmds = append(cmds, m.startTurnWithRaw(sentLine, sentLine, line, sentLine))
1864 return m, finalize(m, cmds)
1865 }
1866
1867 case agentEventMsg:
1868 e := event.Event(msg)
1869 drained := m.drainAgentEvents(e)
1870 cmds = append(cmds, waitForAgentEvent(m.eventCh))
1871 cmds = append(cmds, drained.cmds...)
1872 // A turn just spent tokens (and money) — refresh the balance readout and
1873 // the custom status line (its context/cost inputs just changed).
1874 if drained.turnDone {
1875 cmds = append(cmds, fetchBalance(m.ctrl))
1876 if c := m.runStatusline(); c != nil {
1877 cmds = append(cmds, c)
1878 }
1879 // Durable inbox dispatch is owned by the controller after TurnDone.
1880 // Reset local queue navigation when the snapshot changes.
1881 m.resetQueueNavigation()
1882 // A /reload typed while the turn ran fires now that the TUI may be
1883 // idle; the drain re-checks busy state (an inbox admission above or a
1884 // background job keeps it queued).
1885 if c := m.drainQueuedRuntimeReload(); c != nil {
1886 cmds = append(cmds, c)
1887 }
1888 }
1889 if drained.turnDone || drained.gitMaybeChanged {
1890 if c := m.refreshGitStatus(); c != nil {
1891 cmds = append(cmds, c)
1892 }
1893 }
1894
1895 case balanceMsg:
1896 m.balance = msg.text
1897
1898 case statuslineMsg:
1899 m.statuslineOut = msg.out
1900
1901 case gitStatusMsg:
1902 m.gitStatus = msg.status
1903
1904 case compactDoneMsg:
1905 if msg.err != nil {
1906 m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashCompactFailed, msg.err))
1907 } else {
1908 _ = m.ctrl.Snapshot()
1909 m.followSessionLease()
1910 }
1911
1912 case tuiShutdownMsg:
1913 return m.shutdownAndQuit(msg)
1914
1915 case tuiSessionReclaimedMsg:
1916 return m.completeSessionReclaim()
1917
1918 case turnModelSettingsMsg:
1919 return m, m.handleTurnModelSettings(msg)
1920 case modelSwitchMsg:
1921 cmds = append(cmds, m.handleModelSwitch(msg)...)
1922
1923 case connectionCredentialSavedMsg:
1924 return m, m.handleConnectionCredentialSaved(msg)
1925 case connectionCredentialTestedMsg:
1926 m.handleConnectionCredentialTested(msg)
1927 return m, nil
1928
1929 case promptResolvedMsg:
1930 switch {
1931 case msg.err != nil:
1932 m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+msg.err.Error(), m.width, activeCLITheme.warn))
1933 case strings.TrimSpace(msg.sent) == "":
1934 m.notice(i18n.M.SlashPromptEmpty)
1935 default:
1936 cmds = append(cmds, m.startTurn(msg.sent, msg.display, msg.display))
1937 }
1938
1939 case extensionActionMsg:
1940 switch {
1941 case msg.err != nil:
1942 m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+msg.err.Error(), m.width, activeCLITheme.warn))
1943 case strings.TrimSpace(msg.message) != "":
1944 m.notice(msg.message)
1945 }
1946
1947 case mcpExternalDoneMsg:
1948 m.handleMCPExternalDone(msg)
1949
1950 case refsResolvedMsg:
1951 for _, e := range msg.errs {
1952 m.notice(e) // surface a fetch failure but still send the turn
1953 }
1954 sent := msg.sent
1955 if msg.block != "" {
1956 sent = "Referenced context:\n\n" + msg.block + "\n\n" + msg.sent
1957 }
1958 // raw = msg.display (the expanded paste content, without resolved @-ref
1959 // payloads) — NOT msg.restore (the folded label). See the non-refs branch
1960 // above for why raw needs the expansion.
1961 cmds = append(cmds, m.startTurnWithRaw(sent, msg.display, msg.restore, msg.display))
1962
1963 case clipboardImageMsg:
1964 requests := max(m.clipboardImageRequests, 1)
1965 m.clipboardImagePending = false
1966 m.clipboardImageRequests = 0
1967 if msg.err != nil {
1968 // An empty image clipboard is the normal case for a text paste on
1969 // terminals that hand Ctrl+V to the application instead of pasting
1970 // themselves. Fall through to text rather than blocking the paste.
1971 if errors.Is(msg.err, control.ErrNoClipboardImage) {
1972 // Skip the fallback when the terminal already delivered a
1973 // bracketed paste for this key press; it owns the paste and
1974 // pasting again would duplicate the text.
1975 pending := pendingClipboardTextPastes(requests, m.clipboardImageTerminalPasteSeq, m.terminalPasteSeq)
1976 if pending > 0 {
1977 cmds = append(cmds, pasteClipboardTextGuarded(m.terminalPasteSeq, pending, msg.err))
1978 }
1979 break
1980 }
1981 m.notice(fmt.Sprintf(i18n.M.ClipboardImagePasteFailedFmt, sanitizeExternalDisplayText(msg.err.Error())))
1982 break
1983 }
1984 imageBefore := m.input.Value()
1985 m.insertImageRef(msg.path)
1986 if shouldClearWideInputChange(imageBefore, m.input.Value()) {
1987 cmds = append(cmds, tea.ClearScreen)
1988 }
1989
1990 case clipboardTextPasteMsg:
1991 return m.handleClipboardTextPaste(msg)
1992
1993 case clipboardCopyMsg:
1994 if msg.statusHint && msg.seq != m.copyNoticeSeq {
1995 break
1996 }
1997 label := i18n.M.MouseCopiedHint
1998 if !msg.statusHint {
1999 label = i18n.M.SlashCopyDone
2000 }
2001 if msg.osc52 || msg.err != nil {
2002 label = i18n.M.ClipboardCopyOSC52Hint
2003 if msg.err != nil {
2004 label = i18n.M.ClipboardCopyFallbackHint
2005 }
2006 cmds = append(cmds, tea.SetClipboard(msg.text))
2007 }
2008 if msg.statusHint {
2009 m.copyNoticeText = label
2010 cmds = append(cmds, copyNoticeExpire(msg.seq))
2011 } else {
2012 m.notice(label)
2013 }
2014
2015 case copyNoticeExpireMsg:
2016 if msg.seq == m.copyNoticeSeq {
2017 m.copyNoticeText = ""
2018 }
2019
2020 case themeSweepTickMsg:
2021 if m.themeSweep != nil {
2022 if m.themeSweep.advance() {
2023 cmds = append(cmds, themeSweepTick())
2024 } else {
2025 m.themeSweep = nil
2026 }
2027 }
2028
2029 case elapsedTickMsg:
2030 if m.state == tuiRunning && msg.generation == m.elapsedTickGeneration {
2031 // elapsedTick is the primary active-turn heartbeat: long turns that
2032 // emit no agent events still prove the Bubble Tea loop is alive.
2033 m.noteWatchdogHeartbeat("elapsed_tick")
2034 m.elapsed = int(time.Since(m.runStart).Seconds())
2035 m.tickToolRunning()
2036 m.tickSubagentProgress()
2037 cmds = append(cmds, elapsedTick(msg.generation))
2038 }
2039
2040 case spinner.TickMsg:
2041 if m.state == tuiRunning {
2042 var cmd tea.Cmd
2043 m.spinner, cmd = m.spinner.Update(msg)
2044 cmds = append(cmds, cmd)
2045 }
2046 }
2047
2048 beforeInput := m.input.Value()
2049 if inputBeforeSelection != "" {
2050 beforeInput = inputBeforeSelection
2051 }
2052 var ic tea.Cmd
2053 m.input, ic = m.input.Update(msg)
2054 cmds = append(cmds, ic)
2055 m.growInputToFit()
2056 // Re-filter the autocomplete menu against the freshly-edited input.
2057 if _, ok := msg.(tea.KeyPressMsg); ok {
2058 m.updateCompletion()
2059 }
2060 if shouldClearWideInputChange(beforeInput, m.input.Value()) {
2061 cmds = append(cmds, tea.ClearScreen)
2062 }
2063
2064 return m, finalize(m, cmds)
2065 }
2066
2067 var clearWideInputChanges = runtime.GOOS == "windows"
2068
2069 func shouldClearWideInputChange(before, after string) bool {
2070 return clearWideInputChanges &&
2071 before != after &&
2072 (hasWideInputCells(before) || hasWideInputCells(after))
2073 }
2074
2075 func hasWideInputCells(s string) bool {
2076 return s != "" && visibleWidth(s) != utf8.RuneCountInString(s)
2077 }
2078
2079 // finalize drains the committed-line queue and batches the turn's commands. In
2080 // the default alt-screen path the queue is already mirrored in m.transcript. In
2081 // Termux finalized lines are also emitted into the terminal's native scrollback.
2082 func finalize(m chatTUI, cmds []tea.Cmd) tea.Cmd {
2083 if m.nativeScrollback && len(*m.pendingCommit) > 0 {
2084 out := strings.TrimRight(clampWidth(strings.Join(*m.pendingCommit, "\n"), m.width), "\n")
2085 *m.pendingCommit = (*m.pendingCommit)[:0]
2086 var prints []tea.Cmd
2087 for _, chunk := range chunkLines(out, m.scrollChunkHeight()) {
2088 prints = append(prints, tea.Println(chunk))
2089 }
2090 cmds = append(cmds, tea.Sequence(prints...))
2091 return tea.Batch(cmds...)
2092 }
2093 *m.pendingCommit = (*m.pendingCommit)[:0]
2094 return tea.Batch(cmds...)
2095 }
2096
2097 func (m *chatTUI) clearTranscriptDisplay() {
2098 if m.pendingCommit != nil {
2099 *m.pendingCommit = (*m.pendingCommit)[:0]
2100 }
2101 m.transcript = nil
2102 m.transcriptSources = nil
2103 m.clearWrapCache()
2104 m.viewport.SetContent("")
2105 m.shellOutputs = make(map[string]string)
2106 m.shellExpanded = make(map[string]bool)
2107 m.shellTranscriptIdx = make(map[string]int)
2108 m.toolLineCountByID = make(map[string]int)
2109 m.subagentProgressIdx = make(map[string]int)
2110 m.subagentProgress = make(map[string]*cliSubagentProgress)
2111 m.toolStreamID = ""
2112 m.toolStreamIdx = -1
2113 m.toolTail = nil
2114 m.toolPartial = ""
2115 m.toolLineCount = 0
2116 }
2117
2118 // scrollChunkHeight is the largest block (in lines) finalize prints at once in
2119 // native-scrollback mode, leaving room for the pinned bottom frame.
2120 func (m chatTUI) scrollChunkHeight() int {
2121 if m.height <= 0 {
2122 return 100
2123 }
2124 if n := m.height - m.bottomRows(); n > 1 {
2125 return n
2126 }
2127 return 1
2128 }
2129
2130 // chunkLines splits s into blocks of at most n lines each, preserving order and
2131 // line content. A single block is returned when it already fits.
2132 func chunkLines(s string, n int) []string {
2133 if n < 1 {
2134 n = 1
2135 }
2136 lines := strings.Split(s, "\n")
2137 if len(lines) <= n {
2138 return []string{s}
2139 }
2140 var out []string
2141 for i := 0; i < len(lines); i += n {
2142 end := min(i+n, len(lines))
2143 out = append(out, strings.Join(lines[i:end], "\n"))
2144 }
2145 return out
2146 }
2147
2148 // clampWidth hard-breaks any line wider than width so no scrollback line wraps
2149 // in the terminal. bubbletea's inline renderer estimates how far to scroll for
2150 // each printed block from each line's width (insertAbove: offset += width/w); an
2151 // over-wide line that the terminal wraps throws that estimate off and drifts the
2152 // pinned input box off-screen. Lines already within width are left byte-for-byte
2153 // untouched (chunkByWidth preserves content and ANSI), so rendered tables and the
2154 // wrapped answer — which the markdown renderer already fit to width — are safe;
2155 // only stray long lines (tool-dispatch args, unwrapped code) get broken.
2156 func clampWidth(s string, width int) string {
2157 if width <= 0 {
2158 return s
2159 }
2160 // ansi.Hardwrap breaks any line over `width` visible cols on grapheme
2161 // boundaries, preserving ANSI and counting wide chars — exactly what we want,
2162 // and lines already within width pass through unchanged.
2163 return ansi.Hardwrap(s, width, false)
2164 }
2165
2166 // commitLine queues one finalized block for the next scrollback flush.
2167 func (m *chatTUI) commitLine(s string) {
2168 *m.pendingCommit = append(*m.pendingCommit, s)
2169 m.appendTranscriptBlock(s, transcriptSource{kind: transcriptSourceFixed})
2170 }
2171
2172 // commitSpacer separates the next block (a thinking marker or a tool line) from
2173 // the previous one with a single blank line, skipping it at the top of the
2174 // transcript or when a blank already trails so spacers never double up.
2175 func (m *chatTUI) commitSpacer() {
2176 if n := len(m.transcript); n > 0 && strings.TrimSpace(m.transcript[n-1]) != "" {
2177 m.commitLine("")
2178 }
2179 }
2180
2181 // bottomRows is the terminal-row height of the pinned bottom region: any open
2182 // bottom panels (todo / approval / chooser / rewind / completion), the composer
2183 // when visible, and the two fixed status rows. Full-screen managers such as MCP
2184 // and skills normally render inside the main transcript area; in native
2185 // scrollback mode they join the bottom rail because there is no main viewport.
2186 func (m chatTUI) bottomRows() int {
2187 rows := 0
2188 for _, s := range []string{
2189 m.renderTodoPanel(),
2190 m.renderApprovalBanner(),
2191 m.renderChooser(),
2192 m.renderElicit(),
2193 m.renderRewind(),
2194 m.renderMCPImport(),
2195 m.renderResumePicker(),
2196 m.renderQuickPicker(),
2197 m.renderConnectionSetup(),
2198 m.renderCopyPicker(),
2199 m.renderCompletion(),
2200 } {
2201 if s != "" {
2202 rows += strings.Count(s, "\n") + 1
2203 }
2204 }
2205 // Remove the hardcoded working-line increment — it is counted inside
2206 // statusLineCount via computeStatusLineCount, which also accounts for
2207 // wrapping. The fallback to 2 (unwrapped) covers the initial frame and
2208 // tests that don't call Update first.
2209 if m.nativeScrollback {
2210 if main := m.renderMainManager(); main != "" {
2211 rows += strings.Count(main, "\n") + 1
2212 }
2213 }
2214 if footer := m.renderMainManagerFooter(); footer != "" {
2215 rows += strings.Count(footer, "\n") + 1
2216 }
2217 if !m.hideComposer() {
2218 rows += m.input.Height() + 2
2219 }
2220 if m.statusLineCount > 0 {
2221 return rows + m.statusLineCount
2222 }
2223 return rows + 2 // fallback for tests that don't set statusLineCount
2224 }
2225
2226 // hideComposer is the single ownership gate for the bottom composer.
2227 //
2228 // Rule for new CLI panels:
2229 // - If a panel is modal and keystrokes navigate/confirm/cancel the panel, hide
2230 // the composer so users do not see an inactive chat input.
2231 // - If a panel is input-owned (autocomplete, or chooser free-text mode), keep
2232 // the composer visible because the textarea is the active control.
2233 //
2234 // Whenever a new slash-command overlay or approval-style prompt is added, update
2235 // this function and the modal layout tests together. Otherwise the panel may
2236 // reserve rows for a composer that cannot receive input, leaving a confusing
2237 // blank/bordered area at the bottom of the TUI.
2238 func (m chatTUI) hideComposer() bool {
2239 if m.mcp != nil || m.clearConfirm != nil || m.mcpImport != nil || m.skillPick != nil || m.resumePick != nil || m.quickPick != nil || m.setup != nil || m.copyPick != nil || m.rewind != nil || m.pendingApproval != nil {
2240 return true
2241 }
2242 return (m.chooser != nil && !m.chooser.typing) || (m.elicit != nil && !m.elicit.typing)
2243 }
2244
2245 // transcriptHeight is the row budget left for the transcript viewport once the
2246 // pinned bottom region is accounted for (at least one row).
2247 func (m chatTUI) transcriptHeight() int {
2248 if h := m.height - m.bottomRows(); h > 1 {
2249 return h
2250 }
2251 return 1
2252 }
2253
2254 func (m chatTUI) renderMainManager() string {
2255 if card := m.renderMCPManager(); card != "" {
2256 return card
2257 }
2258 if card := m.renderClearConfirm(); card != "" {
2259 return card
2260 }
2261 return m.renderSkillPicker()
2262 }
2263
2264 func managerContentPanelStyle(width int) lipgloss.Style {
2265 return choicePanelStyle.
2266 Border(lipgloss.NormalBorder(), true, false, false, false).
2267 Width(width)
2268 }
2269
2270 func managerFooterPanelStyle(width int) lipgloss.Style {
2271 return choicePanelStyle.
2272 Border(lipgloss.NormalBorder(), false, false, true, false).
2273 Width(width)
2274 }
2275
2276 func (m chatTUI) renderMainManagerFooter() string {
2277 hint := ""
2278 switch {
2279 case m.mcp != nil:
2280 hint = m.mcp.footerHint()
2281 case m.clearConfirm != nil:
2282 hint = "Enter confirm · y clear · n/Esc cancel"
2283 case m.skillPick != nil:
2284 hint = m.skillPickerFooterHint()
2285 }
2286 if strings.TrimSpace(hint) == "" {
2287 return ""
2288 }
2289 w := max(viewWidth(m.width), 40)
2290 return managerFooterPanelStyle(w).Render(dim(hint))
2291 }
2292
2293 func (m chatTUI) renderTranscriptWithMainManager(card string) string {
2294 h := m.viewport.Height()
2295 if h <= 0 {
2296 return ""
2297 }
2298 cw := m.viewport.Width()
2299 if cw <= 0 {
2300 cw = max(m.width-1, 1)
2301 }
2302
2303 cardLines := strings.Split(strings.TrimRight(card, "\n"), "\n")
2304 if len(cardLines) > h {
2305 cardLines = cardLines[:h]
2306 }
2307 maxTranscriptRows := h - len(cardLines)
2308 if maxTranscriptRows > 0 && len(cardLines) > 0 && len(m.wrappedLines) > 0 {
2309 maxTranscriptRows--
2310 }
2311
2312 var rows []string
2313 if maxTranscriptRows > 0 {
2314 lines := m.wrappedLines
2315 start := max(0, len(lines)-maxTranscriptRows)
2316 rows = append(rows, lines[start:]...)
2317 }
2318 if len(rows) > 0 && len(cardLines) > 0 {
2319 rows = append(rows, "")
2320 }
2321 rows = append(rows, cardLines...)
2322 for len(rows) < h {
2323 rows = append(rows, "")
2324 }
2325 for i, row := range rows {
2326 rows[i] = padRight(ansi.Cut(row, 0, cw), cw)
2327 }
2328 return strings.Join(rows, "\n")
2329 }
2330
2331 // reasoningViewMax bounds the live thinking buffer the streamed block renders
2332 // from. Re-rendering the full chain of thought on every delta was O(n²) (a 2k-
2333 // token thought churned ~4.7GB); rendering only the trailing window keeps each
2334 // delta O(1). The full text still lives in m.reasoning for verbose mode.
2335 const reasoningViewMax = 4096
2336
2337 // reasoningTailLines caps how many trailing visual lines the live block shows.
2338 const reasoningTailLines = 12
2339
2340 // streamReasoning appends a chunk and rewrites the live reasoning block from a
2341 // bounded trailing view (mirrors streamToolOutput), so the chain of thought is
2342 // visible while the model works without re-rendering the whole thing per token.
2343 func (m *chatTUI) streamReasoning(chunk string) {
2344 m.reasoning.WriteString(chunk) // full text retained for verbose mode
2345 if m.reasoningTextIdx < 0 {
2346 return
2347 }
2348 m.reasoningView = append(m.reasoningView, chunk...)
2349 if len(m.reasoningView) > reasoningViewMax {
2350 drop := len(m.reasoningView) - reasoningViewMax
2351 for drop < len(m.reasoningView) && !utf8.RuneStart(m.reasoningView[drop]) {
2352 drop++
2353 }
2354 m.reasoningView = m.reasoningView[:copy(m.reasoningView, m.reasoningView[drop:])]
2355 }
2356 raw := string(m.reasoningView)
2357 m.setTranscriptBlock(m.reasoningTextIdx, reasoningBlock(raw, m.width, reasoningTailLines), transcriptSource{
2358 kind: transcriptSourceReasoning, raw: raw, maxLines: reasoningTailLines,
2359 })
2360 }
2361
2362 // reasoningBlock renders raw thinking text as dim, width-wrapped lines under a
2363 // "⎿" connector that ties the block to the "▎ thinking…" marker above it. A
2364 // positive maxLines keeps only the trailing visual lines (the live view); 0
2365 // renders all (verbose collapse).
2366 func reasoningBlock(raw string, width, maxLines int) string {
2367 return connectorBlock(reasoningBlockLines(raw, width, maxLines))
2368 }
2369
2370 // toolStreamTailLines caps how many trailing output lines a running tool shows;
2371 // the live block scrolls within this window so a chatty build doesn't flood.
2372 const toolStreamTailLines = 8
2373
2374 // shellPreviewLines is how many lines of shell output to show by default after
2375 // the command finishes. Ctrl+B toggles the full output.
2376 const shellPreviewLines = 10
2377
2378 // shellExpandMaxLines caps how many lines Ctrl+B shows in expanded mode, so a
2379 // very large output (e.g. thousands of lines) doesn't hang the TUI or push the
2380 // input box off-screen.
2381 const shellExpandMaxLines = 200
2382
2383 // streamToolOutput appends a chunk of a running tool's output and re-renders its
2384 // live block (the last toolStreamTailLines lines) under the tool card, opening
2385 // the block on the first chunk. Mirrors streamReasoning.
2386 func (m *chatTUI) streamToolOutput(id, chunk string) {
2387 if id == "" {
2388 return
2389 }
2390 if m.toolStreamID != id {
2391 // Switching to a different id means either:
2392 // (a) the previous tool finished and a new one is starting — collapse
2393 // the current id's live block, then append a fresh slot at the
2394 // end of the transcript.
2395 // (b) late ToolProgress for an earlier (already dispatched and
2396 // possibly collapsed) tool — reuse the slot beginToolRunning
2397 // already wrote for that id, so the live block stays directly
2398 // under the earlier tool's card rather than stacking at the end.
2399 if existingIdx, ok := m.shellTranscriptIdx[id]; ok && existingIdx >= 0 && existingIdx < len(m.transcript) {
2400 // Stash the switched-away id's live count before resetting it;
2401 // its late ToolResult reads it back via toolLineCountByID.
2402 if m.toolStreamID != "" && m.toolStreamID != id {
2403 n := m.toolLineCount
2404 if m.toolPartial != "" {
2405 n++
2406 }
2407 if n > 0 {
2408 m.toolLineCountByID[m.toolStreamID] = n
2409 }
2410 }
2411 m.toolStreamID = id
2412 m.toolStreamIdx = existingIdx
2413 m.toolTail = m.toolTail[:0]
2414 m.toolPartial = ""
2415 m.toolLineCount = 0
2416 } else {
2417 // Unknown id: collapse the active stream (its live count is intact).
2418 m.collapseToolOutput(m.toolStreamID, "")
2419 m.toolStreamID = id
2420 m.toolTail = m.toolTail[:0]
2421 m.toolPartial = ""
2422 m.toolLineCount = 0
2423 if m.nativeScrollback {
2424 m.toolStreamIdx = -1
2425 } else {
2426 m.toolStreamIdx = len(m.transcript)
2427 m.commitConnectorBlock(nil)
2428 }
2429 }
2430 }
2431 // Accumulate full output for shell commands so Ctrl+B can expand it.
2432 if strings.HasPrefix(id, "shell-") {
2433 m.shellOutputs[id] += chunk
2434 }
2435 // Fold completed lines into the bounded tail; keep the trailing partial.
2436 data := m.toolPartial + chunk
2437 for {
2438 i := strings.IndexByte(data, '\n')
2439 if i < 0 {
2440 break
2441 }
2442 m.pushToolLine(strings.TrimRight(data[:i], "\r"))
2443 data = data[i+1:]
2444 }
2445 m.toolPartial = data
2446
2447 vis := m.toolTail
2448 if m.toolPartial != "" {
2449 vis = append(append([]string{}, m.toolTail...), m.toolPartial)
2450 }
2451 if m.nativeScrollback {
2452 return
2453 }
2454 lines := make([]string, len(vis))
2455 for i, ln := range vis {
2456 lines[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
2457 }
2458 m.rewriteConnectorBlock(m.toolStreamIdx, lines)
2459 }
2460
2461 // pushToolLine appends a completed output line to the bounded tail, dropping the
2462 // oldest when it exceeds the window (the backing array stays ≤ window+1).
2463 func (m *chatTUI) pushToolLine(line string) {
2464 m.toolLineCount++
2465 m.toolTail = append(m.toolTail, line)
2466 if len(m.toolTail) > toolStreamTailLines {
2467 copy(m.toolTail, m.toolTail[1:])
2468 m.toolTail = m.toolTail[:toolStreamTailLines]
2469 }
2470 }
2471
2472 // subagentPreviewMax bounds each child's retained reasoning/text preview tail
2473 // (verbose mode renders from it); the notice tail is smaller.
2474 const (
2475 subagentPreviewMax = 4096
2476 subagentNoticeMax = 2048
2477 // subagentPreviewTailLines caps the trailing visual lines of a preview.
2478 subagentPreviewTailLines = 12
2479 )
2480
2481 // cliSubagentProgress is the per-child live state backing one fixed transcript
2482 // slot. Everything here is in-memory only: the persisted sub-agent transcript
2483 // remains the source of truth after a restart.
2484 type cliSubagentProgress struct {
2485 phase string
2486 startedAt time.Time
2487 lastActive time.Time
2488 reasoning string // bounded ≤ subagentPreviewMax, UTF-8-safe tail
2489 text string
2490 notice string
2491 truncated bool
2492 durationMs int64
2493 terminal bool
2494 lastPrintedPhase string // native-scrollback dedupe
2495 verboseLastPrint time.Time
2496 }
2497
2498 func subagentPhaseTerminal(phase string) bool {
2499 switch phase {
2500 case "completed", "failed", "cancelled":
2501 return true
2502 }
2503 return false
2504 }
2505
2506 // cliPreviewTail keeps the most recent maxBytes of s at a rune boundary.
2507 func cliPreviewTail(s string, maxBytes int) string {
2508 if len(s) <= maxBytes {
2509 return s
2510 }
2511 s = s[len(s)-maxBytes:]
2512 for len(s) > 0 && !utf8.RuneStart(s[0]) {
2513 s = s[1:]
2514 }
2515 return s
2516 }
2517
2518 // streamSubagentProgress routes reserved ToolProgress channels into per-child
2519 // progress state instead of the single live tool stream: each child keeps its
2520 // own phase, elapsed, recent activity, and (verbose-only) preview tails.
2521 func (m *chatTUI) streamSubagentProgress(t event.Tool) {
2522 if t.ID == "" {
2523 return
2524 }
2525 sp := m.subagentProgress[t.ID]
2526 if sp == nil {
2527 sp = &cliSubagentProgress{startedAt: time.Now()}
2528 m.subagentProgress[t.ID] = sp
2529 }
2530 sp.lastActive = time.Now()
2531 switch t.Name {
2532 case event.SubagentProgressStatusName:
2533 sp.phase = t.Output
2534 if t.DurationMs > 0 {
2535 sp.durationMs = t.DurationMs
2536 }
2537 sp.terminal = subagentPhaseTerminal(t.Output)
2538 m.renderSubagentProgress(t.ID)
2539 case event.SubagentProgressReasoningName:
2540 sp.reasoning = cliPreviewTail(sp.reasoning+t.Output, subagentPreviewMax)
2541 sp.truncated = sp.truncated || t.Truncated
2542 if m.showReasoning {
2543 m.renderSubagentProgress(t.ID)
2544 }
2545 case event.SubagentProgressTextName:
2546 sp.text = cliPreviewTail(sp.text+t.Output, subagentPreviewMax)
2547 sp.truncated = sp.truncated || t.Truncated
2548 if m.showReasoning {
2549 m.renderSubagentProgress(t.ID)
2550 }
2551 case event.SubagentProgressNoticeName:
2552 sp.notice = cliPreviewTail(sp.notice+t.Output, subagentNoticeMax)
2553 sp.truncated = sp.truncated || t.Truncated
2554 if m.showReasoning {
2555 m.renderSubagentProgress(t.ID)
2556 }
2557 }
2558 }
2559
2560 // renderSubagentProgress redraws a child's progress block. Alt-screen TUIs
2561 // rewrite the fixed transcript slot in place (created on first sight under the
2562 // current transcript end); native-scrollback terminals print a status line
2563 // only on phase changes and terminal, since printed output cannot be rewritten.
2564 func (m *chatTUI) renderSubagentProgress(id string) {
2565 sp := m.subagentProgress[id]
2566 if sp == nil || sp.phase == "" {
2567 return
2568 }
2569 if m.nativeScrollback {
2570 m.printSubagentProgressScrollback(id, sp)
2571 return
2572 }
2573 idx, ok := m.subagentProgressIdx[id]
2574 if !ok {
2575 idx = len(m.transcript)
2576 m.subagentProgressIdx[id] = idx
2577 m.commitLine(m.subagentProgressBlock(id, sp))
2578 return
2579 }
2580 m.setTranscriptBlock(idx, m.subagentProgressBlock(id, sp), transcriptSource{kind: transcriptSourceSubagentProgress, raw: id})
2581 }
2582
2583 // tickSubagentProgress refreshes the elapsed / recent-activity fields of live
2584 // progress blocks once a second (mirrors tickToolRunning), so a child that
2585 // produces no events still reads as alive.
2586 func (m *chatTUI) tickSubagentProgress() {
2587 if m.nativeScrollback {
2588 return
2589 }
2590 for id, sp := range m.subagentProgress {
2591 if sp.terminal || sp.phase == "" {
2592 continue
2593 }
2594 idx, ok := m.subagentProgressIdx[id]
2595 if !ok {
2596 continue
2597 }
2598 m.setTranscriptBlock(idx, m.subagentProgressBlock(id, sp), transcriptSource{kind: transcriptSourceSubagentProgress, raw: id})
2599 }
2600 }
2601
2602 // subagentProgressBlock renders one child's progress block. The default line
2603 // shows phase, running elapsed, and recent activity; verbose mode adds the
2604 // bounded reasoning/text/notice tails above it. Terminal children collapse to
2605 // a one-line summary (the preview survives in memory for verbose re-render).
2606 func (m *chatTUI) subagentProgressBlock(id string, sp *cliSubagentProgress) string {
2607 var lines []string
2608 if m.showReasoning {
2609 if sp.reasoning != "" {
2610 lines = append(lines, subagentPreviewBlock(i18n.M.ChatSubagentPreviewLabel, sp.reasoning, m.width, subagentPreviewTailLines))
2611 }
2612 if sp.text != "" {
2613 lines = append(lines, subagentPreviewBlock("✎", sp.text, m.width, subagentPreviewTailLines))
2614 }
2615 if sp.notice != "" {
2616 lines = append(lines, subagentPreviewBlock("!", sp.notice, m.width, subagentPreviewTailLines))
2617 }
2618 if sp.truncated {
2619 lines = append(lines, dim("… preview truncated"))
2620 }
2621 }
2622 label := subagentPhaseLabel(sp.phase)
2623 switch sp.phase {
2624 case "completed":
2625 label = green(label + " ✓")
2626 case "failed":
2627 label = red(label + " ✗")
2628 case "cancelled":
2629 label = dim(label + " ⊘")
2630 case "retrying":
2631 label = yellow(label)
2632 }
2633 if sp.terminal {
2634 secs := sp.durationMs / 1000
2635 if secs <= 0 && !sp.startedAt.IsZero() {
2636 secs = int64(time.Since(sp.startedAt).Seconds())
2637 }
2638 lines = append(lines, fmt.Sprintf(i18n.M.ChatSubagentProgressDoneFmt, label, secs))
2639 } else {
2640 elapsed := int64(0)
2641 idle := int64(0)
2642 if !sp.startedAt.IsZero() {
2643 elapsed = int64(time.Since(sp.startedAt).Seconds())
2644 }
2645 if !sp.lastActive.IsZero() {
2646 idle = int64(time.Since(sp.lastActive).Seconds())
2647 }
2648 lines = append(lines, fmt.Sprintf(i18n.M.ChatSubagentProgressFmt, label, elapsed, idle))
2649 }
2650 return connectorBlock(lines)
2651 }
2652
2653 // subagentPreviewBlock renders a bounded trailing window of a preview channel
2654 // as dim, width-wrapped lines carrying a small glyph marker.
2655 func subagentPreviewBlock(glyph, raw string, width, maxLines int) string {
2656 w := max(width-len([]rune(connector)), 8)
2657 var lines []string
2658 first := true
2659 for ln := range strings.SplitSeq(strings.TrimRight(raw, "\n"), "\n") {
2660 if first {
2661 ln = glyph + " " + ln
2662 first = false
2663 }
2664 for wl := range strings.SplitSeq(ansi.Wrap(expandTabs(ln), w, ""), "\n") {
2665 lines = append(lines, dim(wl))
2666 }
2667 }
2668 if maxLines > 0 && len(lines) > maxLines {
2669 lines = lines[len(lines)-maxLines:]
2670 }
2671 return strings.Join(lines, "\n")
2672 }
2673
2674 // subagentPhaseLabel maps a reserved status value to its localized label.
2675 func subagentPhaseLabel(phase string) string {
2676 switch phase {
2677 case "queued":
2678 return i18n.M.ChatSubagentPhaseQueued
2679 case "running":
2680 return i18n.M.ChatSubagentPhaseRunning
2681 case "reasoning":
2682 return i18n.M.ChatSubagentPhaseReasoning
2683 case "responding":
2684 return i18n.M.ChatSubagentPhaseResponding
2685 case "tool":
2686 return i18n.M.ChatSubagentPhaseTool
2687 case "retrying":
2688 return i18n.M.ChatSubagentPhaseRetrying
2689 case "completed":
2690 return i18n.M.ChatSubagentPhaseCompleted
2691 case "failed":
2692 return i18n.M.ChatSubagentPhaseFailed
2693 case "cancelled":
2694 return i18n.M.ChatSubagentPhaseCancelled
2695 }
2696 return phase
2697 }
2698
2699 // printSubagentProgressScrollback queues a status line for native-scrollback
2700 // terminals, which cannot rewrite printed output (finalized blocks drain via
2701 // pendingCommit like every other scrollback commit): status lines appear on
2702 // phase changes and terminal only, and verbose previews are throttled to at
2703 // most one print per 2s per child.
2704 func (m *chatTUI) printSubagentProgressScrollback(id string, sp *cliSubagentProgress) {
2705 if m.pendingCommit == nil {
2706 return
2707 }
2708 block := m.subagentProgressBlock(id, sp)
2709 if sp.terminal {
2710 *m.pendingCommit = append(*m.pendingCommit, block)
2711 sp.lastPrintedPhase = sp.phase
2712 sp.verboseLastPrint = time.Now()
2713 return
2714 }
2715 if sp.phase != sp.lastPrintedPhase {
2716 *m.pendingCommit = append(*m.pendingCommit, block)
2717 sp.lastPrintedPhase = sp.phase
2718 sp.verboseLastPrint = time.Now()
2719 return
2720 }
2721 if m.showReasoning && time.Since(sp.verboseLastPrint) >= 2*time.Second && (sp.reasoning != "" || sp.text != "" || sp.notice != "") {
2722 *m.pendingCommit = append(*m.pendingCommit, block)
2723 sp.verboseLastPrint = time.Now()
2724 }
2725 }
2726
2727 // collapseToolOutput replaces a finished tool's live block with a dim
2728 // "⎿ N lines" summary, so the scrollback keeps a marker of the run without the
2729 // full output (which the model already received). For shell commands ("shell-"
2730 // prefix), it shows the first shellPreviewLines with a Ctrl+B hint instead.
2731 // No-op when id isn't streaming. resultOutput (the ToolResult's final output)
2732 // is the last-resort line-count source when the live state was already reset.
2733 func (m *chatTUI) collapseToolOutput(id, resultOutput string) {
2734 if m.nativeScrollback {
2735 if id == "" || m.toolStreamID != id {
2736 return
2737 }
2738 n := m.toolLineCount
2739 if m.toolPartial != "" {
2740 n++
2741 }
2742 if n > 0 {
2743 if full, ok := m.shellOutputs[id]; ok {
2744 lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
2745 total := len(lines)
2746 if total > shellPreviewLines {
2747 preview := make([]string, shellPreviewLines+1)
2748 for i := range shellPreviewLines {
2749 preview[i] = dim(clampPlain(lines[i], m.width-len([]rune(connector))))
2750 }
2751 preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
2752 m.commitConnectorBlock(preview)
2753 } else {
2754 rendered := make([]string, total)
2755 for i, ln := range lines {
2756 rendered[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
2757 }
2758 m.commitConnectorBlock(rendered)
2759 }
2760 m.shellTranscriptIdx[id] = len(m.transcript) - 1
2761 } else {
2762 m.commitConnectorBlock([]string{dim(fmt.Sprintf("%d lines", n))})
2763 }
2764 }
2765 m.toolStreamIdx = -1
2766 m.toolStreamID = ""
2767 m.toolTail = m.toolTail[:0]
2768 m.toolPartial = ""
2769 m.toolLineCount = 0
2770 return
2771 }
2772 if m.toolStreamIdx < 0 || id == "" || m.toolStreamID != id {
2773 // Slot no longer active (another tool took over, or this id never
2774 // streamed). If beginToolRunning recorded a transcript index, collapse
2775 // in place so a late ToolResult doesn't leave raw streamed text behind.
2776 if idx, ok := m.shellTranscriptIdx[id]; ok && idx >= 0 && idx < len(m.transcript) {
2777 m.collapseShellSlot(id, idx, resultOutput)
2778 }
2779 return
2780 }
2781 m.collapseShellSlot(id, m.toolStreamIdx, resultOutput)
2782 m.toolStreamIdx = -1
2783 m.toolStreamID = ""
2784 m.toolTail = m.toolTail[:0]
2785 m.toolPartial = ""
2786 m.toolLineCount = 0
2787 }
2788
2789 // collapseShellSlot finalises a tool's live block at idx. Used both by the
2790 // active-tool path (idx == toolStreamIdx, streaming state intact) and the
2791 // late-result path (idx recorded in shellTranscriptIdx at dispatch). Line-count
2792 // sources, in order: live streaming state, shellOutputs ("shell-" ids only),
2793 // the per-id count stashed by streamToolOutput, then the ToolResult's output.
2794 func (m *chatTUI) collapseShellSlot(id string, idx int, resultOutput string) {
2795 m.transcriptDirty = true
2796 n := -1
2797 if id == m.toolStreamID {
2798 // Prefer the larger of the live count and resultOutput: resultOutput
2799 // is the authoritative end-state, the live state may lag behind it.
2800 n = m.toolLineCount
2801 if m.toolPartial != "" {
2802 n++
2803 }
2804 if resultOutput != "" {
2805 fromResult := len(strings.Split(strings.TrimRight(resultOutput, "\n"), "\n"))
2806 if fromResult > n {
2807 n = fromResult
2808 }
2809 }
2810 }
2811 if n < 0 {
2812 if full, ok := m.shellOutputs[id]; ok {
2813 n = len(strings.Split(strings.TrimRight(full, "\n"), "\n"))
2814 } else if c, ok := m.toolLineCountByID[id]; ok {
2815 n = c
2816 } else if resultOutput != "" {
2817 n = len(strings.Split(strings.TrimRight(resultOutput, "\n"), "\n"))
2818 }
2819 }
2820 if n < 0 {
2821 // Nothing applies (e.g. a late result for a non-"shell-" id that never
2822 // streamed): treat as zero rather than fabricate a "-1 lines" count.
2823 n = 0
2824 }
2825 if n == 0 {
2826 // Tool finished with no output: clear the "working…" placeholder but
2827 // keep the slot (shellTranscriptIdx still points here for late progress).
2828 m.rewriteConnectorBlock(idx, nil)
2829 return
2830 }
2831 if full, ok := m.shellOutputs[id]; ok {
2832 // Shell command: show first N lines + hint.
2833 lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
2834 total := len(lines)
2835 if total > shellPreviewLines {
2836 preview := make([]string, shellPreviewLines+1)
2837 for i := range shellPreviewLines {
2838 preview[i] = dim(clampPlain(lines[i], m.width-len([]rune(connector))))
2839 }
2840 preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
2841 m.rewriteConnectorBlock(idx, preview)
2842 } else {
2843 rendered := make([]string, total)
2844 for i, ln := range lines {
2845 rendered[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
2846 }
2847 m.rewriteConnectorBlock(idx, rendered)
2848 }
2849 } else {
2850 m.rewriteConnectorBlock(idx, []string{dim(fmt.Sprintf("%d lines", n))})
2851 }
2852 m.shellTranscriptIdx[id] = idx
2853 }
2854
2855 // toggleShellOutput expands or collapses the output of the most recent shell
2856 // command. When expanded, up to shellExpandMaxLines lines are shown; when
2857 // collapsed, only the first shellPreviewLines are shown. Called on Ctrl+B.
2858 func (m *chatTUI) toggleShellOutput() {
2859 // Find the most recent shell output that has a transcript entry.
2860 var lastID string
2861 lastIdx := -1
2862 for id, idx := range m.shellTranscriptIdx {
2863 if idx >= 0 && idx < len(m.transcript) && idx > lastIdx {
2864 lastID = id
2865 lastIdx = idx
2866 }
2867 }
2868 if lastID == "" {
2869 return
2870 }
2871 full, ok := m.shellOutputs[lastID]
2872 if !ok {
2873 return
2874 }
2875 lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
2876 total := len(lines)
2877 innerW := m.width - len([]rune(connector))
2878 if innerW < 10 {
2879 innerW = 80
2880 }
2881
2882 if m.shellExpanded[lastID] {
2883 // Collapse back to preview.
2884 m.shellExpanded[lastID] = false
2885 if total > shellPreviewLines {
2886 preview := make([]string, shellPreviewLines+1)
2887 for i := range shellPreviewLines {
2888 preview[i] = dim(clampPlain(lines[i], innerW))
2889 }
2890 preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
2891 m.rewriteConnectorBlock(lastIdx, preview)
2892 }
2893 } else {
2894 // Expand: show up to shellExpandMaxLines lines.
2895 m.shellExpanded[lastID] = true
2896 show := min(total, shellExpandMaxLines)
2897 rendered := make([]string, show)
2898 for i := range show {
2899 rendered[i] = dim(clampPlain(lines[i], innerW))
2900 }
2901 if total > shellExpandMaxLines {
2902 rendered = append(rendered, dim(fmt.Sprintf("… %d more lines", total-shellExpandMaxLines)))
2903 }
2904 m.rewriteConnectorBlock(lastIdx, rendered)
2905 }
2906 if m.nativeScrollback {
2907 m.commitLine(m.transcript[lastIdx])
2908 }
2909 }
2910
2911 // toolWorkingFrames is the braille spinner cycled once per second on the
2912 // "⎿ working · Ns" line of a tool that hasn't streamed output yet.
2913 var toolWorkingFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
2914
2915 // beginToolRunning opens an empty live block under a just-dispatched tool card,
2916 // keyed by the call id. tickToolRunning fills it with a "working · Ns" line each
2917 // second; if the tool later streams output, streamToolOutput reuses the same
2918 // block; collapseToolOutput closes it on the result.
2919 func (m *chatTUI) beginToolRunning(id string) {
2920 if id == "" {
2921 return
2922 }
2923 m.toolStreamID = id
2924 m.toolTail = m.toolTail[:0]
2925 m.toolPartial = ""
2926 m.toolLineCount = 0
2927 // Clear accumulated output for this tool ID so a re-run (e.g. repeated
2928 // !pwd with the same "shell-pwd" id) doesn't append to old output.
2929 delete(m.shellOutputs, id)
2930 m.toolStreamStart = time.Now()
2931 m.toolStreamFrame = 0
2932 if m.nativeScrollback {
2933 m.toolStreamIdx = -1
2934 return
2935 }
2936 m.toolStreamIdx = len(m.transcript)
2937 m.commitConnectorBlock([]string{dim(fmt.Sprintf(i18n.M.ChatToolWorkingFmt, toolWorkingFrames[0], 0))})
2938 // Remember the transcript slot for this id so a late ToolProgress for a
2939 // previously dispatched (and possibly already collapsed) tool can reuse
2940 // it instead of appending a fresh slot at the end of the transcript. For
2941 // back-to-back tool calls this keeps each tool's live block directly
2942 // under its own card.
2943 m.shellTranscriptIdx[id] = m.toolStreamIdx
2944 }
2945
2946 // tickToolRunning re-renders the working line of a tool that's dispatched but
2947 // hasn't produced output yet. A no-op once output streams in or no tool runs.
2948 func (m *chatTUI) tickToolRunning() {
2949 if m.nativeScrollback {
2950 return
2951 }
2952 if m.toolStreamIdx < 0 || m.toolLineCount != 0 || m.toolPartial != "" {
2953 return
2954 }
2955 m.toolStreamFrame++
2956 frame := toolWorkingFrames[m.toolStreamFrame%len(toolWorkingFrames)]
2957 secs := int(time.Since(m.toolStreamStart).Seconds())
2958 m.rewriteConnectorBlock(m.toolStreamIdx, []string{dim(fmt.Sprintf(i18n.M.ChatToolWorkingFmt, frame, secs))})
2959 }
2960
2961 // commitReasoning closes the live thinking block: the "▎ thinking…" marker is
2962 // rewritten to a dim "▎ thought for Ns" summary and the streamed text below it is
2963 // removed (collapsed) — kept only in verbose mode. The viewport re-wraps from
2964 // m.transcript, so the change is flagged via transcriptDirty.
2965 func (m *chatTUI) commitReasoning() {
2966 if m.reasoningNative {
2967 if strings.TrimSpace(m.reasoning.String()) != "" || !m.thinkStart.IsZero() {
2968 secs := int(time.Since(m.thinkStart).Seconds())
2969 m.commitSpacer()
2970 m.commitLine(dim(fmt.Sprintf(" ▎ "+i18n.M.ChatThoughtForFmt, secs)))
2971 if m.showReasoning && strings.TrimSpace(m.reasoning.String()) != "" {
2972 m.commitLine(reasoningBlock(m.reasoning.String(), m.width, 0))
2973 }
2974 }
2975 m.reasoning.Reset()
2976 m.reasoningView = m.reasoningView[:0]
2977 m.reasoningNative = false
2978 m.thinkStart = time.Time{}
2979 return
2980 }
2981 if m.reasoningLineIdx < 0 {
2982 return
2983 }
2984 secs := int(time.Since(m.thinkStart).Seconds())
2985 m.setTranscriptBlock(m.reasoningLineIdx, dim(fmt.Sprintf(" ▎ "+i18n.M.ChatThoughtForFmt, secs)), transcriptSource{kind: transcriptSourceFixed})
2986 if m.reasoningTextIdx >= 0 {
2987 if m.showReasoning && strings.TrimSpace(m.reasoning.String()) != "" {
2988 raw := m.reasoning.String()
2989 m.setTranscriptBlock(m.reasoningTextIdx, reasoningBlock(raw, m.width, 0), transcriptSource{
2990 kind: transcriptSourceReasoning, raw: raw,
2991 })
2992 } else {
2993 m.removeTranscriptBlock(m.reasoningTextIdx)
2994 }
2995 }
2996 m.transcriptDirty = true
2997 m.reasoning.Reset()
2998 m.reasoningView = m.reasoningView[:0]
2999 m.reasoningLineIdx = -1
3000 m.reasoningTextIdx = -1
3001 }
3002
3003 // commitReasoningBeforeAnswer closes a real reasoning block and leaves exactly
3004 // one blank transcript row before the assistant answer. Answers that start
3005 // without reasoning keep their existing compact placement.
3006 func (m *chatTUI) commitReasoningBeforeAnswer() {
3007 hadReasoning := m.reasoningNative || m.reasoningLineIdx >= 0
3008 m.commitReasoning()
3009 if hadReasoning {
3010 m.commitSpacer()
3011 }
3012 }
3013
3014 // streamAnswer renders the answer streamed so far up to its last completed
3015 // paragraph (flushableMarkdownPrefix) and writes it as one transcript block,
3016 // rewritten in place as later paragraphs land — so a long reply appears chunk by
3017 // chunk instead of all at once on turn end. The trailing, still-streaming block
3018 // stays buffered (a half-written fence/list never renders early), and it only
3019 // re-renders when a new paragraph actually closes.
3020 func (m *chatTUI) streamAnswer() {
3021 if m.nativeScrollback {
3022 return
3023 }
3024 prefix := flushableMarkdownPrefix(m.pending.String())
3025 if len(prefix) <= m.answerFlushed {
3026 return
3027 }
3028 source := transcriptSource{kind: transcriptSourceMarkdown, raw: prefix}
3029 m.answerFlushed = len(prefix)
3030 if m.answerIdx < 0 {
3031 m.answerIdx = len(m.transcript)
3032 m.commitTranscriptSource(source)
3033 } else {
3034 // setTranscriptBlock invalidates the wrap suffix from answerIdx so the
3035 // next Update only re-wraps the live answer block — not the full history.
3036 block := m.renderTranscriptSource(source, m.width)
3037 m.setTranscriptBlock(m.answerIdx, block, source)
3038 }
3039 }
3040
3041 // commitPending freezes the full accumulated answer as markdown — overwriting the
3042 // streamed block if one is open (streamAnswer), else committing fresh. Joining
3043 // commitReasoning then commitPending puts the answer on its own line, restoring
3044 // the thinking→answer break the renderer strips.
3045 func (m *chatTUI) commitPending() {
3046 if m.pending.Len() == 0 {
3047 m.answerIdx = -1
3048 m.answerFlushed = 0
3049 return
3050 }
3051 raw := m.pending.String()
3052 source := transcriptSource{kind: transcriptSourceMarkdown, raw: raw}
3053 if m.answerIdx < 0 {
3054 m.commitTranscriptSource(source)
3055 } else {
3056 block := m.renderTranscriptSource(source, m.width)
3057 m.setTranscriptBlock(m.answerIdx, block, source)
3058 }
3059 m.pending.Reset()
3060 m.answerIdx = -1
3061 m.answerFlushed = 0
3062 }
3063
3064 // flushableMarkdownPrefix returns the longest prefix of buf made of complete
3065 // markdown blocks — text up to the last blank line outside any open fenced code
3066 // block. A blank line inside a ``` / ~~~ fence isn't a boundary, so a half-written
3067 // code block stays buffered until it closes.
3068 func flushableMarkdownPrefix(buf string) string {
3069 lines := strings.Split(buf, "\n")
3070 inFence := false
3071 boundary := -1
3072 for i, ln := range lines {
3073 t := strings.TrimSpace(ln)
3074 if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
3075 inFence = !inFence
3076 continue
3077 }
3078 if !inFence && t == "" {
3079 boundary = i
3080 }
3081 }
3082 if boundary <= 0 {
3083 return ""
3084 }
3085 return strings.Join(lines[:boundary], "\n")
3086 }
3087
3088 // planApprovalTool is the Tool name the controller puts on the ApprovalRequest it
3089 // emits to gate a plan (mirrors control's constant). The banner, status line, and
3090 // approval handler key on it to render the plan-specific prompt and to keep the
3091 // [plan] tag in sync when the user starts execution or exits without executing.
3092 const planApprovalTool = "exit_plan_mode"
3093
3094 // handleApprovalKey resolves a pending approval from a keystroke and re-arms the
3095 // listener. 1/y/Enter allows once and 2/a allows the exact scope for the rest
3096 // of the session. Fresh two-choice prompts use 2 for deny, while n/Esc and
3097 // legacy 4 still deny. Plan prompts use 1 to execute, 2/n/Esc to keep planning, and 3 to
3098 // reject the pending plan and leave plan mode without executing it.
3099 // Ctrl-C cancels the whole turn via the run context. For a plan approval
3100 // (planApprovalTool), starting execution or explicitly exiting without execution
3101 // drops the local [plan] tag and turns plan mode off on the controller.
3102 func (m chatTUI) handleApprovalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
3103 if isRecoveryApprovalEvent(m.pendingApproval) {
3104 // Historical recovery requests are display-only. Escape and n dismiss the
3105 // compatibility record locally; no recovery RPC or tool replay is issued.
3106 if msg.String() == "esc" || strings.EqualFold(msg.String(), "n") {
3107 m.pendingApproval = nil
3108 }
3109 return m, nil
3110 }
3111 choices := approvalChoices(m.pendingApproval)
3112 answer := func(choice approvalChoice) (tea.Model, tea.Cmd) {
3113 allow, session := choice.allow, choice.allowForSession
3114 if m.pendingApproval.Tool == planApprovalTool && (allow || choice.exitPlan) {
3115 m.planMode = false
3116 m.ctrl.SetPlanMode(false)
3117 }
3118 m.ctrl.Approve(m.pendingApproval.ID, allow, session, false)
3119 m.pendingApproval = nil
3120 return m, nil
3121 }
3122 switch msg.String() {
3123 case "ctrl+c":
3124 m.ctrl.Cancel()
3125 return answer(approvalChoice{})
3126 case "up", "k", "ctrl+p":
3127 if m.approvalSelection < 0 && len(choices) > 0 {
3128 m.approvalSelection = 0
3129 } else if m.approvalSelection > 0 {
3130 m.approvalSelection--
3131 }
3132 return m, nil
3133 case "down", "j", "ctrl+n":
3134 if m.approvalSelection < len(choices)-1 {
3135 m.approvalSelection++
3136 }
3137 return m, nil
3138 case "enter":
3139 if m.approvalSelection >= 0 && m.approvalSelection < len(choices) {
3140 return answer(choices[m.approvalSelection])
3141 }
3142 return m, nil
3143 case "esc":
3144 return answer(approvalChoice{})
3145 }
3146 lower := strings.ToLower(msg.String())
3147 if len(lower) == 1 && lower[0] >= '1' && lower[0] <= '9' {
3148 idx := int(lower[0] - '1')
3149 if idx < len(choices) {
3150 return answer(choices[idx])
3151 }
3152 // Legacy muscle memory: tool approvals historically numbered deny as 4.
3153 // Honor 4 as deny even when the current prompt shows fewer rows, matching
3154 // the "legacy 4 still deny" contract in this function's doc comment.
3155 if lower == "4" {
3156 return answer(approvalChoice{})
3157 }
3158 return m, nil
3159 }
3160 switch lower {
3161 case "y":
3162 if len(choices) > 0 {
3163 return answer(choices[0])
3164 }
3165 case "a":
3166 for _, choice := range choices {
3167 if choice.allowForSession {
3168 return answer(choice)
3169 }
3170 }
3171 case "n":
3172 return answer(approvalChoice{})
3173 }
3174 return m, nil
3175 }
3176
3177 func isRecoveryApprovalEvent(a *event.Approval) bool {
3178 return a != nil && (a.Kind == recovery.ApprovalKindRecovery || a.Recovery != nil)
3179 }
3180
3181 func isRecoveryPlanChangeApproval(a *event.Approval) bool {
3182 if !isRecoveryApprovalEvent(a) || a.Recovery == nil {
3183 return false
3184 }
3185 switch strings.ToLower(strings.TrimSpace(a.Recovery.ChangeKind)) {
3186 case string(recovery.ChangeStrategy), string(recovery.ChangeScope):
3187 return true
3188 default:
3189 return false
3190 }
3191 }
3192
3193 func freshApprovalAllowsSession(toolName string) bool {
3194 return toolName == control.SandboxEscapeApprovalTool || toolName == control.ManagedConfigWriteApprovalTool
3195 }
3196
3197 var (
3198 // Input box: only top + bottom borders, no sides. The concrete colors are
3199 // refreshed from the active CLI theme during startup.
3200 inputBoxStyle lipgloss.Style
3201 todoPanelStyle lipgloss.Style
3202 statusBlockStyle lipgloss.Style
3203 workingStyle lipgloss.Style
3204 )
3205
3206 func (m chatTUI) cancelRequested() bool {
3207 if m.state != tuiRunning || m.ctrl == nil {
3208 return false
3209 }
3210 return m.ctrl.CancelRequested()
3211 }
3212
3213 func (m chatTUI) runningWorkingLine(cancelRequested, styled bool) string {
3214 if m.state != tuiRunning {
3215 return ""
3216 }
3217 if m.retryAttempt > 0 && !cancelRequested {
3218 if line, ok := m.waitingRecoveryLine(); ok {
3219 return line
3220 }
3221
3222 return fmt.Sprintf(" "+i18n.M.ChatStatusRetryingFmt, m.spinner.View(), m.retryAttempt, m.retryMax)
3223 }
3224
3225 var working string
3226 if cancelRequested {
3227 working = fmt.Sprintf(" "+i18n.M.ChatStatusCancellingFmt, m.spinner.View(), m.elapsed)
3228 } else {
3229 phaseLabel := m.readStatusLabel
3230 if phaseLabel == "" {
3231 phaseLabel = turnPhaseStatusLabel(m.turnPhase)
3232 }
3233 if phaseLabel != "" {
3234 working = fmt.Sprintf(" %s %s · %ds", m.spinner.View(), phaseLabel, m.elapsed)
3235 } else {
3236 working = fmt.Sprintf(" "+i18n.M.ChatStatusThinkingFmt, m.spinner.View(), m.elapsed)
3237 }
3238 }
3239 if m.turnTokens > 0 {
3240 working += " · ↓" + shortTokens(m.turnTokens)
3241 }
3242 if n := m.inboxQueuedCount(); n > 0 {
3243 var queued string
3244 if n == 1 {
3245 queued = " · ✎ 1 in inbox"
3246 } else {
3247 queued = fmt.Sprintf(" · ✎ %d in inbox", n)
3248 }
3249 if m.inboxSnap().Paused {
3250 queued += " (paused)"
3251 }
3252 if styled {
3253 working += dim(queued)
3254 } else {
3255 working += queued
3256 }
3257 }
3258 return working
3259 }
3260
3261 func (m chatTUI) View() tea.View {
3262 if m.themeSweep != nil {
3263 v := tea.NewView(m.themeSweep.render())
3264 if !m.nativeScrollback {
3265 v.AltScreen = true
3266 if m.mouseCaptureOff {
3267 v.MouseMode = tea.MouseModeNone
3268 } else {
3269 v.MouseMode = tea.MouseModeCellMotion
3270 }
3271 }
3272 return v
3273 }
3274 boxW := max(m.width, 10)
3275 hideComposer := m.hideComposer()
3276 shellMode := strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!")
3277 cancelRequested := m.cancelRequested()
3278 var box string
3279 if !hideComposer {
3280 style := inputBoxStyle.Width(boxW)
3281 if shellMode {
3282 style = withThemeBorderFG(style, statusShellColor)
3283 }
3284 box = style.Render(m.renderComposerInput())
3285 }
3286
3287 var modeTag string
3288 if shellMode {
3289 modeTag = modeTagStyle(statusShellColor, modeTagLight).Render("Shell")
3290 } else {
3291 background := statusAutoColor
3292 foreground := modeTagDark
3293 switch {
3294 case m.ctrl.AutoApproveTools():
3295 background = statusYoloColor
3296 foreground = modeTagLight
3297 case m.planMode:
3298 background = statusPlanColor
3299 foreground = modeTagLight
3300 }
3301 modeTag = modeTagStyle(background, foreground).Render(m.modeTagText())
3302 }
3303
3304 primaryStatus := m.primaryStatusLine(modeTag, shellMode, cancelRequested)
3305 // The spinning "thinking…" indicator is its own line ABOVE the input box (shown
3306 // only while a turn runs); the status/data rows stay below. This mirrors Claude
3307 // Code: live progress over the composer, shortcuts + stats under it.
3308 working := m.runningWorkingLine(cancelRequested, true)
3309 // Bottom region pinned under the transcript viewport: optional panels, the
3310 // composer when visible, then the two status rows. Its height feeds
3311 // transcriptHeight so the viewport above fills exactly the rest of the screen.
3312 var parts []string
3313 rowsAboveBox := 0 // terminal rows occupied by panels/working line before the composer
3314 if todo := m.renderTodoPanel(); todo != "" {
3315 parts = append(parts, todo)
3316 rowsAboveBox += strings.Count(todo, "\n") + 1
3317 }
3318 if banner := m.renderApprovalBanner(); banner != "" {
3319 parts = append(parts, banner)
3320 rowsAboveBox += strings.Count(banner, "\n") + 1
3321 }
3322 if card := m.renderChooser(); card != "" {
3323 parts = append(parts, card)
3324 rowsAboveBox += strings.Count(card, "\n") + 1
3325 }
3326 if card := m.renderElicit(); card != "" {
3327 parts = append(parts, card)
3328 rowsAboveBox += strings.Count(card, "\n") + 1
3329 }
3330 if card := m.renderRewind(); card != "" {
3331 parts = append(parts, card)
3332 rowsAboveBox += strings.Count(card, "\n") + 1
3333 }
3334 if card := m.renderMCPImport(); card != "" {
3335 parts = append(parts, card)
3336 rowsAboveBox += strings.Count(card, "\n") + 1
3337 }
3338 if card := m.renderResumePicker(); card != "" {
3339 parts = append(parts, card)
3340 rowsAboveBox += strings.Count(card, "\n") + 1
3341 }
3342 if card := m.renderQuickPicker(); card != "" {
3343 parts = append(parts, card)
3344 rowsAboveBox += strings.Count(card, "\n") + 1
3345 }
3346 if card := m.renderConnectionSetup(); card != "" {
3347 parts = append(parts, card)
3348 rowsAboveBox += strings.Count(card, "\n") + 1
3349 }
3350 if card := m.renderCopyPicker(); card != "" {
3351 parts = append(parts, card)
3352 rowsAboveBox += strings.Count(card, "\n") + 1
3353 }
3354 if menu := m.renderCompletion(); menu != "" {
3355 parts = append(parts, menu)
3356 rowsAboveBox += strings.Count(menu, "\n") + 1
3357 }
3358 if m.nativeScrollback {
3359 if card := m.renderMainManager(); card != "" {
3360 parts = append(parts, card)
3361 rowsAboveBox += strings.Count(card, "\n") + 1
3362 }
3363 }
3364 // Layout: the working spinner (when running), then the composer when visible,
3365 // then the persistent status block. Wide terminals keep two information rows
3366 // separated by a quiet rule: interaction + model/profile, then flexible Git
3367 // + fixed telemetry. Narrow
3368 // terminals break only between those semantic groups. Padding to full width
3369 // prevents stale cells.
3370 if working != "" {
3371 parts = append(parts, workingStyle.Width(boxW).MaxWidth(boxW).Render(wrapStatusLine(working, boxW)))
3372 rowsAboveBox++
3373 }
3374 if footer := m.renderMainManagerFooter(); footer != "" {
3375 parts = append(parts, footer)
3376 rowsAboveBox += strings.Count(footer, "\n") + 1
3377 }
3378 statusBlock := m.renderStatusBlock(primaryStatus, boxW)
3379 if !hideComposer {
3380 if qi := m.renderQueueIndicator(); qi != "" {
3381 parts = append(parts, qi)
3382 rowsAboveBox += strings.Count(qi, "\n") + 1
3383 }
3384 parts = append(parts, box)
3385 }
3386 parts = append(parts, statusBlockStyle.Width(boxW).MaxWidth(boxW).Render(statusBlock))
3387
3388 if m.nativeScrollback {
3389 v := tea.NewView(strings.Join(parts, "\n"))
3390 if !hideComposer {
3391 if cur := m.composerCursor(); cur != nil {
3392 cur.X += 1
3393 cur.Y += rowsAboveBox + 1
3394 v.Cursor = clampCursorToTerminal(cur, m.width, m.height)
3395 }
3396 }
3397 return v
3398 }
3399
3400 // Full-screen frame: the transcript viewport on top (it pads to exactly its
3401 // height), the pinned bottom region beneath. Alt-screen owns the grid, so
3402 // resize repaints cleanly — no scrollback reflow, no ghost borders.
3403 mainArea := m.renderTranscript()
3404 if card := m.renderMainManager(); card != "" {
3405 mainArea = m.renderTranscriptWithMainManager(card)
3406 }
3407 v := tea.NewView(mainArea + "\n" + strings.Join(parts, "\n"))
3408 v.AltScreen = true
3409 if m.mouseCaptureOff {
3410 // Release the mouse to the terminal: native click-drag selection and
3411 // right-click context menu work again, at the cost of the in-app
3412 // scrollbar, wheel-scroll, and drag-select while it's off.
3413 v.MouseMode = tea.MouseModeNone
3414 } else {
3415 v.MouseMode = tea.MouseModeCellMotion // wheel targets the hovered scroll region; text selection is handled in-app
3416 }
3417 // Anchor the real terminal cursor at the textarea's insertion point only when
3418 // the composer is visible. input.Cursor() is relative to the textarea; offset
3419 // by the viewport height + rows above + the box's top border row (+1 column
3420 // for PaddingLeft). Clamp to terminal bounds so VS Code fullscreen / resize
3421 // storms cannot leave the caret off-grid (#6282, #7236).
3422 if !hideComposer {
3423 if cur := m.composerCursor(); cur != nil {
3424 cur.X += 1
3425 cur.Y += m.viewport.Height() + rowsAboveBox + 1
3426 v.Cursor = clampCursorToTerminal(cur, m.width, m.height)
3427 }
3428 }
3429 return v
3430 }
3431
3432 // clampCursorToTerminal keeps the reported caret inside [0,w) × [0,h).
3433 func clampCursorToTerminal(cur *tea.Cursor, width, height int) *tea.Cursor {
3434 if cur == nil {
3435 return nil
3436 }
3437 if width > 0 {
3438 if cur.X < 0 {
3439 cur.X = 0
3440 }
3441 if cur.X >= width {
3442 cur.X = width - 1
3443 }
3444 }
3445 if height > 0 {
3446 if cur.Y < 0 {
3447 cur.Y = 0
3448 }
3449 if cur.Y >= height {
3450 cur.Y = height - 1
3451 }
3452 }
3453 return cur
3454 }
3455
3456 // compactionCardLines renders a finished compaction as a titled card: a header
3457 // with the message count and trigger, then the structured summary under a dim
3458 // gutter so it reads as one block in scrollback. The summary is also the new
3459 // context base, so this card is the user's window into exactly what was kept.
3460 func compactionCardLines(c event.Compaction) []string {
3461 trigger := c.Trigger
3462 switch c.Trigger {
3463 case "auto":
3464 trigger = i18n.M.CompactionAuto
3465 case "manual":
3466 trigger = i18n.M.CompactionManual
3467 }
3468 header := fmt.Sprintf("%s · %d %s · %s", i18n.M.CompactionTitle, c.Messages, i18n.M.CompactionUnit, trigger)
3469 lines := []string{accent("◆ " + header)}
3470 for ln := range strings.SplitSeq(strings.TrimRight(c.Summary, "\n"), "\n") {
3471 lines = append(lines, dim(" │ "+ln))
3472 }
3473 if c.Archive != "" {
3474 lines = append(lines, dim(" │ archived "+c.Archive))
3475 }
3476 return lines
3477 }
3478
3479 // contextTag renders the prompt-vs-context-window gauge for the status line,
3480 // framed around the auto-compaction threshold: it shows how much headroom is
3481 // left until the next compaction, and colours by proximity to that point rather
3482 // than the raw window. Falls back to a plain percentage when compaction is disabled.
3483 func (m chatTUI) contextTag() string {
3484 used, window := m.ctrl.ContextSnapshot()
3485 if used == 0 || window == 0 {
3486 return ""
3487 }
3488 pct := used * 100 / window
3489 ratio := m.ctrl.CompactRatio()
3490 if ratio <= 0 || ratio >= 1 {
3491 // Compaction disabled: just the raw gauge, coloured on window fill.
3492 body := fmt.Sprintf("%s / %s ctx (%d%%)", shortTokens(used), shortTokens(window), pct)
3493 switch {
3494 case pct >= 85:
3495 return themeStyle(activeCLITheme.danger).Render(body)
3496 case pct >= 60:
3497 return themeStyle(activeCLITheme.warn).Render(body)
3498 default:
3499 return dim(body)
3500 }
3501 }
3502 threshold := int(ratio * 100)
3503 // Headroom to the compaction point, as a percentage of the window (clamped at 0).
3504 left := max(threshold-pct, 0)
3505 body := fmt.Sprintf("%s ctx (%d%%) · %d%% to compact", shortTokens(used), pct, left)
3506 switch {
3507 case pct >= threshold:
3508 return themeStyle(activeCLITheme.danger).Render(fmt.Sprintf("%s ctx (%d%%) · compacting soon", shortTokens(used), pct))
3509 case left <= 10:
3510 return themeStyle(activeCLITheme.warn).Render(body)
3511 default:
3512 return dim(body)
3513 }
3514 }
3515
3516 func cacheRateLabel(format string, hit, denom int) string {
3517 if denom <= 0 {
3518 return ""
3519 }
3520 return fmt.Sprintf(format, fmt.Sprintf("%.2f%%", float64(hit)*100/float64(denom)))
3521 }
3522
3523 // cacheTag renders both prompt cache-hit rates for the status line —
3524 // "turn hit 88.00% · avg 78.00%": the single-turn rate (latest turn, the higher/steeper
3525 // number on a non-compacting DeepSeek session) and the session-aggregate rate
3526 // Σhit/Σ(hit+miss) (the steadier, cost-oriented number that matches the legacy
3527 // dashboard). "" before any cache tokens have been reported.
3528 func (m chatTUI) cacheStatus() (body string, rate float64, ok bool) {
3529 now := ""
3530 nowRate := 0.0
3531 if u := m.ctrl.LastUsage(); u != nil {
3532 // Only render when the provider actually reports cache token fields:
3533 // falling back to PromptTokens as the denominator painted a bogus
3534 // "turn hit 0.00%" for providers with no prompt-cache support.
3535 now = cacheRateLabel(i18n.M.ChatStatusCacheNowFmt, u.CacheHitTokens, u.CacheHitTokens+u.CacheMissTokens)
3536 if denom := u.CacheHitTokens + u.CacheMissTokens; denom > 0 {
3537 nowRate = float64(u.CacheHitTokens) * 100 / float64(denom)
3538 }
3539 }
3540 avg := ""
3541 avgRate := 0.0
3542 if hit, miss := m.ctrl.SessionCache(); hit+miss > 0 {
3543 avg = cacheRateLabel(i18n.M.ChatStatusCacheAvgFmt, hit, hit+miss)
3544 avgRate = float64(hit) * 100 / float64(hit+miss)
3545 }
3546 switch {
3547 case now != "" && avg != "":
3548 return now + " · " + avg, avgRate, true
3549 case now != "":
3550 return now, nowRate, true
3551 case avg != "":
3552 return avg, avgRate, true
3553 }
3554 return "", 0, false
3555 }
3556
3557 func (m chatTUI) cacheTag() string {
3558 body, _, ok := m.cacheStatus()
3559 if !ok {
3560 return ""
3561 }
3562 return dim(body)
3563 }
3564
3565 // jobsTag shows the count of running background jobs in the status line. Job
3566 // start/finish emit Notices that arrive on eventCh and re-render the frame, so
3567 // the count stays current without a dedicated tick.
3568 func (m chatTUI) jobsTag() string {
3569 n := len(m.ctrl.Jobs())
3570 if n == 0 {
3571 return ""
3572 }
3573 return dim(fmt.Sprintf("⚙ %d", n))
3574 }
3575
3576 func (m chatTUI) effortTag() string {
3577 if m.effortLevel == "" {
3578 return ""
3579 }
3580 value := footerValue(m.effortLevel)
3581 if m.effortLevel != "auto" {
3582 value = themeStyle(activeCLITheme.info).Bold(true).Render(m.effortLevel)
3583 }
3584 return footerMetric(i18n.M.ChatStatusEffortLabel, value)
3585 }
3586
3587 // mouseTag is a persistent status-line marker while mouseCaptureOff is on, so
3588 // the loss of in-app scrollbar/wheel-scroll/drag-select reads as a deliberate
3589 // state rather than a bug the user has to guess at.
3590 func (m chatTUI) mouseTag() string {
3591 if !m.mouseCaptureOff {
3592 return ""
3593 }
3594 return dim(i18n.M.MouseCaptureTag)
3595 }
3596
3597 // shortTokens prints token counts compactly: 1_500 → "1.5K", 142_000 → "142.0K", 1_000_000 → "1.0M".
3598 func shortTokens(n int) string {
3599 switch {
3600 case n >= 999_950:
3601 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
3602 case n >= 1_000:
3603 return fmt.Sprintf("%.1fK", float64(n)/1_000)
3604 default:
3605 return fmt.Sprintf("%d", n)
3606 }
3607 }
3608
3609 // turnPhaseStatusLabel maps host turn_phase values to a short status label.
3610 // Empty when the phase is unknown so callers fall back to the default thinking line.
3611 func turnPhaseStatusLabel(phase string) string {
3612 switch strings.ToLower(strings.TrimSpace(phase)) {
3613 case "working":
3614 return i18n.M.TurnPhaseWorking
3615 case "checking":
3616 return i18n.M.TurnPhaseChecking
3617 case "verifying":
3618 return i18n.M.TurnPhaseVerifying
3619 case "reviewing":
3620 return i18n.M.TurnPhaseReviewing
3621 default:
3622 return ""
3623 }
3624 }
3625
3626 // formatCompletionSummaryLine renders a content-free quality summary for TUI scrollback.
3627 func formatCompletionSummaryLine(c *event.CompletionSummaryInfo) string {
3628 if c == nil {
3629 return ""
3630 }
3631 verdict := strings.TrimSpace(c.Verdict)
3632 if verdict == "" {
3633 verdict = "complete"
3634 }
3635 line := fmt.Sprintf("%s · mut=%d · checks %d✓/%d✗/%d⊘",
3636 verdict, c.Mutations, c.ChecksPassed, c.ChecksFailed, c.ChecksSuppressed)
3637 if c.Review != "" && c.Review != "none" {
3638 line += " · review=" + c.Review
3639 }
3640 if len(c.GapKinds) > 0 {
3641 line += " · gaps=" + strings.Join(c.GapKinds, ",")
3642 }
3643 if c.ConstraintDegraded {
3644 line += " · constraints"
3645 }
3646 return line
3647 }
3648
3649 func completionSummaryNeedsAttention(c *event.CompletionSummaryInfo, _ string) bool {
3650 if c == nil {
3651 return false
3652 }
3653 if strings.TrimSpace(c.Floor) != "" {
3654 return c.Attention
3655 }
3656 if strings.EqualFold(strings.TrimSpace(c.Verdict), "blocked") || c.ChecksFailed > 0 || c.ChecksSuppressed > 0 {
3657 return true
3658 }
3659 for _, gap := range c.GapKinds {
3660 switch strings.ToLower(strings.TrimSpace(gap)) {
3661 case "unbacked_claim", "failed_verification":
3662 return true
3663 }
3664 }
3665 return false
3666 }
3667
3668 func completionSummaryWarning(c *event.CompletionSummaryInfo) string {
3669 if c != nil && strings.EqualFold(strings.TrimSpace(c.Verdict), "blocked") {
3670 return i18n.M.CompletionSummaryBlocked
3671 }
3672 return i18n.M.CompletionSummaryNeedsAttention
3673 }
3674
3675 // renderApprovalBanner is the slim notice shown above the input while a tool
3676 // call (or a plan) awaits the user's decision.
3677 func (m chatTUI) renderApprovalBanner() string {
3678 w := max(m.width, 10)
3679 if m.pendingApproval == nil {
3680 return ""
3681 }
3682 if isRecoveryApprovalEvent(m.pendingApproval) {
3683 return choicePanelStyle.Width(w).Render("ℹ Historical recovery record (retired). It cannot confirm or replay an operation.\n" + dim("Esc/n dismiss"))
3684 }
3685 var text string
3686 var planDetails []string
3687 if m.pendingApproval.Tool == planApprovalTool {
3688 text = i18n.M.PlanApprovalPrompt
3689 } else if isRecoveryPlanChangeApproval(m.pendingApproval) {
3690 text = i18n.M.RecoveryPlanDecisionPrompt
3691 if rec := m.pendingApproval.Recovery; rec != nil {
3692 if before := compactApprovalPlan(rec.PlanBefore); before != "" {
3693 planDetails = append(planDetails, fmt.Sprintf(i18n.M.RecoveryPlanBeforeFmt, truncateSubject(before, w)))
3694 }
3695 if after := compactApprovalPlan(rec.PlanAfter); after != "" {
3696 planDetails = append(planDetails, fmt.Sprintf(i18n.M.RecoveryPlanAfterFmt, truncateSubject(after, w)))
3697 }
3698 }
3699 } else {
3700 name, detail := approvalToolDetails(m.pendingApproval.Tool)
3701 subj := strings.TrimSpace(m.pendingApproval.Subject)
3702 full := subj
3703 if subj != "" {
3704 subj = " " + truncateSubject(subj, w)
3705 }
3706 text = strings.TrimSpace(fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, name, subj, detail, ""))
3707 // A command clipped to one line can hide the part that matters — the
3708 // path being written, the flag that makes it destructive (#4682).
3709 if body := approvalSubjectBody(full, strings.TrimSpace(subj), w); body != "" {
3710 planDetails = append(planDetails, body)
3711 }
3712 }
3713 planDetails = append(planDetails, writeAccessBannerDetails(m.pendingApproval)...)
3714 if reason := strings.TrimSpace(m.pendingApproval.Reason); reason != "" {
3715 text += " · " + truncateSubject(reason, w)
3716 }
3717 if len(planDetails) > 0 {
3718 text += "\n" + strings.Join(planDetails, "\n")
3719 }
3720 var b strings.Builder
3721 b.WriteString("⏸ " + text + "\n")
3722 for i, choice := range approvalChoices(m.pendingApproval) {
3723 b.WriteString(rowLine(i == m.approvalSelection, i+1, "", choice.label, false) + "\n")
3724 }
3725 b.WriteString(dim("↑/↓ navigate · Enter select · y/a/p/n shortcuts"))
3726 return choicePanelStyle.Width(w).Render(b.String())
3727 }
3728
3729 // maxApprovalSubjectLines bounds the expanded command so a heredoc cannot push
3730 // the composer off screen.
3731 const maxApprovalSubjectLines = 8
3732
3733 // approvalSubjectBody returns the full command wrapped over several lines when
3734 // the banner's one-line preview had to clip it, or "" when the preview already
3735 // showed everything.
3736 func approvalSubjectBody(full, preview string, width int) string {
3737 full = strings.TrimSpace(full)
3738 if full == "" || full == preview {
3739 return ""
3740 }
3741 wrapWidth := max(width-4, 20)
3742 lines := strings.Split(wrapStatusLine(full, wrapWidth), "\n")
3743 if len(lines) > maxApprovalSubjectLines {
3744 lines = lines[:maxApprovalSubjectLines]
3745 lines[maxApprovalSubjectLines-1] = ansi.Truncate(lines[maxApprovalSubjectLines-1], wrapWidth-1, "") + "…"
3746 }
3747 return strings.Join(lines, "\n")
3748 }
3749
3750 func compactApprovalPlan(plan string) string {
3751 return strings.Join(strings.Fields(strings.ReplaceAll(strings.TrimSpace(plan), "\n", " · ")), " ")
3752 }
3753
3754 // approvalToolDetails turns provider-visible tool IDs into user-facing labels.
3755 // MCP tools are advertised as mcp__<server>__<tool>; showing the short tool name
3756 // first keeps the approval prompt readable while preserving the source.
3757 func approvalToolDetails(toolName string) (name, detail string) {
3758 if toolName == agent.PlanModeReadOnlyCommandApprovalTool {
3759 return i18n.M.ApprovalToolLabelPlanModeReadOnly, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
3760 }
3761 if toolName == control.SandboxEscapeApprovalTool {
3762 return i18n.M.ApprovalToolLabelSandboxEscape, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
3763 }
3764 if toolName == control.ManagedConfigWriteApprovalTool {
3765 return i18n.M.ApprovalToolLabelConfigWrite, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
3766 }
3767 if server, short, ok := tool.SplitMCPName(toolName); ok {
3768 lines := []string{}
3769 if strings.EqualFold(short, "understand_image") {
3770 lines = append(lines, i18n.M.ToolApprovalImageUse)
3771 }
3772 lines = append(lines, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, server))
3773 return short, strings.Join(lines, "\n")
3774 }
3775 return approvalToolLabel(toolName), fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
3776 }
3777
3778 func approvalToolLabel(toolName string) string {
3779 switch toolName {
3780 case "bash", "pwsh", "powershell", "shell":
3781 return i18n.M.ApprovalToolLabelBash
3782 case "edit_file":
3783 return i18n.M.ApprovalToolLabelEditFile
3784 case "write_file":
3785 return i18n.M.ApprovalToolLabelWriteFile
3786 case "multi_edit":
3787 return i18n.M.ApprovalToolLabelMultiEdit
3788 case "move_file":
3789 return i18n.M.ApprovalToolLabelMoveFile
3790 case "web_fetch":
3791 return i18n.M.ApprovalToolLabelWebFetch
3792 case "run_skill":
3793 return i18n.M.ApprovalToolLabelRunSkill
3794 case "remember":
3795 return i18n.M.ApprovalToolLabelRemember
3796 case "forget":
3797 return i18n.M.ApprovalToolLabelForget
3798 default:
3799 return toolName
3800 }
3801 }
3802
3803 // todoPanelMaxRows caps how many task lines the pinned panel shows; a long list
3804 // is truncated with a "+N more" footer so the bottom region stays compact.
3805 const todoPanelMaxRows = 8
3806
3807 // renderTodoPanel renders the committed current-turn task list above the input.
3808 // Completed lists remain inspectable until the next host turn boundary.
3809 func (m chatTUI) renderTodoPanel() string {
3810 if m.todosDismissed || len(m.todos) == 0 {
3811 return ""
3812 }
3813 done := 0
3814 for _, t := range m.todos {
3815 if t.Status == "completed" {
3816 done++
3817 }
3818 }
3819
3820 var b strings.Builder
3821 fmt.Fprintf(&b, "%s %s\n", accent("To-dos"), dim(fmt.Sprintf("%d/%d", done, len(m.todos))))
3822 start, end := todoPanelWindow(m.todos)
3823 if start > 0 {
3824 b.WriteString(dim(fmt.Sprintf(" +%d above", start)) + "\n")
3825 }
3826 for _, t := range m.todos[start:end] {
3827 indent := " "
3828 switch t.Status {
3829 case "completed":
3830 b.WriteString(indent + green("✔") + " " + dim(t.Content) + "\n")
3831 case "in_progress":
3832 b.WriteString(indent + yellow("▶ "+t.Content) + "\n")
3833 default:
3834 b.WriteString(indent + dim("○ "+t.Content) + "\n")
3835 }
3836 }
3837 if end < len(m.todos) {
3838 b.WriteString(dim(fmt.Sprintf(" +%d more", len(m.todos)-end)) + "\n")
3839 }
3840 return todoPanelStyle.Width(max(m.width, 10)).Render(strings.TrimRight(b.String(), "\n"))
3841 }
3842
3843 func todoPanelWindow(todos []event.Todo) (int, int) {
3844 if len(todos) <= todoPanelMaxRows {
3845 return 0, len(todos)
3846 }
3847 active := -1
3848 for i, t := range todos {
3849 if t.Status == "in_progress" {
3850 active = i
3851 break
3852 }
3853 }
3854 if active < 0 {
3855 return 0, todoPanelMaxRows
3856 }
3857 start := max(active-todoPanelMaxRows/2, 0)
3858 if maxStart := len(todos) - todoPanelMaxRows; start > maxStart {
3859 start = maxStart
3860 }
3861 return start, start + todoPanelMaxRows
3862 }
3863
3864 // truncateSubject trims a tool subject so the approval banner fits one line.
3865 func truncateSubject(s string, width int) string {
3866 max := width - 28
3867 if max < 16 {
3868 max = 16
3869 }
3870 return ansi.Truncate(s, max, "…")
3871 }
3872
3873 // wrapStatusLine wraps a status line to `width` visible columns, ANSI-aware,
3874 // so text that exceeds one row flows onto additional lines instead of being
3875 // truncated with an ellipsis. Wrapping is permissive — spaces are preferred
3876 // break points — and works within the alt-screen view so there is no scrollback
3877 // artifact.
3878 func wrapStatusLine(s string, width int) string {
3879 if width <= 0 || s == "" {
3880 return s
3881 }
3882 return ansi.Hardwrap(s, width, true)
3883 }
3884
3885 // computeStatusLineCount returns the number of terminal rows the status block
3886 // (working line + first status line + optional data band) will occupy after
3887 // wrapping to `width`. It mirrors the construction in View() so the reserved
3888 // height matches the rendered height exactly — the load-bearing invariant for
3889 // bottomRows().
3890 // Use the same width (m.width) that View() passes to wrapStatusLine.
3891 func (m chatTUI) computeStatusLineCount(width int) int {
3892 if m.ctrl == nil {
3893 return 3 // two information rows plus their divider
3894 }
3895 shellMode := strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!")
3896 cancelRequested := m.cancelRequested()
3897
3898 // Replicate the first status line (mode tag + state) from View().
3899 // ModeTag is rendered with Padding(0,1) in View() — add the same padding
3900 // here so the visible width matches exactly.
3901 modeTag := " " + m.modeTagText() + " "
3902 if shellMode {
3903 modeTag = " Shell "
3904 }
3905 primaryStatus := m.primaryStatusLine(modeTag, shellMode, cancelRequested)
3906 statusBlock := m.renderStatusBlock(primaryStatus, width)
3907
3908 // Replicate the working (spinner) line from View(), shown only while a turn runs.
3909 working := m.runningWorkingLine(cancelRequested, false)
3910
3911 // Count wrapped rows for every piece that View() renders as wrapped.
3912 var lines int
3913 if m.state == tuiRunning {
3914 // working (spinner) line — wraps independently of the status block below.
3915 lines += strings.Count(wrapStatusLine(working, width), "\n") + 1
3916 }
3917 lines += strings.Count(statusBlock, "\n") + 1
3918 return lines
3919 }
3920
3921 // The composer grows with its content up to this comfort cap. The effective
3922 // cap is lowered for short terminals by syncInputHeightLimit, after which the
3923 // textarea scrolls internally and keeps the caret visible.
3924 const maxInputRows = 8
3925
3926 const (
3927 composerBorderRows = 2
3928 minTranscriptRows = 3
3929 )
3930 const foldedPasteMinChars = 1000
3931 const foldedPasteMinLines = 5
3932
3933 type pastedBlock struct {
3934 label string
3935 text string
3936 image bool // an image attachment: expands to its bare @ref, not a wrapped block
3937 }
3938
3939 func (m *chatTUI) chooserTyping() bool {
3940 return m.chooser != nil && m.chooser.typing
3941 }
3942
3943 // inputHeightLimit returns the number of visible textarea rows that fit without
3944 // letting the complete composer block consume more than half the terminal or
3945 // pushing the transcript below its minimum useful height. Panel and wrapped
3946 // status rows are treated as fixed bottom chrome and remain outside the input
3947 // viewport.
3948 func (m chatTUI) inputHeightLimit() int {
3949 if m.height <= 0 {
3950 return maxInputRows
3951 }
3952
3953 limit := maxInputRows
3954 // Match the bounded-composer convention used by other coding TUIs: borders
3955 // are part of the half-screen budget, not extra rows added afterward.
3956 halfScreen := max(1, m.height/2-composerBorderRows)
3957 limit = min(limit, halfScreen)
3958
3959 // bottomRows includes the current composer. Remove it to get the fixed
3960 // panels/status budget, then reserve the input borders and a readable slice
3961 // of transcript. On extremely short terminals one editable row still wins.
3962 fixedBottomRows := m.bottomRows()
3963 if !m.hideComposer() {
3964 fixedBottomRows -= m.input.Height() + composerBorderRows
3965 }
3966 available := max(1, m.height-fixedBottomRows-composerBorderRows-minTranscriptRows)
3967 return max(1, min(limit, available))
3968 }
3969
3970 func (m *chatTUI) syncInputHeightLimit() {
3971 limit := m.inputHeightLimit()
3972 if m.input.MaxHeight == limit {
3973 return
3974 }
3975 m.followComposerCursor()
3976 m.input.MaxHeight = limit
3977 // SetWidth recalculates DynamicHeight from the full soft-wrapped content,
3978 // clamping the visible viewport to the new limit while preserving the text.
3979 m.input.SetWidth(max(m.width-4, 1))
3980 }
3981
3982 func (m *chatTUI) growInputToFit() {
3983 if m.input.DynamicHeight {
3984 return
3985 }
3986 lines := min(max(strings.Count(m.input.Value(), "\n")+1, 1), maxInputRows)
3987 if lines != m.input.Height() {
3988 m.input.SetHeight(lines)
3989 }
3990 }
3991
3992 func (m chatTUI) desktopShortcutLayout() bool {
3993 return m.cfg != nil && m.cfg.UIShortcutLayout() == "desktop"
3994 }
3995
3996 func (m chatTUI) modeTagText() string {
3997 goalMode := strings.TrimSpace(m.ctrl.Goal()) != "" && m.ctrl.GoalStatus() == control.GoalStatusRunning
3998 toolApprovalMode := m.ctrl.ToolApprovalMode()
3999 if m.desktopShortcutLayout() {
4000 switch {
4001 case m.planMode && toolApprovalMode == control.ToolApprovalDangerFullAccess:
4002 return "Plan+YOLO"
4003 case goalMode && toolApprovalMode == control.ToolApprovalDangerFullAccess:
4004 return "Goal+YOLO"
4005 case toolApprovalMode == control.ToolApprovalDangerFullAccess:
4006 return "YOLO"
4007 case m.planMode:
4008 return "Plan"
4009 case goalMode && toolApprovalMode == control.ToolApprovalWorkspaceWrite:
4010 return "Goal+Workspace"
4011 case goalMode:
4012 return "Goal"
4013 case toolApprovalMode == control.ToolApprovalWorkspaceWrite:
4014 return "Workspace"
4015 case toolApprovalMode == control.ToolApprovalDontAsk:
4016 return "Read only"
4017 default:
4018 return "Read only"
4019 }
4020 }
4021 switch {
4022 case m.planMode && toolApprovalMode == control.ToolApprovalDangerFullAccess:
4023 return "Plan+YOLO"
4024 case m.planMode && toolApprovalMode == control.ToolApprovalWorkspaceWrite:
4025 return "Plan+Workspace"
4026 case goalMode && toolApprovalMode == control.ToolApprovalDangerFullAccess:
4027 return "Goal+YOLO"
4028 case goalMode && toolApprovalMode == control.ToolApprovalWorkspaceWrite:
4029 return "Goal+Workspace"
4030 case toolApprovalMode == control.ToolApprovalDangerFullAccess:
4031 return "YOLO"
4032 case toolApprovalMode == control.ToolApprovalWorkspaceWrite:
4033 return "Workspace"
4034 case toolApprovalMode == control.ToolApprovalDontAsk:
4035 return "Read only"
4036 case m.planMode:
4037 return "Plan"
4038 case goalMode:
4039 return "Goal"
4040 default:
4041 return "Read only"
4042 }
4043 }
4044
4045 func (m *chatTUI) toggleVerboseReasoning(notify bool) {
4046 m.showReasoning = !m.showReasoning
4047 var saveErr error
4048 if m.cfg != nil {
4049 _ = m.cfg.SetShowReasoning(m.showReasoning)
4050 path := config.SourcePath()
4051 if path == "" {
4052 path = "reasonix.toml"
4053 }
4054 saveErr = config.EditConfigFile(path, func(cfg *config.Config) error {
4055 return cfg.SetShowReasoning(m.showReasoning)
4056 })
4057 }
4058 if !notify {
4059 return
4060 }
4061 suffix := ""
4062 if saveErr != nil {
4063 suffix = "\npreference was not saved: " + saveErr.Error()
4064 }
4065 if m.showReasoning {
4066 m.notice("verbose on — thinking text will be shown" + suffix)
4067 } else {
4068 m.notice("verbose off — thinking text will stay collapsed" + suffix)
4069 }
4070 }
4071
4072 // toggleMouseCapture flips whether Reasonix owns the mouse. It's session-only
4073 // (unlike /verbose, this accommodates the terminal/multiplexer at hand rather
4074 // than recording a lasting preference) — mirrors nativeScrollback, which is
4075 // likewise never persisted to config. Clears any in-app selection/scrollbar
4076 // drag in flight so a stale one can't be found mid-gesture once the terminal
4077 // starts intercepting the events that would have finished it.
4078 func (m *chatTUI) toggleMouseCapture() {
4079 m.mouseCaptureOff = !m.mouseCaptureOff
4080 m.sel = selection{}
4081 m.composerSel = composerSelection{}
4082 m.scrollbarDrag = false
4083 m.autoScroll = 0
4084 if m.mouseCaptureOff {
4085 m.notice(i18n.M.MouseCaptureOffHint)
4086 } else {
4087 m.notice(i18n.M.MouseCaptureOnHint)
4088 }
4089 }
4090
4091 // unsendPending "un-sends" the in-flight turn while the server hasn't replied yet
4092 // (bubblePending): it pops the echoed bubble back off the transcript, restores the
4093 // just-sent text to the input box, and cancels the request — marking the turn
4094 // discarded so its already-buffered events reach nothing. Once a packet has arrived
4095 // the bubble is confirmed and this path isn't taken (Esc cancels normally instead).
4096 func (m *chatTUI) unsendPending() {
4097 m.input.SetValue(m.pendingRestore)
4098 m.growInputToFit()
4099 m.truncateTranscriptBlocks(m.bubbleStartIdx)
4100 m.transcriptDirty = true
4101 m.bubblePending = false
4102 m.pendingRestore = ""
4103 m.pendingPastes = nil
4104 m.turnDiscarded = true
4105 m.ctrl.Cancel()
4106 }
4107
4108 // ingestEvent routes one typed event from the agent. Reasoning (dim) and answer
4109 // free-text accumulate in their live buffers; every other event first finalizes
4110 // the reasoning and answer streamed so far, then commits its own line —
4111 // preserving order. Switching on the event Kind replaces the old prefix-sniffing
4112 // of a flattened byte stream: the structure is now explicit.
4113
4114 // finalizeStreamed freezes any in-progress reasoning + answer into scrollback so
4115 // a following event line lands after them, preserving chronological order.
4116 func (m *chatTUI) finalizeStreamed() {
4117 m.collapseToolOutput(m.toolStreamID, "")
4118 m.commitReasoning()
4119 m.commitPending()
4120 }
4121
4122 func waitForAgentEvent(ch chan event.Event) tea.Cmd {
4123 return func() tea.Msg { return agentEventMsg(<-ch) }
4124 }
4125
4126 func elapsedTick(generation uint64) tea.Cmd {
4127 return tea.Tick(time.Second, func(_ time.Time) tea.Msg {
4128 return elapsedTickMsg{generation: generation}
4129 })
4130 }
4131
4132 // runSlashCommand handles "/<cmd> <args>" input. Local commands queue their
4133 // output to scrollback; MCP prompt / custom commands resolve to a model turn.
4134 func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
4135 typedCmd := strings.TrimSpace(strings.SplitN(input, " ", 2)[0])
4136 if notice := m.slashInputBlockedNotice(typedCmd); notice != "" {
4137 m.notice(notice)
4138 return nil
4139 }
4140
4141 if strings.HasPrefix(typedCmd, "/mcp__") {
4142 return m.runMCPPrompt(input)
4143 }
4144 cmd := canonicalBuiltinSlashCommand(typedCmd)
4145
4146 switch cmd {
4147 case control.RecoverContextCommand:
4148 id, guidance, _ := control.ParseProtocolRecoveryCommand(input)
4149 return m.startControllerTurn(input, input, func(ctrl control.SessionAPI) {
4150 if runner, ok := ctrl.(interface{ SubmitProtocolRecovery(string, string) }); ok {
4151 runner.SubmitProtocolRecovery(id, guidance)
4152 }
4153 })
4154 case control.ContinueChecksCommand:
4155 prompt, _ := control.ParseFinalReadinessRecoveryCommand(input)
4156 return m.startControllerTurn(input, input, func(ctrl control.SessionAPI) {
4157 ctrl.SubmitFinalReadinessRecovery(input, prompt)
4158 })
4159 case "/compact":
4160 m.echoLocalCommand(input)
4161 // Compaction makes a (network) summarizer call; run it off the Update loop
4162 // so the TUI doesn't freeze. The CompactionStarted/Done events render the
4163 // card as they arrive; compactDoneMsg only handles the terminal error /
4164 // snapshot once the pass returns. Any text after "/compact" is focus
4165 // guidance steering what the summary keeps.
4166 focus := strings.TrimSpace(strings.TrimPrefix(input, typedCmd))
4167 return func() tea.Msg { return compactDoneMsg{err: m.ctrl.Compact(context.Background(), focus)} }
4168 case "/context":
4169 return m.showContextReport(input)
4170 case "/new":
4171 m.echoLocalCommand(input)
4172 if err := m.ctrl.NewSession(); err != nil {
4173 m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashNewFailed, err))
4174 return nil
4175 }
4176 m.followSessionLease()
4177 // Native scrollback keeps the old transcript; mark the fork with a fresh banner.
4178 m.resetFreshContextView(false)
4179 m.notice(i18n.M.SlashNewDone)
4180 case "/clear":
4181 m.echoLocalCommand(input)
4182 if m.ctrl.ToolApprovalMode() == control.ToolApprovalDangerFullAccess {
4183 // Full access is an explicit commitment to skip ordinary confirmations; /clear is
4184 // rarely mistyped and the damage is recoverable, so clear directly.
4185 return m.clearContext()
4186 } else {
4187 m.clearConfirm = &clearConfirm{confirm: 1}
4188 }
4189 case "/cls":
4190 m.echoLocalCommand(input)
4191 m.finalizeStreamed()
4192 m.clearTranscriptDisplay()
4193 m.commitLine(strings.TrimRight(
4194 renderTUIBanner(m.label, "", transcriptContentWidth(m.width, m.nativeScrollback)), "\n"))
4195 m.transcriptDirty = true
4196 m.forceGotoBottom = true
4197 m.notice(i18n.M.SlashClsDone)
4198 case "/resume":
4199 m.runResumeCommand(input)
4200 case "/takeover":
4201 m.runTakeoverCommand(input)
4202 case "/status":
4203 m.echoLocalCommand(input)
4204 m.showStatusDetails()
4205 case "/rename":
4206 m.runRenameCommand(input)
4207 case "/todo":
4208 m.echoLocalCommand(input)
4209 // Dismiss only this mounted view; a later committed write brings it back.
4210 m.todosDismissed = true
4211 m.notice(i18n.M.SlashTodoCleared)
4212 case "/verbose":
4213 m.toggleVerboseReasoning(true)
4214 case "/mouse":
4215 m.toggleMouseCapture()
4216 case "/sandbox":
4217 m.echoLocalCommand(input)
4218 m.showSandboxStatus()
4219 case "/effort":
4220 return m.runEffortCommand(input)
4221 case "/preset", "/work-mode", "/profile":
4222 m.echoLocalCommand(input)
4223 return m.runPresetCommand(input)
4224 case "/reasoning-language":
4225 m.echoLocalCommand(input)
4226 m.runReasoningLanguageCommand(input)
4227 case "/rewind":
4228 m.echoLocalCommand(input)
4229 m.openRewind()
4230 case "/tree":
4231 m.echoLocalCommand(input)
4232 m.showBranchTree()
4233 case "/branch":
4234 m.echoLocalCommand(input)
4235 m.runBranchCommand(input)
4236 case "/switch":
4237 m.echoLocalCommand(input)
4238 m.runSwitchCommand(input)
4239 case "/mcp":
4240 m.echoLocalCommand(input)
4241 m.runMCPSubcommand(input)
4242 case "/remote":
4243 m.echoLocalCommand(input)
4244 m.showRemoteHosts()
4245 case "/plugin", "/plugins":
4246 m.echoLocalCommand(input)
4247 m.runPluginSubcommand(input)
4248 case "/model":
4249 m.echoLocalCommand(input)
4250 m.runModelSubcommand(input)
4251 if m.pendingModelSwitch != nil {
4252 return m.pendingModelSwitch
4253 }
4254 case "/provider":
4255 m.echoLocalCommand(input)
4256 m.runProviderCommand(input)
4257 if m.pendingModelSwitch != nil {
4258 return m.pendingModelSwitch
4259 }
4260 case "/setup":
4261 m.echoLocalCommand(input)
4262 m.openConnectionSetup()
4263 case "/skill", "/skills":
4264 m.echoLocalCommand(input)
4265 m.runSkillSubcommand(input)
4266 if m.pendingModelSwitch != nil {
4267 return m.pendingModelSwitch
4268 }
4269 case "/hooks":
4270 m.echoLocalCommand(input)
4271 m.runHooksSubcommand(input)
4272 case "/reload-cmd":
4273 m.echoLocalCommand(input)
4274 if m.ctrl == nil {
4275 m.notice("controller not ready")
4276 return nil
4277 }
4278 if m.ctrl.Running() {
4279 m.notice("wait for the current turn to finish, then retry /reload-cmd")
4280 return nil
4281 }
4282 prev := len(m.commands)
4283 err := m.ctrl.ReloadCommands(context.Background())
4284 m.commands = m.ctrl.Commands()
4285 m.invalidateSlashCatalog()
4286 m.updateCompletion()
4287 if err != nil {
4288 m.notice("reload-cmd: " + err.Error())
4289 return nil
4290 }
4291 m.notice(fmt.Sprintf("commands reloaded: %d → %d commands", prev, len(m.commands)))
4292
4293 case "/reload":
4294 m.echoLocalCommand(input)
4295 return m.runReloadCommand()
4296
4297 case "/paste-image":
4298 return m.beginClipboardImagePaste()
4299 case "/output-style", "/output-styles":
4300 m.echoLocalCommand(input)
4301 styles := outputstyle.List(outputstyle.Dirs())
4302 if len(styles) == 0 {
4303 m.notice(i18n.M.OutputStyleNone)
4304 } else {
4305 m.commitLine(renderOutputStyles(m.width, styles, m.outputStyle))
4306 }
4307 case "/diff-fold":
4308 m.echoLocalCommand(input)
4309 if m.diffMaxLines == 0 {
4310 m.diffMaxLines = diffFoldLimit
4311 m.notice(fmt.Sprintf(i18n.M.DiffFoldEnabledFmt, diffFoldLimit))
4312 } else {
4313 m.diffMaxLines = 0
4314 m.notice(i18n.M.DiffFoldDisabled)
4315 }
4316 case "/theme":
4317 m.echoLocalCommand(input)
4318 return m.runThemeSubcommand(input)
4319 case "/language":
4320 m.echoLocalCommand(input)
4321 return m.runLanguageSubcommand(input)
4322 case "/currency":
4323 m.echoLocalCommand(input)
4324 return m.runCurrencySubcommand(input)
4325 case "/help", "/web":
4326 return m.runHelpOrWebSlash(input, typedCmd)
4327 case "/memory":
4328 m.echoLocalCommand(input)
4329 m.showMemory(input)
4330 case "/migrate", "/migration":
4331 m.echoLocalCommand(input)
4332 migration.RunLegacyRescueCommand(strings.TrimSpace(strings.TrimPrefix(input, typedCmd)), event.FuncSink(func(e event.Event) {
4333 if e.Kind == event.Notice {
4334 m.notice(e.Text)
4335 }
4336 }))
4337 case "/goal":
4338 return m.runGoalSubcommand(input)
4339 case "/remember":
4340 m.rememberNote(strings.TrimSpace(strings.TrimPrefix(input, typedCmd)))
4341 case "/quit", "/exit":
4342 return shutdownNow
4343 case "/copy":
4344 return m.runCopyCommand(input)
4345 case "/export":
4346 m.runExportCommand(input)
4347 case "/forget":
4348 m.forgetMemory(strings.TrimSpace(strings.TrimPrefix(input, typedCmd)))
4349 default:
4350 return m.runUnrecognizedSlash(input, typedCmd, cmd)
4351 }
4352 return nil
4353 }
4354
4355 // showStatusDetails keeps diagnostics available without permanently crowding
4356 // the two-line composer footer.
4357 func (m *chatTUI) showStatusDetails() {
4358 var lines []string
4359 lines = append(lines, viewHeader("%s", "Session status"))
4360 mode := "Workspace"
4361 if m.ctrl != nil {
4362 mode = m.modeTagText()
4363 }
4364 lines = append(lines, " mode "+mode)
4365 model := strings.TrimSpace(m.modelRef)
4366 if model == "" {
4367 model = strings.TrimSpace(m.label)
4368 }
4369 if model != "" {
4370 lines = append(lines, " model "+model)
4371 }
4372 if m.ctrl != nil {
4373 if tag := m.contextTag(); tag != "" {
4374 lines = append(lines, " context "+tag)
4375 }
4376 }
4377 if m.effortLevel != "" {
4378 // The persistent footer uses an uppercase semantic label. The expanded
4379 // diagnostic view keeps its sentence-like wording for readability.
4380 lines = append(lines, " effort effort "+m.effortLevel)
4381 }
4382 if m.ctrl != nil {
4383 if tag := m.cacheTag(); tag != "" {
4384 lines = append(lines, " cache "+tag)
4385 }
4386 }
4387 if tag := m.gitTag(); tag != "" {
4388 lines = append(lines, " git "+tag)
4389 }
4390 if m.ctrl != nil {
4391 if tag := m.jobsTag(); tag != "" {
4392 lines = append(lines, " jobs "+tag)
4393 }
4394 }
4395 if m.balance != "" {
4396 lines = append(lines, " balance "+m.balance)
4397 }
4398 if tag := m.mouseTag(); tag != "" {
4399 lines = append(lines, " mouse "+tag)
4400 }
4401 lines = append(lines, " config "+activeConfigTag())
4402 m.commitLine(strings.Join(lines, "\n"))
4403 }
4404
4405 // activeConfigTag names the config file actually in effect. A ./reasonix.toml
4406 // outranks the user-global file, so a session started in a directory holding
4407 // one silently ignores global edits unless the source is visible (#3317).
4408 func activeConfigTag() string {
4409 path := config.SourcePath()
4410 if path == "" {
4411 return "(defaults — no config file)"
4412 }
4413 abs, err := filepath.Abs(path)
4414 if err != nil {
4415 return displayPath(path)
4416 }
4417 return displayPath(abs)
4418 }
4419
4420 // runCopyCommand copies the Nth-latest assistant message from the current turn
4421 // (after the last user message) to the clipboard.
4422 //
4423 // - "/copy" — shows a numbered list of assistant messages to choose from.
4424 // - "/copy N" — copies the Nth message directly (1 = most recent).
4425 //
4426 // Counting does not cross user message boundaries.
4427 func (m *chatTUI) runCopyCommand(input string) tea.Cmd {
4428 m.echoLocalCommand(input)
4429 // "/copy N" copies the Nth-newest assistant message directly (1 = most
4430 // recent), matching the picker's newest-first ordering. A bare "/copy"
4431 // (or a non-numeric argument) opens the interactive picker instead.
4432 arg := strings.TrimSpace(strings.TrimPrefix(input, "/copy"))
4433 if n, err := strconv.Atoi(arg); err == nil && n > 0 {
4434 msgs := chatUIDisplayHistory(m.ctrl)
4435 parts := copyAssistantParts(msgs)
4436 if len(parts) == 0 {
4437 m.notice(i18n.M.SlashCopyEmpty)
4438 return nil
4439 }
4440 // copyAssistantParts is oldest-first; index 0 of the reversed slice
4441 // is the most recent, so "/copy 1" = parts[len-1].
4442 idx := len(parts) - n
4443 if idx < 0 || idx >= len(parts) {
4444 m.notice(i18n.M.SlashCopyEmpty)
4445 return nil
4446 }
4447 return copyToClipboard(parts[idx])
4448 }
4449 m.openCopyPicker()
4450 return nil
4451 }
4452
4453 // firstLine returns the first non-empty line of s, truncated to 80 runes.
4454 func firstLine(s string) string {
4455 for line := range strings.SplitSeq(s, "\n") {
4456 if t := strings.TrimSpace(line); t != "" {
4457 runes := []rune(t)
4458 if len(runes) > 80 {
4459 return string(runes[:77]) + "..."
4460 }
4461 return t
4462 }
4463 }
4464 return "..."
4465 }
4466
4467 // runExportCommand exports the entire session as a markdown file, excluding
4468 // system messages, reasoning/thinking content, and tool calls/results.
4469 func (m *chatTUI) runExportCommand(input string) {
4470 m.echoLocalCommand(input)
4471 msgs := chatUIDisplayHistory(m.ctrl)
4472 if len(msgs) == 0 {
4473 m.notice(i18n.M.SlashExportEmpty)
4474 return
4475 }
4476
4477 var b strings.Builder
4478 b.WriteString("# reasonix session\n\n")
4479 lastRole := provider.Role("")
4480 exportedMessages := 0
4481 for _, msg := range cliHistoryWithoutPinnedContextRevisions(msgs) {
4482 switch msg.Role {
4483 case provider.RoleUser:
4484 // Skip internal steer messages.
4485 if _, isSteer := agent.SteerText(msg.Content); isSteer {
4486 continue
4487 }
4488 content := exportUserContent(msg.Content)
4489 if content == "" {
4490 continue
4491 }
4492 if lastRole != provider.RoleUser {
4493 b.WriteString("## User\n\n")
4494 }
4495 b.WriteString(content)
4496 b.WriteString("\n\n")
4497 exportedMessages++
4498 lastRole = provider.RoleUser
4499 case provider.RoleAssistant:
4500 content := strings.TrimSpace(msg.Content)
4501 if content == "" {
4502 continue
4503 }
4504 if lastRole != provider.RoleAssistant {
4505 b.WriteString("## Assistant\n\n")
4506 }
4507 b.WriteString(content)
4508 b.WriteString("\n\n")
4509 exportedMessages++
4510 lastRole = provider.RoleAssistant
4511 }
4512 }
4513 if exportedMessages == 0 {
4514 m.notice(i18n.M.SlashExportEmpty)
4515 return
4516 }
4517
4518 // Choose a filename. If the workspace has a root, save there; otherwise
4519 // the current directory. Use a timestamp-based name.
4520 dir := "."
4521 if m.ctrl != nil {
4522 if wr := m.ctrl.WorkspaceRoot(); wr != "" {
4523 dir = wr
4524 }
4525 }
4526 ts := time.Now().Format("20060102-150405")
4527 filename := fmt.Sprintf("session-%s.md", ts)
4528 path := filepath.Join(dir, filename)
4529 if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
4530 m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashUnknown, err))
4531 return
4532 }
4533 m.notice(fmt.Sprintf(i18n.M.SlashExportDoneFmt, path))
4534 }
4535
4536 func exportUserContent(content string) string {
4537 content = control.StripComposePrefixes(content)
4538 content = control.StripReferencedContextPrefix(content)
4539 return strings.TrimSpace(content)
4540 }
4541
4542 func (m *chatTUI) echoLocalCommand(input string) {
4543 input = strings.TrimSpace(input)
4544 if input == "" {
4545 return
4546 }
4547 m.commitLine(dim(" › " + input))
4548 }
4549
4550 // commandNames renders the custom command list for /help, "" when there are none.
4551 func (m *chatTUI) commandNames() string {
4552 names := make([]string, 0, len(m.commands))
4553 for _, c := range m.commands {
4554 if !c.Hidden {
4555 names = append(names, "/"+c.Name)
4556 }
4557 }
4558 return strings.Join(names, " · ")
4559 }
4560
4561 // showSandboxStatus displays the current sandbox configuration and whether
4562 // the OS sandbox backend is available. It reads from the stored config so
4563 // the user can inspect sandbox state without leaving the TUI (closes #3316).
4564 func (m *chatTUI) showSandboxStatus() {
4565 if m.cfg == nil {
4566 m.notice("sandbox: config not loaded")
4567 return
4568 }
4569 bash := m.cfg.BashMode()
4570 network := m.cfg.Sandbox.Network
4571 available := sandbox.Available()
4572 roots := m.cfg.WriteRoots()
4573
4574 var b strings.Builder
4575 b.WriteString("sandbox\n")
4576 b.WriteString(" phase 0 file-writer confinement\n")
4577 if len(roots) > 0 {
4578 fmt.Fprintf(&b, " write_roots %s\n", strings.Join(roots, ", "))
4579 }
4580 if m.cfg.Sandbox.WorkspaceRoot != "" {
4581 fmt.Fprintf(&b, " workspace_root %s\n", m.cfg.Sandbox.WorkspaceRoot)
4582 }
4583 if len(m.cfg.Sandbox.AllowWrite) > 0 {
4584 fmt.Fprintf(&b, " allow_write %s\n", strings.Join(m.cfg.Sandbox.AllowWrite, ", "))
4585 }
4586 b.WriteString(" phase 1 OS bash sandbox\n")
4587 fmt.Fprintf(&b, " bash %s", bash)
4588 if bash == "enforce" && !available {
4589 b.WriteString(" (unavailable: no OS sandbox on this host; bash execution is refused. " + sandbox.UnavailableRemediation() + ")")
4590 }
4591 b.WriteString("\n")
4592 fmt.Fprintf(&b, " network %v\n", network)
4593 m.notice(b.String())
4594 }
4595
4596 // runMCPSubcommand handles "/mcp" (status), "/mcp add …" (connect a server live
4597 // and persist it), and "/mcp remove <name>" (disconnect + drop from config). Add
4598 // connects synchronously — like /compact, an explicit command may briefly block
4599 // the UI while the handshake runs.
4600 func (m *chatTUI) runMCPSubcommand(input string) {
4601 args := tokenizeArgs(input) // args[0] == "/mcp"
4602 if len(args) < 2 {
4603 m.openMCPManager("")
4604 return
4605 }
4606 switch args[1] {
4607 case "list", "ls":
4608 // The completion menu offers "list"; treat it as the status view (same as
4609 // the legacy /mcp output) rather than an unknown subcommand.
4610 m.showMCPStatus()
4611 case "show":
4612 if len(args) < 3 {
4613 m.notice("usage: /mcp show <name>")
4614 return
4615 }
4616 m.openMCPManager(args[2])
4617 case "tools":
4618 if len(args) < 3 {
4619 m.notice("usage: /mcp tools <name>")
4620 return
4621 }
4622 m.openMCPManager(args[2])
4623 if m.mcp != nil {
4624 m.mcp.stage = mcpStageTools
4625 }
4626 case "add":
4627 entry, err := parseMCPAdd(args[2:])
4628 if err != nil {
4629 m.notice(err.Error())
4630 return
4631 }
4632 n, err := m.ctrl.AddMCPServer(entry)
4633 if err != nil {
4634 m.notice("mcp add: " + err.Error())
4635 return
4636 }
4637 m.refreshHostAndInvalidateSlashCatalog()
4638 m.notice(fmt.Sprintf("connected %s — %d tools, saved to global config (available next message)", entry.Name, n))
4639 case "connect":
4640 if len(args) < 3 {
4641 m.notice("usage: /mcp connect <name>")
4642 return
4643 }
4644 n, err := m.ctrl.ConnectConfiguredMCPServer(args[2])
4645 if err != nil {
4646 m.notice("mcp connect: " + err.Error())
4647 return
4648 }
4649 m.refreshHostAndInvalidateSlashCatalog()
4650 m.notice(fmt.Sprintf("connected %s — %d tools (available next message)", args[2], n))
4651 case "remove", "rm":
4652 if len(args) < 3 {
4653 m.notice("usage: /mcp remove <name>")
4654 return
4655 }
4656 name := args[2]
4657 disconnected, err := m.ctrl.RemoveMCPServer(name)
4658 if err != nil {
4659 m.notice("mcp remove: " + err.Error())
4660 return
4661 }
4662 m.refreshHostAndInvalidateSlashCatalog()
4663 if disconnected {
4664 m.notice("disconnected " + name + " and removed it from config")
4665 } else {
4666 m.notice("removed " + name + " from config")
4667 }
4668 case "import":
4669 m.openMCPImportPicker()
4670 default:
4671 m.notice("unknown /mcp subcommand " + args[1] + " — try: /mcp, /mcp list, /mcp show, /mcp add, /mcp connect, /mcp import, /mcp remove")
4672 }
4673 }
4674
4675 // showMCPStatus queues the connected MCP servers, their counts, and the prompt
4676 // commands / resource refs they expose — the discovery surface for /mcp.
4677 func (m *chatTUI) showMCPStatus() {
4678 if m.host == nil || (len(m.host.Servers()) == 0 && len(m.host.Failures()) == 0) {
4679 m.notice(i18n.M.SlashMCPNone)
4680 return
4681 }
4682 m.commitLine(renderMCPStatus(m.width, m.host.Servers(), m.host.Prompts(), m.host.Resources(), m.host.Failures(), m.host.CapabilityViews()))
4683 }
4684
4685 // notice queues a dim informational line to scrollback.
4686 func (m *chatTUI) notice(note string) {
4687 m.commitLine(dim(" · " + note))
4688 }
4689
4690 // showRemoteHosts renders a read-only summary of configured remote hosts. The
4691 // remote session lives in a `reasonix serve` on the remote host, so connecting
4692 // happens from a terminal (`reasonix remote connect`), not inside this chat.
4693 func (m *chatTUI) showRemoteHosts() {
4694 cfg, err := config.Load()
4695 if err != nil {
4696 m.notice(err.Error())
4697 return
4698 }
4699 if len(cfg.Remote.Hosts) == 0 {
4700 m.notice(i18n.M.RemoteNoHostsHint)
4701 return
4702 }
4703 var b strings.Builder
4704 for _, h := range cfg.Remote.Hosts {
4705 target := h.Host
4706 if h.User != "" {
4707 target = h.User + "@" + target
4708 }
4709 if h.Port != 0 && h.Port != 22 {
4710 target = fmt.Sprintf("%s:%d", target, h.Port)
4711 }
4712 fmt.Fprintf(&b, " · %s %s\n", h.Name, target)
4713 }
4714 fmt.Fprintf(&b, " run `reasonix remote connect <name>` in a terminal to open the remote workspace")
4715 m.commitLine(dim(b.String()))
4716 }
4717
4718 // resolveRefs resolves a line's @references off the event loop via the
4719 // controller, delivering a refsResolvedMsg with the tagged context block.
4720 func (m *chatTUI) resolveRefs(sent, display, restore string) tea.Cmd {
4721 return func() tea.Msg {
4722 block, errs := m.ctrl.ResolveRefs(context.Background(), sent)
4723 return refsResolvedMsg{sent: sent, display: display, restore: restore, block: block, errs: errs}
4724 }
4725 }
4726
4727 // runMCPPrompt resolves a /mcp__server__prompt command off the event loop via
4728 // the controller, delivering a promptResolvedMsg with the rendered prompt.
4729 func (m *chatTUI) runMCPPrompt(input string) tea.Cmd {
4730 return func() tea.Msg {
4731 sent, found, err := m.ctrl.MCPPrompt(context.Background(), input)
4732 if !found {
4733 name := strings.TrimPrefix(strings.Fields(input)[0], "/")
4734 return promptResolvedMsg{display: input, err: fmt.Errorf("%s: /%s", i18n.M.SlashUnknown, name)}
4735 }
4736 return promptResolvedMsg{display: input, sent: sent, err: err}
4737 }
4738 }
4739
4740 // runExtensionAction invokes one extension UI action off the event loop (the
4741 // call is a blocking sidecar round-trip), delivering an extensionActionMsg
4742 // whose message surfaces as a transcript notice.
4743 func (m *chatTUI) runExtensionAction(name string, args map[string]string) tea.Cmd {
4744 return func() tea.Msg {
4745 message, err := m.ctrl.InvokeExtensionAction(context.Background(), name, args)
4746 return extensionActionMsg{message: message, err: err}
4747 }
4748 }
4749
4750 // replaySectionsFor turns a loaded session into scrollback blocks. Normal tool
4751 // results remain quiet, while interrupted-turn reasoning and tool cards replay
4752 // from provider-excluded LocalOnly records so restart matches the live view.
4753 func replaySectionsFor(history []provider.Message, width int) []string {
4754 return replaySectionsForWithAssistantRenderer(history, width, renderAssistantMarkdown)
4755 }
4756
4757 func replaySectionsForWithAssistantRenderer(
4758 history []provider.Message,
4759 width int,
4760 renderAssistant func(string, int) string,
4761 ) []string {
4762 return replaySectionsForWithRenderers(history, width, renderAssistant, reasoningBlock)
4763 }
4764
4765 func replaySectionsForWithRenderers(
4766 history []provider.Message,
4767 width int,
4768 renderAssistant func(string, int) string,
4769 renderReasoning func(string, int, int) string,
4770 ) []string {
4771 var out []string
4772 for _, m := range cliHistoryWithoutPinnedContextRevisions(history) {
4773 if m.LocalOnly {
4774 if recovery, ok := provider.DecodeProtocolRecovery(m.ProtocolRecovery); ok && recovery.State == "pending" {
4775 out = append(out, fmt.Sprintf(" · %s: /recover-context %s\n\n", i18n.M.ProtocolRecoveryLabel, recovery.ID))
4776 }
4777 if m.FinalReadinessRecovery != nil && m.FinalReadinessRecovery.Pending {
4778 out = append(out, fmt.Sprintf(" · %s\n\n", i18n.M.FinalReadinessRecovery))
4779 continue
4780 }
4781 if reasoning := strings.TrimSpace(m.ReasoningContent); reasoning != "" {
4782 out = append(out, dim(" ▎ "+i18n.M.ChatThinking)+"\n"+renderReasoning(reasoning, width, 0)+"\n\n")
4783 }
4784 if body := strings.TrimSpace(m.Content); body != "" {
4785 out = append(out, renderAssistant(body, width)+"\n\n")
4786 }
4787 for _, call := range m.ToolCalls {
4788 out = append(out, toolCard(call.Name, "", width)+"\n\n")
4789 }
4790 if m.InterruptedTurn != nil {
4791 out = append(out, fmt.Sprintf(" · %s\n\n", interruptedTurnDisplayNotice()))
4792 }
4793 continue
4794 }
4795 out = append(out, searchHistorySections(m, width, renderAssistant)...)
4796 switch m.Role {
4797 case provider.RoleUser:
4798 // Host-generated wrappers (session-context snapshots, injected
4799 // preamble) are provider-workset plumbing, not visible turns; the
4800 // desktop transcript drops them and so does this replay.
4801 if agent.IsHostGeneratedUserMessage(m) {
4802 continue
4803 }
4804 // Steer messages are surfaced as a notice line, not a user bubble.
4805 if text, handled := agent.ReplaySteerText(m.Content); handled {
4806 if text != "" {
4807 out = append(out, fmt.Sprintf(" ↪ %s\n\n", text))
4808 }
4809 continue
4810 }
4811 content := control.StripComposePrefixes(agent.UserMessageText(m))
4812 out = append(out, renderUserBubble(content, width, false)+"\n\n")
4813 case provider.RoleAssistant:
4814 if reasoning := strings.TrimSpace(m.ReasoningContent); reasoning != "" {
4815 out = append(out, dim(" ▎ "+i18n.M.ChatThinking)+"\n"+renderReasoning(reasoning, width, 0)+"\n\n")
4816 }
4817 body := strings.TrimSpace(m.Content)
4818 if body != "" {
4819 out = append(out, renderAssistant(body, width)+"\n\n")
4820 }
4821 for _, call := range m.ToolCalls {
4822 out = append(out, toolCard(call.Name, call.Arguments, width)+"\n\n")
4823 }
4824 }
4825 }
4826 return out
4827 }
4828
4829 func interruptedTurnDisplayNotice() string {
4830 return i18n.M.InterruptedRecovery
4831 }
4832
4833 // renderTUIBanner is the title + tip + optional missing-key warning printed once
4834 // at the top of the session.
4835 func renderTUIBanner(label, missing string, width int) string {
4836 var b strings.Builder
4837 b.WriteString(accent("◆") + " " + bold("reasonix") + " " + dim("· "+label) + "\n")
4838 b.WriteString(dim(" "+i18n.M.ChatTip) + "\n")
4839 if missing != "" {
4840 b.WriteString(wrapForViewport(" ! "+missing, width, activeCLITheme.warn) + "\n")
4841 }
4842 return b.String()
4843 }
4844
4845 // wrapForViewport hard-wraps text to fit width columns and colours every line.
4846 func wrapForViewport(text string, width int, fg cliColor) string {
4847 if width <= 0 {
4848 width = 80
4849 }
4850 return themeStyle(fg).Width(width).Render(text)
4851 }
4852
4853 // renderUserBubble renders the just-submitted prompt as a transcript line. Keep
4854 // it visually lighter than the real bottom composer so a fresh session does not
4855 // look like it has a second input box in the transcript.
4856 func renderUserBubble(line string, width int, planMode bool) string {
4857 line = displayLineForImageRefs(line)
4858 prefix := "› "
4859 if planMode {
4860 prefix = "› [plan] "
4861 }
4862 if !colorOn() {
4863 return "│ " + prefix + line
4864 }
4865 return " " + accent(prefix+line)
4866 }
4867
4868 var cliImageRefRe = regexp.MustCompile(`(?:^|\s)@\.reasonix/attachments/clipboard-\d{8}-\d{6}\.\d+(?:-(?:\d{6}|[a-f0-9]{8}))?\.(?:png|jpg|jpeg|gif|webp)`)
4869
4870 func displayLineForImageRefs(line string) string {
4871 idx := 0
4872 out := cliImageRefRe.ReplaceAllStringFunc(line, func(_ string) string {
4873 idx++
4874 return " [image" + strconv.Itoa(idx) + "]"
4875 })
4876 return strings.TrimSpace(out)
4877 }
4878
4879 // eventSink is the event.Sink the agent emits to in TUI mode. Each event
4880 // becomes an agentEventMsg. The channel is generously buffered so streaming
4881 // bursts don't back-pressure the agent goroutine.
4882 type eventSink struct {
4883 ch chan<- event.Event
4884 }
4885
4886 func (s *eventSink) Emit(e event.Event) { s.ch <- e }
4887
4887 lines GO