feat(tui): OSC 8 PR hyperlink and native terminal drag-select#87
Merged
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Switches the `gh pr view` probe from `--jq .number` to `--json number,url` and parses both fields into a new `PullRequest { number, url }` struct. Status-line state now carries the URL alongside the number so a follow-up can render `#NN` as an OSC 8 hyperlink without re-shelling on click.
Bumps `current_pull_request`'s return type from `Option<u64>` to `Option<PullRequest>`. Sites that only need the number (line.rs render) read `pr.number`; the URL waits for the OSC 8 commit.
Caches the jump-to-bottom pill rect on `App` during render and intercepts left-click events that fall inside it. The click triggers `ChatView::jump_to_bottom`, which both scrolls and re-arms `auto_scroll`. Wheel scroll continues to flow through `ChatView::handle_event` for `MouseEventKind::ScrollUp`/`ScrollDown`. Promotes `ChatView::scroll_to_bottom`'s caller pair (scroll + re-arm) into a `pub(crate) fn jump_to_bottom`, and routes the existing Ctrl+End arm through it so the keyboard shortcut and the click share semantics. Adds a small `rect_contains` helper next to `jump_overlay_label` for cell-coordinate hit-testing without pulling in ratatui's `Position` wrapper at every call site. Adds `#[cfg(test)] pub(crate)` accessors on `ChatView` (`scroll_offset_for_test`, `auto_scroll_for_test`, etc.) so app-level tests can drive the chat into a known scroll state without going through the full transcript-load path.
Wraps the visible `#NN` span with `ESC ] 8 ; ; URL ESC \\` opener and `ESC ] 8 ; ; ESC \\` closer so terminals with OSC 8 support (iTerm2, WezTerm, Alacritty, kitty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal) make the number Ctrl-clickable to the PR URL. Terminals without OSC 8 ignore the bytes and just print `#NN`, so plain text is the natural fallback. The escape bytes ride zero-width `Span::raw`s emitted around the visible span at line-assembly time. `unicode_width::UnicodeWidthStr` reports 0 for control codes, so column accounting and `fit_segments`'s drop ordering see only the `#NN` width. `RenderedSegment` gains an `Option<String> hyperlink` so the wrapper-vs-plain split lives at the segment level instead of inside the line builder.
Adds `selection` to the canonical slot list with a bg-only default in every built-in theme. The slot is used by the upcoming copy-on-select feature to paint the dragged region; per-theme bg colors keep the highlight legible against the theme's `surface` (mocha/macchiato/frappe pick the next-up surface tier, latte uses a slightly darker grey, material uses near-surface grey). Tightens `default_theme_has_distinct_colors` to fail when a future theme drops the `selection` bg, since the slot is bg-only by design and the existing fg-distinctness checks would not catch a missing entry.
acaf6a2 to
982ea58
Compare
Adds mouse-drag text selection with OSC 52 clipboard write. Left-button down inside the chat area arms a `Selection` state machine; drag updates the endpoint clamped to the chat rect; mouse-up materializes the selected text from the chat's wrapped `Text<'static>` and emits `\x1b]52;c;<base64>\x07` to stdout. Works in any terminal that honors OSC 52 (iTerm2, WezTerm, kitty, Alacritty, foot, recent xterm with `allowWindowOps`); tmux passes through with `set -g set-clipboard on`. Selection geometry is line-rect (terminal convention): the first row starts at the click column, the last row ends at the release column, intermediate rows span the full chat width. Wide CJK glyphs and emoji are taken whole when their leading half lands inside the selection range, so multi-byte sequences never split. The OSC 52 payload is base64'd over the raw UTF-8 bytes, clamped to 8 KB pre-base64 (xterm's conservative cap) at a UTF-8 char boundary; truncation pushes a system message into the chat. The selection paints a `Theme::selection` bg over the rendered chat buffer each frame, so the highlight tracks the drag without recomputing layout. Wheel scroll continues to flow through `ChatView::handle_event`. Click on the jump-to-bottom pill still wins over selection-begin via priority order. Adds the `base64` crate at the workspace level. New `tui::selection` module with the state machine, line slicing (unicode-width-aware), and OSC 52 encoder + 14 unit tests including CJK round-trip, wide-char boundary, oversize-payload clamp.
Adds the design doc for the mouse-interaction policy: capture mouse, route clicks to app-owned hit zones (jump-to-bottom pill), render terminal hyperlinks for the PR status segment, and copy-on-select via OSC 52 over the chat region. Documents the selection geometry (line-rect, unicode-width-aware), OSC 52 payload cap (8 KB pre-base64 with UTF-8 char-boundary clamp), the per-terminal selection escape hatches for content outside the chat, and the OSC 52 opt-in config required by xterm and tmux. Adds the research note covering Claude Code's full hit-test framework + OSC 52 + `CLAUDE_CODE_DISABLE_MOUSE`, OpenAI Codex's no-capture + DECSET `?1007` alternate-scroll + OSC 8 hyperlinks via `mark_url_hyperlink`, opencode's `mouse: true` config + per-element click handlers + copy-on-select via opentui `Selection.copy`. Also documents the OSC 52 / OSC 8 protocols, payload caps per terminal, the crossterm mouse mode bundle (`?1000`/`?1002`/`?1003`/`?1006`/`?1015`), and the takeaway that informed the chosen scope. Updates the docs index tables, the CLAUDE.md crate tree (`tui/selection.rs`), and the cspell allowlist for the new terminal / library names referenced in the docs.
`slice_line` skipped chars whose `unicode-width` reported zero, which dropped combining marks (e.g., NFD-decomposed accents) sitting on a base char that was otherwise inside the selection. Track the previous base char's keep state and attach the mark to it. `osc8_open` formatted the URL straight into the escape envelope, so an embedded `\x1b` or `\x07` from a malformed source could break the hyperlink boundary. Strip control chars before composition.
`render_jump_overlay` and `draw_frame` already run with `&mut self`, so the `Cell<Option<Rect>>` indirection bought nothing. Switch to plain `Option<Rect>` and reorder the new fields next to `chat`. Replace the bespoke `rect_contains` helper with `Rect::contains(Position)` from ratatui. Bind `chat_rect` once in the drag arm to avoid re-fetching the same `Option<Rect>` per coordinate.
The mouse-selection types and `osc52_set_clipboard` are only consumed by the sibling `tui::app` module, so `pub(super)` is the smallest fit. `ChatView::jump_to_bottom` is `pub(crate)` and called from `app.rs`; promote it out of the `// ── Private Helpers ──` block and into the public-API impl. Hoist the six `*_for_test` accessors to a dedicated `// ── Test accessors ──` block at the bottom of the file so the production API isn't interleaved with test plumbing.
`selection.rs` test sections paraphrased their topic (`Selection state`, `Materialization (line-rect)`, `CJK round-trip`, `Painting`) and bundled three independent functions under one header. Rename per-fn so the test order traceably mirrors production order: `Selection`, `materialize`, `slice_line`, `osc52_set_clipboard`, `floor_to_char_boundary`, `paint`. Rename `materialize_clips_to_area_bounds` to describe what it actually asserts (drag starting below the chat area). `app.rs` lumped four mouse-routing tests inside `handle_crossterm_event`. Lift them into a dedicated `handle_mouse_event` section after the crossterm-event tests so each section maps to one production fn.
Extract the OSC 52 emission path out of `copy_selection_to_clipboard` into `write_selection_to(&mut dyn Write)` so the production path can keep wrapping stdout while tests assert against a `Vec<u8>`. The truncation system message and the no-rect / no-drag short-circuits now have direct coverage. New tests: - exact-cap and one-byte-over-cap OSC 52 truncation flag, - mid-3-byte CJK boundary clamp keeps the payload valid UTF-8, - drag exiting the chat rect clamps the endpoint inside, - mouse-up outside the chat still finalizes a drag, - write_selection_to honors `chat_rect` / `Selection::Idle` short-circuits, - write_selection_to emits an OSC 52 envelope on a real drag and uses the current scroll offset. Tighten the paint test with a sentinel pre-fill so untouched cells are distinguishable from cells that happen to default to `Color::Reset`. Reword the existing `materialize_clips_to_area_bounds` to describe what it actually asserts (drag starts below the area). Allowlist `rects` for cspell.
Drop semicolons from doc comments on the OSC 52 cap constant, the `Selection::Dragging` variant, the `osc8_open` helper, and the inline `handle_crossterm_mouse_is_forwarded_to_chat` test. Drop the "X, not Y" antithesis from `Selection::normalized` and tighten `Selection::begin` to one line. Trim "used by [`crate::tui::selection`]" mechanics from the new chat accessors and the `jump_to_bottom` doc; one-line contracts suffice. Drop test-narration comments that restate the test name on the new mouse-routing tests. Tighten the four-line OSC 8 layout-math comment in the status-line render test to three lines.
Replace U+2026 ellipses with ASCII `...`, swap mid-prose em-dashes for periods or transition phrases, and rewrite the semicolons inside prose (opencode copy-on-select default, OSC 52 failure-mode line, tmux wheel-up paragraph). Add spaces around `/` separating distinct words (`expand / collapse`, `question / option`) so the file matches the existing convention used elsewhere in the doc.
The previous approach wrapped `#NN` in zero-width `Span::raw` opener and closer carrying the OSC 8 escape bytes. ratatui's `Buffer::set_string` filters out any grapheme that contains a control char, so the leading and trailing `\x1b` were stripped on the way into the buffer while the printable middle (`]8;;<URL>\#NN]8;;\`) leaked through as visible text. Switch to a post-render mark step. `StatusLine::render` now returns the rendered line plus the cell-column ranges of every hyperlinked segment. `StatusBar::render` paints the line, then walks each range and rewrites each cell's symbol via `Cell::set_symbol` to embed the OSC 8 envelope around the original glyph. Crossterm's Backend prints `cell.symbol()` verbatim, so the escape bytes survive the diff renderer. Sanitize `url` (strip control chars) inside the helper so a malformed value cannot break out of the envelope.
The bare OSC 52 sequence is silently dropped inside tmux when the user's config doesn't have `set -g set-clipboard on` (the pre-3.2 default). The sequence also isn't visible to the terminal until stdout is flushed, but `std::io::stdout()` is line-buffered. When `TMUX` is set, emit a second copy of the OSC 52 wrapped in tmux's DCS pass-through (`\x1bPtmux;\x1b\x1b]52;c;...\x07\x1b\\`) so tmux strips its DCS envelope and forwards the inner bytes verbatim to the outer terminal. Configurations that already pass OSC 52 through receive both copies; the terminal's clipboard handler is idempotent. Flush the sink after both writes so the bytes reach the terminal before the next ratatui frame.
ST (`\x1b\\`) is misparsed by some xterm.js-based emulators (VS Code, Cursor's integrated terminal), leaking the visible bytes of self-contained per-cell OSC 8 envelopes into adjacent cells. Single-byte BEL (`\x07`) is universally accepted and produces the same hyperlink behavior.
… copy The mouse-capture-driven drag-select-and-OSC-52-copy path fights the terminal at every step: capturing mouse suppresses native drag-select, emitting OSC 52 relies on terminal cooperation that VS Code / Cursor's xterm.js-based terminals don't reliably honor, and the in-app selection highlight duplicates UI the terminal already renders for free. The cleaner design defers selection entirely to the terminal. This commit removes the supporting machinery so the next commit can drop mouse capture and let native drag-select work everywhere: - Delete `tui/selection.rs` (Selection state, materialize, OSC 52 encoder, tmux DCS pass-through, paint helper) and its tests. - Drop the `selection` theme slot and its bg-only entry in every built-in theme TOML. - Drop `chat_rect`, `selection`, `copy_selection_to_clipboard`, and `write_selection_to` from `App`. `handle_mouse_event` now only routes a Down(Left) on the cached jump-pill rect; everything else flows to chat so wheel scroll still works. - Drop the `pub(crate) scroll_offset` and `rendered_text` accessors from `ChatView` that only existed for selection materialization. - Drop `base64` from the workspace dependencies (only the OSC 52 encoder used it).
`EnableMouseCapture` claims wheel, click, and drag from the terminal. That suppresses native drag-select-and-copy in every modern terminal, which is the most-used way to move text out of the TUI. Drop the capture and emit `\x1b[?1007h` (DECSET 1007, alternate-scroll) on enter. Modern terminals (iTerm2, WezTerm, kitty, Alacritty, Terminal.app, GNOME Terminal, Konsole, Ghostty, Windows Terminal, xterm.js-based terminals like VS Code's and Cursor's) translate physical wheel events into arrow-key sequences while the alt-screen is active, so chat still scrolls. `\x1b[?1007l` pairs on leave to restore the prior mode for the parent shell.
The shipped design lets the terminal own the mouse: no capture, DECSET 1007 for wheel, OSC 8 (BEL-terminated) post-render on the PR segment for the click-to-open affordance. Drop the OSC 52 / Selection / theme-slot machinery from both the design spec and the research note, and pin the xterm.js parser quirk that drove the BEL-vs-ST decision and the `Cell::set_symbol` workaround for ratatui's control-char filter. Also drop two cspell entries (`Ptmux`, `rects`) that only existed for the removed OSC 52 / Selection code, and add `misparses` for the new research-note prose.
ratatui's `Buffer::diff` reads `cell.symbol().width()` (UAX 11) to compute `to_skip` for trailing cells. Storing the OSC 8 envelope inside a cell's symbol made `unicode-width` count the URL bytes (~30 for a typical PR URL), so the diff skipped ~30 trailing cells: `#86` rendered as `#pus` once `Opus 4.7` shifted into the dropped columns. `StatusBar::render` now snapshots each link rect's visible cells + styles into a `pending_hyperlinks` queue. `App::render` drains that queue after `terminal.draw()` flushes via `emit_status_hyperlinks`, which writes CUP + OSC 8 opener + replayed styled cells + OSC 8 closer + SGR reset directly to the crossterm backend. The buffer cells stay plain (width 1), the diff math behaves, and modern terminals still make the segment Ctrl-clickable.
…arch Document that tmux with `set -g mouse on` intercepts mouse events before the host terminal sees them, and tmux's copy-mode hit-test off-by-ones in xterm.js hosts (Cursor, VS Code's integrated terminal). This is a tmux/xterm.js interaction the app can't fix; record the known workarounds (hold Shift to bypass tmux, or toggle mouse off) so the next debugging pass starts from the right premise.
`emit_status_hyperlinks` walks each link rect and writes CUP + envelope + replayed cells directly to the crossterm backend. After the last link, the cursor sits inside the status bar next to `#NN`, which shows up as a stray cursor on top of the PR segment because the input field's cursor restore happened earlier inside `terminal.draw`. Save the cursor `terminal.draw()` parked before the writes and restore it via `terminal.set_cursor_position` afterwards. Drop the tmux-mouse-mode off-by-one section from `docs/research/tui/mouse-interactions.md`: the symptom no longer reproduces in the user's environment.
`terminal.get_cursor_position()` issues a DSR query (\x1b[6n) and reads from stdin, which races the TUI event reader. When the response gets consumed by the event loop, the call fails, the saved position is silently dropped, and the cursor stays parked next to `#NN` on the status bar. DECSC (\x1b7) / DECRC (\x1b8) save and restore the cursor terminal-side, so no roundtrip is needed and the event loop never sees the bytes. Also collapse the two emit_status_hyperlinks calls in render() into one after the final draw, since emitting before the relayout-triggered redraw just writes bytes that the redraw clobbers.
`current_branch` and `current_pull_request` had near-identical scaffolding around `Command::output()`: cwd-to-string coercion, spawn-error logging, non-zero-exit logging. Pull both into `cwd_to_str` and `run_probe` so the probe-specific code stays focused on parsing. Adds tests for the previously-uncovered branches: non-UTF-8 cwd, spawn failure (via injected `Err`), non-zero exit (via injected `Output`), and the all-blank-stderr path of `stderr_summary`. Lifts util/git.rs line coverage from 84% to 96%.
…prose - Merge the captured-hyperlink tests under `// ── render ──` so the section order tracks the production layout. The dedicated `take_pending_hyperlinks` section keeps a single drain test. - Pair `STATUS_LINE_MARGIN_STR` with `STATUS_LINE_MARGIN` and assert the two stay in sync at compile time, so a future margin change can't silently desync from the column accumulator. - Move `pr_state` next to other test helpers above the dividers, rename the two PR-segment tests to start with `render_pull_request_*` to match the convention of the surrounding render tests. - Add the missing `refresh_git_branch_returns_false_when_branch_unchanged` test (covers the early-return) and `tick_marks_dirty_when_pull_request _changes` (covers the PR arm of `tick`). - Trim docstrings on `StatusHyperlink`, `take_pending_hyperlinks`, and the WHAT comments inside `render`.
- Trim mechanics-heavy docstrings on `handle_mouse_event`, `emit_status_hyperlinks`, `write_status_hyperlinks`, and `write_styled_symbol` to one-line contracts. - Fix the comment on the `left_click_outside_jump_overlay_does_not_jump_chat` test: DECSET 1007 has no bearing on how tests inject `Down(Left)` events. - Drop visibility on `write_status_hyperlinks` to private (only `App::emit_status_hyperlinks` and the in-file tests call it). - Cover the previously-uncovered modifier and palette branches in `write_styled_symbol` / `ratatui_color_to_crossterm`, plus the empty-link short-circuit in `App::emit_status_hyperlinks`.
Reflects the cursor-restore switch from `terminal.get_cursor_position()` (which races the TUI event reader on stdin) to terminal-side DECSC (\x1b7) / DECRC (\x1b8) brackets. Also tightens prose: drop the trailing `, ...` filler after the terminal list, replace the antithesis on per-terminal escape hatches, dedupe the "too / as well" connector pair in the research notes, and remove the em-dash parenthetical on URL sanitization.
…tmux tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer terminal. OSC 8 isn't on the list, so ctrl+click on `#NN` inside ox under tmux did nothing even when the same OSC 8 sequence worked perfectly in a plain shell in the same terminal. When `$TMUX` is set, wrap the entire envelope (DECSC + per-link CUP / OSC 8 / replayed cells + DECRC) in `\x1bPtmux;...\x1b\\` with every inner ESC doubled per the tmux spec. tmux strips the DCS and forwards the inner bytes verbatim to the outer terminal. Split out `build_status_hyperlink_envelope` as the env-independent core so the existing tests pinning the wire shape stay deterministic. New tests cover the tmux-on, tmux-off, and empty-link cases at the `write_status_hyperlinks` layer plus a unit test for `tmux_passthrough`.
- Hoist `Color`, `Modifier`, `Style`, `Arc`, `HyperlinkCell`, and `StatusHyperlink` to the test mod's top-of-file `use` block so each test reads as production-shaped code instead of carrying its own imports. Keeps the existing `std::fmt::Write as _` aliases inline since they intentionally scope a `Write` trait that would shadow `std::io::Write` at the mod level. - Add a `plain_hyperlink(rect, url, &[symbol])` test fixture so each status-bar OSC 8 test reads as one line of input, mirroring the `pr_state` helper already used in `status/line.rs`. - Pair both `pr_state` helpers (status.rs, status/line.rs) on `(number) -> PullRequest` so the URL is derived rather than mismatched per call site.
Two latent bugs in the post-flush hyperlink emission, plus tests that pin
each fix:
- `write_styled_symbol` only added modifier SGRs (`Bold`, `Italic`, etc.)
and never cleared them, so a cell with `BOLD` followed by a cell with
only `ITALIC` rendered the second cell as `BOLD | ITALIC`. Today every
PR-segment cell shares one accent style so the leak is invisible, but
the moment a link carries mixed-style cells the carry-over corrupts
the replay. Drop the per-cell `\x1b[0m` reset before re-applying fg /
bg / modifiers.
- `std::env::var_os("TMUX").is_some()` mistreats a stale rc-file
`export TMUX=` as "running inside tmux" and routes DCS pass-through
bytes to a raw terminal that prints `Ptmux;...\\` literally. Gate on
non-empty `$TMUX` via a `running_inside_tmux` helper instead.
Restore the `cwd` field on `run_probe`'s `debug!` records (it was lost
when `run_probe` was extracted) so a flaky probe still tells the user
which worktree it failed in. Also fills coverage gaps surfaced by
codecov: every `ratatui_color_to_crossterm` palette branch, the
`(Some(req), _, None)` model-swap branch in `format_config_change`, the
new SGR-reset path, and the empty-`$TMUX` branch.
Three review passes (correctness / prose / conventions) surfaced cleanup that lands in a single sweep: - Lift inline-qualified type paths into top-level `use`: `StatusHyperlink` and `HyperlinkCell` in `tui/app.rs`, `PullRequest` in `tui/components/ status/line.rs`, `Style` in `tui/components/status.rs`. Brings the new types in line with the project's three-block import convention. - Reorder the test sections in `tui/app.rs` to mirror production order (`emit_status_hyperlinks` → `write_status_hyperlinks` → `build_status_hyperlink_envelope` → `tmux_passthrough` → `ratatui_color_to_crossterm`). The misfiled `emit_status_hyperlinks_is_a_noop_when_no_links_pending` gets its own divider. - Lift `plain_hyperlink` to a top-level test fixture so both `build_status_hyperlink_envelope` and `write_status_hyperlinks` tests reuse it. Add `pr_bar` next to `test_bar` in `tui/components/status.rs` to drop the triplicate `StatusBar::new` literal. - Trim docstrings that stated mechanics rather than contract on `emit_status_hyperlinks`, `write_status_hyperlinks`, `build_status_hyperlink_envelope`, `tmux_passthrough`, `StatusHyperlink`, `take_pending_hyperlinks`, `RenderedStatusLine`, `RenderedHyperlink`, `stderr_summary`. Drop comments that duplicated the surrounding doc. - Drop the dead `let _backend = render_status(...)` binds in the new status tests. - Tighten the `mouse-interactions` design doc: drop the parenthetical `(the original #86 → #pus bug)` task-narration aside, document the empty-`$TMUX` and tmux 3.3+ `allow-passthrough` requirements, replace "Three non-obvious mechanics:" with "Three mechanics worth surfacing:".
Adds two affordances that aren't discoverable without telling the user about them, since neither echoes through a keybinding hint or popup. While here, fold in `/compact`, `/config`, `/rename` as starter picks and `Esc`, `Ctrl+End`, and `-p` as tips, each tied to behavior that already shipped. Re-sort both pools case-insensitively to keep the existing alphabetical convention.
Wrap the tildify cases in temp_env so they hold the same environment lock as the expand_user tests. Without that, a parallel test can temporarily replace HOME while tildify reads dirs::home_dir(), producing a nondeterministic full-suite failure.
Remove the jump-pill left-click path because real sessions do not enable mouse capture, so left-click events never reach App without breaking native drag-select. Mouse events that do arrive still route straight to ChatView for scroll handling. Move status OSC 8 replay helpers and their protocol tests from App into terminal.rs, where terminal byte handling lives. While there, trim verbose comments, reorder the affected App tests to match production order, and make the mouse-forwarding test assert visible chat scroll behavior. Also split invalid gh PR payload cases from the happy path and cfg-gate Unix-only non-UTF-8 path tests so the util::git test layout matches the function sections and platform constraints.
Update the mouse-interaction design and research notes for the terminal-owned selection model after dropping the app-routed jump-pill click path. The docs now describe Ctrl+End for the jump overlay, OSC 8 as the PR-click mechanism, and the shorter automated coverage surface. Also document the clickable pull-request status segment in the configuration guide and normalize prose slash spacing in the touched roadmap/config examples.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds two terminal-owned mouse affordances to the TUI without claiming mouse capture:
pull-requeststatus segment renders#NNas an OSC 8 hyperlink, so supporting terminals open the PR URL via Ctrl-click or Cmd-click.enter_tui_modeskipsEnableMouseCapture; DECSET 1007 keeps wheel scrolling working in terminals that support alternate-scroll.Design decisions
ChatView, but production sessions do not rely on app-routed click hit-testing.enter_tui_modeenables alternate-scroll andleave_tui_modedisables it, so physical wheel events arrive as arrow-key sequences while the alternate screen is active.StatusBar::renderrecords each link rect plus visible cells. Afterterminal.draw()flushes, the terminal layer writes DECSC, per-link CUP, OSC 8, replayed styled cells, SGR reset, and DECRC directly to the backend. This avoids ratatui's cell-width diff issue when escape bytes live inside cell symbols.$TMUXis set and non-empty.current_pull_requestparsesgh pr view --json number,urlintoPullRequest { number, url }, so the status bar can render the link without a click-time shell-out.Changes
tui/terminal.rstui/app.rstui/components/status.rs,tui/components/status/line.rstui/components/chat.rstui/components/welcome.rs,tui/components/snapshots/*.snaputil/git.rsPullRequest { number, url }, shares git / gh probe scaffolding, and reorganizes parser / platform tests.util/path.rstildifyHOME fixtures so path tests no longer race parallel env mutations.docs/design/tui/mouse-interactions.md,docs/research/tui/mouse-interactions.mddocs/guide/configuration.md,docs/roadmap.mddocs/design/README.md,docs/research/README.md,CLAUDE.md,.cspell/words.txtTest plan
cargo fmt --all --checkcargo buildcargo clippy --all-targets -- -D warnings: zero warningscargo test: 2094 tests passcargo llvm-cov --ignore-filename-regex 'main\\.rs': 98.59% line coverage overall;tui/app.rs98.28%,tui/components/status.rs99.81%,tui/terminal.rs89.72%,util/git.rs96.14%pnpm lint: 0 errorspnpm spellcheck: 0 issues