Skip to content

feat(tui): OSC 8 PR hyperlink and native terminal drag-select#87

Merged
hakula139 merged 35 commits into
mainfrom
feat/mouse-copy
May 20, 2026
Merged

feat(tui): OSC 8 PR hyperlink and native terminal drag-select#87
hakula139 merged 35 commits into
mainfrom
feat/mouse-copy

Conversation

@hakula139
Copy link
Copy Markdown
Owner

@hakula139 hakula139 commented May 15, 2026

Summary

Adds two terminal-owned mouse affordances to the TUI without claiming mouse capture:

  • The pull-request status segment renders #NN as an OSC 8 hyperlink, so supporting terminals open the PR URL via Ctrl-click or Cmd-click.
  • Native drag-select remains available because enter_tui_mode skips EnableMouseCapture; DECSET 1007 keeps wheel scrolling working in terminals that support alternate-scroll.

Design decisions

  • No mouse capture. The TUI leaves click and drag ownership with the terminal. Any mouse events that are injected or delivered by a terminal still forward to ChatView, but production sessions do not rely on app-routed click hit-testing.
  • DECSET 1007 for wheel. enter_tui_mode enables alternate-scroll and leave_tui_mode disables it, so physical wheel events arrive as arrow-key sequences while the alternate screen is active.
  • Post-flush OSC 8 replay. StatusBar::render records each link rect plus visible cells. After terminal.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.
  • Terminal-compatible envelope details. OSC 8 uses BEL terminators for xterm.js compatibility, strips URL control chars, resets SGR per replayed cell, and wraps the envelope in tmux DCS pass-through when $TMUX is set and non-empty.
  • PR URL is probed with the number. current_pull_request parses gh pr view --json number,url into PullRequest { number, url }, so the status bar can render the link without a click-time shell-out.

Changes

File Description
tui/terminal.rs Adds alternate-scroll enter / leave commands and owns OSC 8 status-link replay, tmux pass-through, color / modifier replay, and terminal protocol tests.
tui/app.rs Emits pending status hyperlinks after frame flush, forwards delivered mouse events to chat, removes the dead jump-pill click path, and tightens related tests / comments.
tui/components/status.rs, tui/components/status/line.rs Carries PR URL state, returns rendered hyperlink ranges, snapshots visible link cells, and drains pending hyperlinks after render.
tui/components/chat.rs Drops test-only scroll accessors that only supported the removed click-path tests.
tui/components/welcome.rs, tui/components/snapshots/*.snap Adds starter / tip copy for native drag-select and PR-click discoverability.
util/git.rs Parses PullRequest { number, url }, shares git / gh probe scaffolding, and reorganizes parser / platform tests.
util/path.rs Isolates tildify HOME fixtures so path tests no longer race parallel env mutations.
docs/design/tui/mouse-interactions.md, docs/research/tui/mouse-interactions.md Documents the no-capture design, DECSET 1007, post-flush OSC 8 replay, tmux pass-through, and rejected app-side selection path.
docs/guide/configuration.md, docs/roadmap.md Documents the clickable PR status segment and trims stale status-line prose.
docs/design/README.md, docs/research/README.md, CLAUDE.md, .cspell/words.txt Keeps indexes, crate tree notes, and spelling allowlist aligned with the terminal changes.

Test plan

  • cargo fmt --all --check
  • cargo build
  • cargo clippy --all-targets -- -D warnings: zero warnings
  • cargo test: 2094 tests pass
  • cargo llvm-cov --ignore-filename-regex 'main\\.rs': 98.59% line coverage overall; tui/app.rs 98.28%, tui/components/status.rs 99.81%, tui/terminal.rs 89.72%, util/git.rs 96.14%
  • pnpm lint: 0 errors
  • pnpm spellcheck: 0 issues
  • Manual terminal matrix: iTerm2 / WezTerm / kitty / Alacritty / GNOME Terminal / VS Code terminal / Cursor terminal, with and without tmux, for wheel scroll, native drag-select, PR Ctrl-click, and alt-screen restore

@hakula139 hakula139 added the enhancement New feature or request label May 15, 2026
@hakula139 hakula139 self-assigned this May 15, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 97.96215% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/oxide-code/src/tui/app.rs 85.48% 9 Missing ⚠️
crates/oxide-code/src/tui/terminal.rs 98.94% 3 Missing ⚠️
crates/oxide-code/src/tui/components/status.rs 99.12% 1 Missing ⚠️
crates/oxide-code/src/util/git.rs 99.14% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

hakula139 added 4 commits May 15, 2026 15:39
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.
hakula139 added 2 commits May 15, 2026 17:34
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.
@hakula139 hakula139 changed the title feat(tui): drop crossterm mouse capture so terminal owns selection feat(tui): mouse interactions — clickable jump, OSC 8 PR link, OSC 52 copy May 15, 2026
hakula139 added 9 commits May 15, 2026 18:35
`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.
@hakula139 hakula139 changed the title feat(tui): mouse interactions — clickable jump, OSC 8 PR link, OSC 52 copy feat(tui): mouse interactions, OSC 8 PR link, OSC 52 copy May 17, 2026
hakula139 added 4 commits May 18, 2026 11:33
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.
@hakula139 hakula139 changed the title feat(tui): mouse interactions, OSC 8 PR link, OSC 52 copy feat(tui): OSC 8 PR hyperlink and native terminal drag-select May 18, 2026
hakula139 added 4 commits May 18, 2026 16:11
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.
hakula139 added 12 commits May 18, 2026 17:27
`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.
@hakula139 hakula139 merged commit 86be30a into main May 20, 2026
4 checks passed
@hakula139 hakula139 deleted the feat/mouse-copy branch May 20, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant