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