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