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