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