From a0fcd50dedb9ab3723ede8ccd319b0ff49c8d59f Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 15:39:50 +0800 Subject: [PATCH 01/35] feat(util): probe PR number and URL together for the status segment 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` to `Option`. Sites that only need the number (line.rs render) read `pr.number`; the URL waits for the OSC 8 commit. --- .../oxide-code/src/tui/components/status.rs | 4 +- .../src/tui/components/status/line.rs | 12 +++-- crates/oxide-code/src/util/git.rs | 50 +++++++++++++------ 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index f57bafb4..01c25cb3 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -41,7 +41,7 @@ pub(crate) struct StatusBar { /// `None` collapses every git probe to a no-op. git_cwd: Option, git_branch: Option, - pull_request: Option, + pull_request: Option, /// `true` while the `pull-request` segment is configured. Skips the `gh` probe entirely when /// the user hasn't opted in. track_pull_request: bool, @@ -273,7 +273,7 @@ impl StatusBar { usage: self.usage, cwd: &self.cwd, git_branch: self.git_branch.as_deref(), - pull_request: self.pull_request, + pull_request: self.pull_request.as_ref(), status_span: self.status_span(), }, width, diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index dd82c75f..ccb64e6f 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -66,9 +66,9 @@ impl StatusLine { Self::segment_style(theme, SegmentStyle::Accent), ) }), - StatusLineSegment::PullRequest => state.pull_request.map(|number| { + StatusLineSegment::PullRequest => state.pull_request.map(|pr| { Span::styled( - format!("#{number}"), + format!("#{}", pr.number), Self::segment_style(theme, SegmentStyle::Accent), ) }), @@ -121,7 +121,7 @@ pub(super) struct StatusLineState<'a> { /// Already tilde-expanded, so the renderer must not substitute `~` again. pub(super) cwd: &'a str, pub(super) git_branch: Option<&'a str>, - pub(super) pull_request: Option, + pub(super) pull_request: Option<&'a crate::util::git::PullRequest>, /// Pre-rendered run-state segment from the parent component. pub(super) status_span: Span<'static>, } @@ -263,6 +263,10 @@ mod tests { use super::*; fn render_text(segments: Vec, width: u16) -> String { + let pr = crate::util::git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }; let line = StatusLine::new(segments).render( &Theme::default(), &StatusLineState { @@ -276,7 +280,7 @@ mod tests { }), cwd: "~/repo", git_branch: Some("main"), - pull_request: Some(86), + pull_request: Some(&pr), status_span: Span::raw("Ready"), }, width, diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index ab6edb10..fdadb425 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -56,15 +56,22 @@ fn parse_branch(stdout: &[u8]) -> Option { } } -/// Probe the open pull request for `cwd`'s current branch via `gh pr view --json number --jq -/// .number`. Returns `None` when `gh` is missing, the user is unauthenticated, or no PR is open. -pub(crate) fn current_pull_request(cwd: &Path) -> Option { +/// Open pull request for the current branch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PullRequest { + pub(crate) number: u64, + pub(crate) url: String, +} + +/// Probe the open pull request for `cwd`'s current branch via `gh pr view --json number,url`. +/// Returns `None` when `gh` is missing, the user is unauthenticated, or no PR is open. +pub(crate) fn current_pull_request(cwd: &Path) -> Option { let Some(cwd_str) = cwd.to_str() else { debug!(cwd = ?cwd, "gh pr probe: cwd is not valid UTF-8"); return None; }; let output = match Command::new("gh") - .args(["pr", "view", "--json", "number", "--jq", ".number"]) + .args(["pr", "view", "--json", "number,url"]) .current_dir(cwd_str) .output() { @@ -83,11 +90,17 @@ pub(crate) fn current_pull_request(cwd: &Path) -> Option { ); return None; } - parse_pr_number(&output.stdout) + parse_pull_request(&output.stdout) } -fn parse_pr_number(stdout: &[u8]) -> Option { - std::str::from_utf8(stdout).ok()?.trim().parse().ok() +fn parse_pull_request(stdout: &[u8]) -> Option { + let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; + let number = value.get("number")?.as_u64()?; + let url = value.get("url")?.as_str()?.to_owned(); + if url.is_empty() { + return None; + } + Some(PullRequest { number, url }) } /// First non-blank stderr line, capped to keep log records terse. Surfaces the actionable signal @@ -165,16 +178,23 @@ mod tests { assert_eq!(current_pull_request(dir.path()), None); } - // ── parse_pr_number ── + // ── parse_pull_request ── #[test] - fn parse_pr_number_keeps_positive_integers_and_drops_everything_else() { - assert_eq!(parse_pr_number(b"86\n"), Some(86)); - assert_eq!(parse_pr_number(b" 12\n"), Some(12)); - assert_eq!(parse_pr_number(b""), None); - assert_eq!(parse_pr_number(b"not-a-number\n"), None); - assert_eq!(parse_pr_number(b"-1\n"), None); - assert_eq!(parse_pr_number(&[0xff, b'\n']), None); + fn parse_pull_request_extracts_number_and_url() { + assert_eq!( + parse_pull_request(br#"{"number":86,"url":"https://github.com/o/r/pull/86"}"#), + Some(PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }), + ); + assert_eq!(parse_pull_request(b""), None); + assert_eq!(parse_pull_request(b"not json"), None); + assert_eq!(parse_pull_request(br#"{"number":86}"#), None); + assert_eq!(parse_pull_request(br#"{"url":"https://x"}"#), None); + assert_eq!(parse_pull_request(br#"{"number":86,"url":""}"#), None); + assert_eq!(parse_pull_request(br#"{"number":-1,"url":"x"}"#), None); } // ── stderr_summary ── From a210cbebb40f0e07cf4dafa42a80011a22ce78ff Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 17:11:45 +0800 Subject: [PATCH 02/35] feat(tui): route left-click on the jump overlay to scroll-to-bottom 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. --- crates/oxide-code/src/tui/app.rs | 98 +++++++++++++++++++- crates/oxide-code/src/tui/components/chat.rs | 42 ++++++++- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index b31627af..d73e5bae 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -4,12 +4,15 @@ //! loop multiplexing crossterm events, agent events, user actions, and a 60 FPS render tick. //! A dirty flag coalesces redraws so renders fire per state change rather than per event. +use std::cell::Cell; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; -use crossterm::event::{Event, EventStream, KeyCode, KeyEvent}; +use crossterm::event::{ + Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind, +}; use futures::{Stream, StreamExt}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::text::{Line, Span}; @@ -61,6 +64,10 @@ pub(crate) struct App { modals: ModalStack, /// Theme saved when a `/theme` picker opens. Restored if the modal cancels. preview_theme_snapshot: Option, + /// Rect of the jump-to-bottom pill on the most recent frame, set by `render_jump_overlay` + /// and read by `handle_crossterm_event` for left-click hit-testing. `None` while the pill + /// is hidden (auto-scroll on, viewport too narrow, or content fits). + jump_overlay_rect: Cell>, should_quit: bool, dirty: bool, } @@ -113,6 +120,7 @@ impl App { pending_prompts: VecDeque::new(), modals: ModalStack::new(), preview_theme_snapshot: None, + jump_overlay_rect: Cell::new(None), should_quit: false, dirty: true, } @@ -206,8 +214,8 @@ impl App { self.chat.handle_event(event); } } - Event::Mouse(..) => { - self.chat.handle_event(event); + Event::Mouse(mouse) => { + self.handle_mouse_event(event, *mouse); } Event::Resize(..) => {} _ => return, @@ -215,6 +223,19 @@ impl App { self.dirty = true; } + /// Routes a mouse event in priority order: hit-test app-owned overlays first, then fall + /// through to chat for wheel scroll. Returns silently when the click misses every target. + fn handle_mouse_event(&mut self, event: &Event, mouse: MouseEvent) { + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind + && let Some(rect) = self.jump_overlay_rect.get() + && rect_contains(rect, mouse.column, mouse.row) + { + self.chat.jump_to_bottom(); + return; + } + self.chat.handle_event(event); + } + fn apply_modal_action(&mut self, action: ModalAction) { match action { // Cancel: revert any in-flight theme preview to the snapshot taken on open. @@ -748,6 +769,7 @@ impl App { fn render_jump_overlay(&self, frame: &mut ratatui::Frame<'_>, area: Rect) { if !self.chat.is_scrolled_up() || area.width < 25 || area.height == 0 { + self.jump_overlay_rect.set(None); return; } @@ -762,6 +784,7 @@ impl App { // opaque surface bg so the chat content underneath stays readable. let pill_width = u16::try_from(label.width().saturating_add(2)).unwrap_or(u16::MAX); if pill_width > area.width { + self.jump_overlay_rect.set(None); return; } let pill = Rect { @@ -770,6 +793,7 @@ impl App { width: pill_width, height: 1, }; + self.jump_overlay_rect.set(Some(pill)); let block = Block::default().style(self.theme.surface()); let inner = block.inner(pill); frame.render_widget(block, pill); @@ -796,6 +820,16 @@ fn jump_overlay_label(new_count: u32, width: usize) -> String { center_truncate_to_width(&label, width.saturating_sub(2)) } +/// Cell-coordinate hit test: `Rect::contains` exists on ratatui but takes a `Position` whose +/// constructor we don't already use elsewhere; this avoids the wrapper boilerplate at every +/// call site. +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + col >= rect.x + && col < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) +} + /// Renders a queued prompt as a dim ghost, capped at `body_width` columns. fn preview_line(prompt: &str, theme: &Theme, body_width: usize) -> Line<'static> { use ratatui::style::Modifier; @@ -1266,7 +1300,7 @@ mod tests { #[test] fn handle_crossterm_mouse_is_forwarded_to_chat() { - // Mouse events reach `ChatView::handle_event` for scroll; the dirty flag must flip. + // Wheel events still reach `ChatView::handle_event` for scroll; the dirty flag must flip. let (mut app, _rx, _agent_tx) = test_app(None); app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, @@ -1277,6 +1311,62 @@ mod tests { assert!(app.dirty); } + #[test] + fn left_click_on_jump_overlay_jumps_chat_to_bottom() { + // Cache a known jump-overlay rect, scroll the chat up, and confirm a left-click inside + // the rect snaps back to bottom and re-arms auto-scroll. + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat.content_height_for_test().set(100); + app.chat.set_viewport_for_test(20); + app.chat.set_scroll_offset_for_test(10); + app.chat.set_auto_scroll_for_test(false); + app.jump_overlay_rect.set(Some(Rect::new(60, 23, 18, 1))); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 70, + row: 23, + modifiers: KeyModifiers::NONE, + })); + + assert_eq!(app.chat.scroll_offset_for_test(), 80); + assert!(app.chat.auto_scroll_for_test()); + assert!(app.dirty); + } + + #[test] + fn left_click_outside_jump_overlay_does_not_jump_chat() { + // A click on the chat area but outside the cached pill rect must not snap to bottom. + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat.content_height_for_test().set(100); + app.chat.set_viewport_for_test(20); + app.chat.set_scroll_offset_for_test(10); + app.chat.set_auto_scroll_for_test(false); + app.jump_overlay_rect.set(Some(Rect::new(60, 23, 18, 1))); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + })); + + assert_eq!(app.chat.scroll_offset_for_test(), 10); + assert!(!app.chat.auto_scroll_for_test()); + } + + #[test] + fn rect_contains_left_top_inclusive_right_bottom_exclusive() { + let rect = Rect::new(2, 3, 4, 2); + assert!(rect_contains(rect, 2, 3), "left-top corner is inside"); + assert!(rect_contains(rect, 5, 4), "last cell inside"); + assert!(!rect_contains(rect, 1, 3), "left of x"); + assert!(!rect_contains(rect, 6, 3), "past right edge"); + assert!(!rect_contains(rect, 2, 5), "past bottom edge"); + assert!(!rect_contains(Rect::new(0, 0, 0, 1), 0, 0), "zero-width"); + assert!(!rect_contains(Rect::new(0, 0, 1, 0), 0, 0), "zero-height"); + } + #[test] fn handle_crossterm_resize_schedules_dirty_for_relayout() { // Resize falls through to `dirty = true` so the next tick re-splits with the new area. diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index bf2bd1db..4c537a5a 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -317,6 +317,36 @@ impl ChatView { self.blocks.last().and_then(|b| b.system_text()) } + #[cfg(test)] + pub(crate) fn scroll_offset_for_test(&self) -> u16 { + self.scroll_offset + } + + #[cfg(test)] + pub(crate) fn auto_scroll_for_test(&self) -> bool { + self.auto_scroll + } + + #[cfg(test)] + pub(crate) fn set_scroll_offset_for_test(&mut self, offset: u16) { + self.scroll_offset = offset; + } + + #[cfg(test)] + pub(crate) fn set_viewport_for_test(&mut self, height: u16) { + self.viewport_height = height; + } + + #[cfg(test)] + pub(crate) fn set_auto_scroll_for_test(&mut self, on: bool) { + self.auto_scroll = on; + } + + #[cfg(test)] + pub(crate) fn content_height_for_test(&self) -> &Cell { + &self.content_height + } + /// Updates cached viewport size and syncs scroll. `true` if auto-scroll moved the offset. #[must_use] pub(crate) fn update_layout(&mut self, area: Rect) -> bool { @@ -372,10 +402,7 @@ impl ChatView { code: KeyCode::End, modifiers: KeyModifiers::CONTROL, .. - }) => { - self.scroll_to_bottom(); - self.auto_scroll = true; - } + }) => self.jump_to_bottom(), _ => {} } } @@ -394,6 +421,13 @@ impl ChatView { // ── Private Helpers ── impl ChatView { + /// Scrolls to the latest content and re-arms auto-scroll. Fires from `Ctrl+End` and from + /// a left-click on the jump-to-bottom overlay. + pub(crate) fn jump_to_bottom(&mut self) { + self.scroll_to_bottom(); + self.auto_scroll = true; + } + fn scroll_to_bottom(&mut self) { self.scroll_offset = self .content_height From c5fe7e86c8f185158ee52fca26da3c9c28fdabf8 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 17:15:57 +0800 Subject: [PATCH 03/35] feat(tui): render PR status segment as an OSC 8 hyperlink 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 hyperlink` so the wrapper-vs-plain split lives at the segment level instead of inside the line builder. --- .../src/tui/components/status/line.rs | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index ccb64e6f..13065fab 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -44,7 +44,13 @@ impl StatusLine { if !first { spans.push(sep.clone()); } - spans.push(segment.span); + if let Some(url) = segment.hyperlink { + spans.push(osc8_open(&url)); + spans.push(segment.span); + spans.push(osc8_close()); + } else { + spans.push(segment.span); + } first = false; } Line::from(spans) @@ -55,6 +61,7 @@ impl StatusLine { theme: &Theme, state: &StatusLineState<'_>, ) -> Option { + let mut hyperlink: Option = None; let span = match segment { StatusLineSegment::CurrentDir => non_empty_span( truncate_to_width(state.cwd, MAX_CURRENT_DIR_WIDTH), @@ -67,6 +74,7 @@ impl StatusLine { ) }), StatusLineSegment::PullRequest => state.pull_request.map(|pr| { + hyperlink = Some(pr.url.clone()); Span::styled( format!("#{}", pr.number), Self::segment_style(theme, SegmentStyle::Accent), @@ -100,7 +108,7 @@ impl StatusLine { Self::segment_style(theme, SegmentStyle::Dim), )), }?; - Some(RenderedSegment::new(segment, span)) + Some(RenderedSegment::new(segment, span, hyperlink)) } fn segment_style(theme: &Theme, style: SegmentStyle) -> ratatui::style::Style { @@ -138,11 +146,17 @@ enum SegmentStyle { struct RenderedSegment { segment: StatusLineSegment, span: Span<'static>, + /// URL to wrap the visible span in an OSC 8 hyperlink. Empty when the segment is plain text. + hyperlink: Option, } impl RenderedSegment { - fn new(segment: StatusLineSegment, span: Span<'static>) -> Self { - Self { segment, span } + fn new(segment: StatusLineSegment, span: Span<'static>, hyperlink: Option) -> Self { + Self { + segment, + span, + hyperlink, + } } fn width(&self) -> usize { @@ -206,6 +220,17 @@ fn non_empty_span(label: String, style: ratatui::style::Style) -> Option Span<'static> { + Span::raw(format!("\x1b]8;;{url}\x1b\\")) +} + +fn osc8_close() -> Span<'static> { + Span::raw("\x1b]8;;\x1b\\") +} + fn model_with_effort(model: &str, effort: Option) -> String { match effort { Some(effort) => format!("{model} ({effort})"), @@ -381,11 +406,45 @@ mod tests { "wide width keeps every segment: {full}", ); // Width 22 forces time (utility 1) to drop before PR (2) and branch (3). Width 14 - // narrows further until only branch and run state remain. - assert_eq!(render_text(segments.clone(), 22), " main │ #86 │ Ready"); + // narrows further until only branch and run state remain. The PR span is wrapped in + // OSC 8 hyperlink bytes that `unicode_width` measures as zero, so column math is + // unaffected even though the printed string carries the escape sequence. + let pr_open = "\x1b]8;;https://github.com/o/r/pull/86\x1b\\"; + let pr_close = "\x1b]8;;\x1b\\"; + assert_eq!( + render_text(segments.clone(), 22), + format!(" main │ {pr_open}#86{pr_close} │ Ready"), + ); assert_eq!(render_text(segments, 14), " main │ Ready"); } + #[test] + fn pull_request_segment_emits_osc8_open_and_close_around_visible_text() { + let rendered = render_text(vec![StatusLineSegment::PullRequest], 80); + let url = "https://github.com/o/r/pull/86"; + assert!(rendered.contains(&format!("\x1b]8;;{url}\x1b\\#86\x1b]8;;\x1b\\"))); + } + + #[test] + fn pull_request_segment_skips_osc8_when_absent() { + let rendered = StatusLine::new(vec![StatusLineSegment::PullRequest]).render( + &Theme::default(), + &StatusLineState { + model: "m", + effort: None, + title: None, + usage: None, + cwd: "~/repo", + git_branch: None, + pull_request: None, + status_span: Span::raw("Ready"), + }, + 80, + ); + let text: String = rendered.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(!text.contains("\x1b]8;;"), "no OSC 8 bytes when PR absent"); + } + // ── context_label ── #[test] From 982ea587737742f8a7330d9be3cbcad1b6066f5e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 17:20:04 +0800 Subject: [PATCH 04/35] feat(tui): add selection theme slot for mouse-drag highlight 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. --- crates/oxide-code/src/tui/theme.rs | 2 ++ crates/oxide-code/themes/frappe.toml | 1 + crates/oxide-code/themes/latte.toml | 1 + crates/oxide-code/themes/macchiato.toml | 1 + crates/oxide-code/themes/material.toml | 1 + crates/oxide-code/themes/mocha.toml | 1 + 6 files changed, 7 insertions(+) diff --git a/crates/oxide-code/src/tui/theme.rs b/crates/oxide-code/src/tui/theme.rs index 65c048f1..ece83ad6 100644 --- a/crates/oxide-code/src/tui/theme.rs +++ b/crates/oxide-code/src/tui/theme.rs @@ -100,6 +100,7 @@ macro_rules! for_each_slot { (border_focused, "Focused component border"), (border_unfocused, "Unfocused component border (default-aligned with `dim`)"), (separator, "Status-line segment separator"), + (selection, "Mouse-drag selection highlight (bg-only)"), } }; } @@ -328,6 +329,7 @@ mod tests { assert_ne!(t.user.fg, t.assistant.fg); assert_ne!(t.success.fg, t.error.fg); assert_ne!(t.diff_add.bg, t.diff_del.bg); + assert!(t.selection.bg.is_some(), "selection slot must define a bg"); } // ── Style helpers ── diff --git a/crates/oxide-code/themes/frappe.toml b/crates/oxide-code/themes/frappe.toml index 78fba13a..e51a6e22 100644 --- a/crates/oxide-code/themes/frappe.toml +++ b/crates/oxide-code/themes/frappe.toml @@ -52,3 +52,4 @@ tool_icon = "#8caaee" border_focused = "#8caaee" border_unfocused = "#626880" separator = "#626880" +selection = { bg = "#51576d" } diff --git a/crates/oxide-code/themes/latte.toml b/crates/oxide-code/themes/latte.toml index 89a96e62..55218543 100644 --- a/crates/oxide-code/themes/latte.toml +++ b/crates/oxide-code/themes/latte.toml @@ -52,3 +52,4 @@ tool_icon = "#1e66f5" border_focused = "#1e66f5" border_unfocused = "#acb0be" separator = "#acb0be" +selection = { bg = "#bcc0cc" } diff --git a/crates/oxide-code/themes/macchiato.toml b/crates/oxide-code/themes/macchiato.toml index 69a45975..cc44c595 100644 --- a/crates/oxide-code/themes/macchiato.toml +++ b/crates/oxide-code/themes/macchiato.toml @@ -52,3 +52,4 @@ tool_icon = "#8aadf4" border_focused = "#8aadf4" border_unfocused = "#5b6078" separator = "#5b6078" +selection = { bg = "#494d64" } diff --git a/crates/oxide-code/themes/material.toml b/crates/oxide-code/themes/material.toml index f616cb56..bf1f927f 100644 --- a/crates/oxide-code/themes/material.toml +++ b/crates/oxide-code/themes/material.toml @@ -56,3 +56,4 @@ tool_icon = "#bb86fc" border_focused = "#bb86fc" border_unfocused = "#424242" separator = "#424242" +selection = { bg = "#3c3c3c" } diff --git a/crates/oxide-code/themes/mocha.toml b/crates/oxide-code/themes/mocha.toml index 7b6f9775..19c2d8d8 100644 --- a/crates/oxide-code/themes/mocha.toml +++ b/crates/oxide-code/themes/mocha.toml @@ -52,3 +52,4 @@ tool_icon = "#89b4fa" border_focused = "#89b4fa" border_unfocused = "#585b70" separator = "#585b70" +selection = { bg = "#45475a" } From 2dd5b2af657e09e5bac86f4f17cb71206c8ac8d1 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 17:34:59 +0800 Subject: [PATCH 05/35] feat(tui): copy-on-select with OSC 52 over chat 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;\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. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/oxide-code/Cargo.toml | 1 + crates/oxide-code/src/tui.rs | 1 + crates/oxide-code/src/tui/app.rs | 123 +++++- crates/oxide-code/src/tui/components/chat.rs | 12 + crates/oxide-code/src/tui/selection.rs | 387 +++++++++++++++++++ 7 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 crates/oxide-code/src/tui/selection.rs diff --git a/Cargo.lock b/Cargo.lock index 86d6f9b2..8d248e77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,6 +1575,7 @@ name = "oxide-code" version = "0.1.0-alpha.1" dependencies = [ "anyhow", + "base64", "clap", "crossterm", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 8b8fff54..cda3397d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ pedantic = { level = "warn", priority = -1 } [workspace.dependencies] anyhow = "1" +base64 = "0.22" clap = { version = "4", features = ["derive"] } crossterm = { version = "0.29", features = ["event-stream"] } dirs = "6" diff --git a/crates/oxide-code/Cargo.toml b/crates/oxide-code/Cargo.toml index fa3791ac..7915d275 100644 --- a/crates/oxide-code/Cargo.toml +++ b/crates/oxide-code/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow.workspace = true +base64.workspace = true clap.workspace = true crossterm.workspace = true dirs.workspace = true diff --git a/crates/oxide-code/src/tui.rs b/crates/oxide-code/src/tui.rs index d8389272..3d57e942 100644 --- a/crates/oxide-code/src/tui.rs +++ b/crates/oxide-code/src/tui.rs @@ -12,6 +12,7 @@ pub(crate) mod glyphs; pub(crate) mod markdown; pub(crate) mod modal; pub(crate) mod pending_calls; +pub(crate) mod selection; pub(crate) mod terminal; pub(crate) mod theme; pub(crate) mod wrap; diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index d73e5bae..a116f724 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -68,6 +68,10 @@ pub(crate) struct App { /// and read by `handle_crossterm_event` for left-click hit-testing. `None` while the pill /// is hidden (auto-scroll on, viewport too narrow, or content fits). jump_overlay_rect: Cell>, + /// Rect of the chat area on the most recent frame, set by `draw_frame`. Used as the bound + /// for mouse-drag text selection. + chat_rect: Cell>, + selection: super::selection::Selection, should_quit: bool, dirty: bool, } @@ -121,6 +125,8 @@ impl App { modals: ModalStack::new(), preview_theme_snapshot: None, jump_overlay_rect: Cell::new(None), + chat_rect: Cell::new(None), + selection: super::selection::Selection::default(), should_quit: false, dirty: true, } @@ -223,17 +229,65 @@ impl App { self.dirty = true; } - /// Routes a mouse event in priority order: hit-test app-owned overlays first, then fall - /// through to chat for wheel scroll. Returns silently when the click misses every target. + /// Routes a mouse event in priority order: jump-overlay click → drag-selection state machine + /// → wheel scroll on chat. Each branch returns silently when the click misses every target. fn handle_mouse_event(&mut self, event: &Event, mouse: MouseEvent) { - if let MouseEventKind::Down(MouseButton::Left) = mouse.kind - && let Some(rect) = self.jump_overlay_rect.get() - && rect_contains(rect, mouse.column, mouse.row) - { - self.chat.jump_to_bottom(); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(rect) = self.jump_overlay_rect.get() + && rect_contains(rect, mouse.column, mouse.row) + { + self.chat.jump_to_bottom(); + return; + } + if let Some(chat_rect) = self.chat_rect.get() + && rect_contains(chat_rect, mouse.column, mouse.row) + { + self.selection.begin(mouse.column, mouse.row); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if self.selection.is_dragging() { + let clamped_col = self.chat_rect.get().map_or(mouse.column, |r| { + mouse.column.clamp(r.x, r.x + r.width.saturating_sub(1)) + }); + let clamped_row = self.chat_rect.get().map_or(mouse.row, |r| { + mouse.row.clamp(r.y, r.y + r.height.saturating_sub(1)) + }); + self.selection.update(clamped_col, clamped_row); + } + } + MouseEventKind::Up(MouseButton::Left) => { + if self.selection.is_dragging() { + self.copy_selection_to_clipboard(); + _ = self.selection.clear(); + } + } + _ => { + self.chat.handle_event(event); + } + } + } + + /// Materializes the current selection from the chat's rendered text and emits an OSC 52 + /// set-clipboard sequence. No-op when there's no selection or when materialization clips out. + fn copy_selection_to_clipboard(&mut self) { + let Some(area) = self.chat_rect.get() else { + return; + }; + let text = self.chat.rendered_text(area.width); + let scroll = self.chat.scroll_offset(); + let Some(payload) = self.selection.materialize(&text, area, scroll) else { return; + }; + let (sequence, truncated) = super::selection::osc52_set_clipboard(&payload); + if let Err(error) = std::io::Write::write_all(&mut std::io::stdout(), &sequence) { + tracing::debug!(?error, "OSC 52 write failed"); + } + if truncated { + self.chat + .push_system_message("Selection truncated to ~8 KB for the clipboard.".to_owned()); } - self.chat.handle_event(event); } fn apply_modal_action(&mut self, action: ModalAction) { @@ -715,11 +769,15 @@ impl App { self.status_bar.render(frame, chunks[0]); if self.chat.is_empty() && self.session_info.config.show_welcome { + self.chat_rect.set(None); let snap = WelcomeSnapshot::from_live(&self.session_info); welcome::paint(frame, chunks[1], &self.theme, &snap); } else { self.chat.render(frame, chunks[1]); self.render_jump_overlay(frame, chunks[1]); + self.chat_rect.set(Some(chunks[1])); + self.selection + .paint(frame.buffer_mut(), chunks[1], &self.theme); } if preview_height > 0 { self.render_preview(frame, chunks[2]); @@ -1367,6 +1425,55 @@ mod tests { assert!(!rect_contains(Rect::new(0, 0, 1, 0), 0, 0), "zero-height"); } + #[test] + fn drag_in_chat_area_arms_selection_state_machine() { + // Down → Drag → Up over the cached chat rect must traverse the selection states. + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat_rect.set(Some(Rect::new(0, 2, 80, 20))); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + })); + assert!(app.selection.is_dragging(), "down arms drag"); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 12, + row: 7, + modifiers: KeyModifiers::NONE, + })); + assert!(app.selection.is_dragging(), "drag updates endpoint"); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 12, + row: 7, + modifiers: KeyModifiers::NONE, + })); + assert!( + !app.selection.is_dragging(), + "up clears the in-flight selection" + ); + } + + #[test] + fn left_click_outside_chat_area_does_not_arm_selection() { + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat_rect.set(Some(Rect::new(0, 2, 80, 20))); + + // Click in the status-bar row 0 (above chat_rect.y == 2). + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 0, + modifiers: KeyModifiers::NONE, + })); + assert!(!app.selection.is_dragging()); + } + #[test] fn handle_crossterm_resize_schedules_dirty_for_relayout() { // Resize falls through to `dirty = true` so the next tick re-splits with the new area. diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 4c537a5a..f327e1be 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -416,6 +416,18 @@ impl ChatView { .scroll((self.scroll_offset, 0)); frame.render_widget(paragraph, area); } + + /// Current scroll offset in content rows. Used by [`crate::tui::selection`] to translate a + /// selection's screen-row coordinates into chat-content row indices. + pub(crate) fn scroll_offset(&self) -> u16 { + self.scroll_offset + } + + /// Wrapped `Text` for the given viewport width. Materializes the same lines `render` paints, + /// so [`crate::tui::selection`] can extract substrings consistent with what's on screen. + pub(crate) fn rendered_text(&self, width: u16) -> Text<'static> { + self.build_text(width) + } } // ── Private Helpers ── diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs new file mode 100644 index 00000000..e52dd787 --- /dev/null +++ b/crates/oxide-code/src/tui/selection.rs @@ -0,0 +1,387 @@ +//! Mouse-drag text selection over the chat viewport. +//! +//! [`Selection`] is a small state machine driven by the app's mouse handler. It tracks the start +//! and current cell coordinates of a left-button drag, materializes the selected text from the +//! chat's rendered `Text` buffer, and writes the result to the system clipboard via OSC 52. +//! +//! Selection geometry follows terminal convention: from `(start_row, start_col)` the selection +//! extends to the end of that row, all of every intermediate row, and from column 0 to +//! `(end_row, end_col)` on the final row. Block / column selection is deferred. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Text; +use unicode_width::UnicodeWidthChar; + +use crate::tui::theme::Theme; + +/// Conservative pre-base64 cap. xterm's OSC budget is ~8 KB; kitty / iTerm2 are larger. Pick the +/// floor so the same selection works everywhere. +const OSC52_PAYLOAD_BYTES: usize = 8 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Cell { + col: u16, + row: u16, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) enum Selection { + #[default] + Idle, + /// Left button pressed; `end` updates on every drag event. + Dragging { start: Cell, end: Cell }, +} + +impl Selection { + pub(crate) fn is_dragging(&self) -> bool { + matches!(self, Selection::Dragging { .. }) + } + + /// Begins a drag. A subsequent `update` with the same coordinates leaves the selection empty, + /// so the click-vs-drag distinction is "did `end` move from `start` before mouse-up?". + pub(crate) fn begin(&mut self, col: u16, row: u16) { + let cell = Cell { col, row }; + *self = Selection::Dragging { + start: cell, + end: cell, + }; + } + + /// Updates the drag endpoint. No-op when not currently dragging. + pub(crate) fn update(&mut self, col: u16, row: u16) { + if let Selection::Dragging { end, .. } = self { + *end = Cell { col, row }; + } + } + + /// Clears any in-flight drag. Returns the prior state so callers can finalize before clearing. + pub(crate) fn clear(&mut self) -> Selection { + std::mem::replace(self, Selection::Idle) + } + + /// Returns the normalized `(start, end)` cells with `start` always above-or-left of `end`. + /// Returns `None` when not dragging or when start == end (a click, not a drag). + fn normalized(&self) -> Option<(Cell, Cell)> { + match self { + Selection::Dragging { start, end } if start != end => { + let (a, b) = if (start.row, start.col) <= (end.row, end.col) { + (*start, *end) + } else { + (*end, *start) + }; + Some((a, b)) + } + _ => None, + } + } + + /// Materializes the selected text from `text` clipped to `area` with `scroll_offset`. Returns + /// `None` when there's no selection or when the drag misses the chat area entirely. + pub(crate) fn materialize( + &self, + text: &Text<'_>, + area: Rect, + scroll_offset: u16, + ) -> Option { + let (start, end) = self.normalized()?; + let row_range = clip_rows(start, end, area)?; + let mut out = String::new(); + for screen_row in row_range { + let line_idx = usize::from(scroll_offset) + usize::from(screen_row - area.y); + let Some(line) = text.lines.get(line_idx) else { + break; + }; + let (col_start, col_end) = + row_columns(screen_row, start, end, area.x, area.x + area.width); + let segment = slice_line(line, col_start - area.x, col_end - area.x); + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&segment); + } + if out.is_empty() { None } else { Some(out) } + } + + /// Highlights selected cells in `buf` using `theme.selection`. Cells outside the chat `area` + /// are clipped. No-op when not dragging. + pub(crate) fn paint(&self, buf: &mut Buffer, area: Rect, theme: &Theme) { + let Some((start, end)) = self.normalized() else { + return; + }; + let Some(rows) = clip_rows(start, end, area) else { + return; + }; + let style = theme.selection.style(); + for row in rows { + let (col_start, col_end) = row_columns(row, start, end, area.x, area.x + area.width); + for col in col_start..col_end { + if col < buf.area.x + buf.area.width && row < buf.area.y + buf.area.height { + buf[(col, row)].set_style(style); + } + } + } + } +} + +/// Clip the selection's row span to `area`. `None` when entirely above or below. +fn clip_rows(start: Cell, end: Cell, area: Rect) -> Option> { + let area_top = area.y; + let area_bottom = area.y + area.height; + let lo = start.row.max(area_top); + let hi = (end.row + 1).min(area_bottom); + if lo >= hi { None } else { Some(lo..hi) } +} + +/// Per-row column range. The first row starts at `start.col`, the last row ends at `end.col + 1`, +/// every other row spans the full chat-area width. +fn row_columns(row: u16, start: Cell, end: Cell, area_left: u16, area_right: u16) -> (u16, u16) { + let lo = if row == start.row { + start.col.max(area_left) + } else { + area_left + }; + let hi = if row == end.row { + (end.col + 1).min(area_right) + } else { + area_right + }; + (lo.min(area_right), hi.min(area_right)) +} + +/// Walks the line's spans to extract the slice between cell columns `[col_start, col_end)`. +/// Wide chars (`UnicodeWidthChar::width == 2`) are taken whole when their leading half lands +/// inside `[col_start, col_end)` so multi-byte sequences never split. +fn slice_line(line: &ratatui::text::Line<'_>, col_start: u16, col_end: u16) -> String { + let mut out = String::new(); + let mut col: u16 = 0; + 'spans: for span in &line.spans { + for ch in span.content.chars() { + let w = u16::try_from(UnicodeWidthChar::width(ch).unwrap_or(0)).unwrap_or(0); + let next = col.saturating_add(w); + if col >= col_end { + break 'spans; + } + if next > col_start && w > 0 { + out.push(ch); + } + col = next; + } + } + out +} + +/// OSC 52 set-clipboard sequence. `c` selects the system clipboard (xterm `selection`). +/// Returns the bytes plus a flag indicating whether the payload was clamped. +pub(crate) fn osc52_set_clipboard(text: &str) -> (Vec, bool) { + let bytes = text.as_bytes(); + let (clipped, truncated) = if bytes.len() > OSC52_PAYLOAD_BYTES { + ( + &bytes[..floor_to_char_boundary(text, OSC52_PAYLOAD_BYTES)], + true, + ) + } else { + (bytes, false) + }; + let encoded = BASE64.encode(clipped); + let mut out = Vec::with_capacity(encoded.len() + 8); + out.extend_from_slice(b"\x1b]52;c;"); + out.extend_from_slice(encoded.as_bytes()); + out.push(0x07); + (out, truncated) +} + +/// Largest byte index `<= cap` that lies on a UTF-8 char boundary. Walks back at most 3 bytes. +fn floor_to_char_boundary(s: &str, cap: usize) -> usize { + let mut i = cap.min(s.len()); + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + +#[cfg(test)] +mod tests { + use ratatui::style::Color; + use ratatui::text::{Line, Span}; + + use super::*; + + fn cell(col: u16, row: u16) -> Cell { + Cell { col, row } + } + + // ── Selection state ── + + #[test] + fn begin_then_update_tracks_drag_endpoint() { + let mut s = Selection::default(); + s.begin(2, 3); + s.update(7, 5); + assert_eq!( + s, + Selection::Dragging { + start: cell(2, 3), + end: cell(7, 5), + } + ); + assert!(s.is_dragging()); + } + + #[test] + fn clear_returns_prior_state_and_resets_to_idle() { + let mut s = Selection::default(); + s.begin(0, 0); + let prior = s.clear(); + assert!(matches!(prior, Selection::Dragging { .. })); + assert_eq!(s, Selection::Idle); + } + + #[test] + fn normalized_orders_start_before_end() { + let mut s = Selection::default(); + s.begin(7, 5); + s.update(2, 3); + let (start, end) = s.normalized().expect("normalizes despite reversed drag"); + assert_eq!((start.col, start.row), (2, 3)); + assert_eq!((end.col, end.row), (7, 5)); + } + + #[test] + fn normalized_is_none_for_zero_length_drag() { + let mut s = Selection::default(); + s.begin(4, 4); + s.update(4, 4); + assert!(s.normalized().is_none(), "click without drag"); + } + + // ── Materialization (line-rect) ── + + fn fixture_text() -> Text<'static> { + Text::from(vec![ + Line::from("hello world"), + Line::from("second line"), + Line::from("third"), + ]) + } + + #[test] + fn materialize_single_row_returns_substring() { + let mut s = Selection::default(); + s.begin(2, 0); + s.update(7, 0); + let area = Rect::new(0, 0, 80, 3); + assert_eq!(s.materialize(&fixture_text(), area, 0).unwrap(), "llo wo"); + } + + #[test] + fn materialize_multi_row_joins_with_newlines() { + let mut s = Selection::default(); + s.begin(6, 0); + s.update(5, 1); + let area = Rect::new(0, 0, 80, 3); + assert_eq!( + s.materialize(&fixture_text(), area, 0).unwrap(), + "world\nsecond", + ); + } + + #[test] + fn materialize_respects_scroll_offset() { + let mut s = Selection::default(); + s.begin(0, 0); + s.update(4, 0); + let area = Rect::new(0, 0, 80, 3); + // scroll_offset 2 puts content row "third" at screen row 0. + assert_eq!(s.materialize(&fixture_text(), area, 2).unwrap(), "third"); + } + + #[test] + fn materialize_clips_to_area_bounds() { + let mut s = Selection::default(); + s.begin(0, 5); + s.update(99, 99); + let area = Rect::new(0, 0, 80, 3); + // Drag starts below the chat area entirely. + assert!(s.materialize(&fixture_text(), area, 0).is_none()); + } + + // ── CJK round-trip ── + + #[test] + fn slice_line_keeps_cjk_chars_whole_at_each_boundary() { + let line = Line::from("Hello 你好 World"); + // Columns: H=0 e=1 l=2 l=3 o=4 ' '=5 你=6,7(wide) 好=8,9(wide) ' '=10 W=11 ... + // Slice from col 6 to col 10 should include both CJK chars in full. + assert_eq!(slice_line(&line, 6, 10), "你好"); + // Slice that begins inside the trailing half of `你` is greedy: the wide char's leading + // half at col 6 contributes content past the 7-col boundary, so the whole char is taken. + assert_eq!(slice_line(&line, 7, 10), "你好"); + // Slice that begins exactly at `好`'s leading half (col 8) skips `你`. + assert_eq!(slice_line(&line, 8, 10), "好"); + } + + #[test] + fn slice_line_handles_emoji_and_mixed_widths() { + let line = Line::from(vec![Span::raw("ab"), Span::raw("好"), Span::raw("c")]); + // a=0 b=1 好=2,3 c=4. Selecting cols 1..=4 should pick "b好c". + assert_eq!(slice_line(&line, 1, 5), "b好c"); + } + + #[test] + fn osc52_round_trips_cjk_bytes() { + let payload = "Hello 你好 🌏 World"; + let (sequence, truncated) = osc52_set_clipboard(payload); + assert!(!truncated); + let prefix = b"\x1b]52;c;"; + let suffix = b"\x07"; + assert!(sequence.starts_with(prefix)); + assert!(sequence.ends_with(suffix)); + let b64 = &sequence[prefix.len()..sequence.len() - suffix.len()]; + let decoded = BASE64.decode(b64).expect("valid base64"); + assert_eq!(decoded, payload.as_bytes(), "UTF-8 round-trip preserved"); + } + + #[test] + fn osc52_clamps_oversize_payload_at_char_boundary() { + let big = "A".repeat(OSC52_PAYLOAD_BYTES + 1024); + let (sequence, truncated) = osc52_set_clipboard(&big); + assert!(truncated); + let prefix = b"\x1b]52;c;"; + let b64 = &sequence[prefix.len()..sequence.len() - 1]; + let decoded = BASE64.decode(b64).unwrap(); + assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); + } + + #[test] + fn floor_to_char_boundary_walks_back_through_multibyte() { + // `好` is 3 bytes (e5 a5 bd). Cap inside the 2nd or 3rd byte must rewind to the start. + let s = "A好B"; + assert_eq!(floor_to_char_boundary(s, 2), 1, "between A and 好"); + assert_eq!(floor_to_char_boundary(s, 3), 1, "inside 好's 2nd byte"); + assert_eq!(floor_to_char_boundary(s, 4), 4, "between 好 and B"); + } + + // ── Painting ── + + #[test] + fn paint_applies_selection_style_to_in_area_cells() { + let area = Rect::new(0, 0, 10, 3); + let mut buf = Buffer::empty(area); + let mut theme = Theme::default(); + theme.selection.bg = Some(Color::Red); + + let mut s = Selection::default(); + s.begin(2, 1); + s.update(5, 1); + s.paint(&mut buf, area, &theme); + + for col in 2..=5 { + assert_eq!(buf[(col, 1)].bg, Color::Red, "col {col} highlighted"); + } + assert_eq!(buf[(1, 1)].bg, Color::Reset, "before selection"); + assert_eq!(buf[(6, 1)].bg, Color::Reset, "after selection"); + } +} From f38dfbd25799d9494a30451d92b75c933041832a Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 17:40:49 +0800 Subject: [PATCH 06/35] docs(tui): design and research notes for mouse interactions 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. --- .cspell/words.txt | 6 ++ CLAUDE.md | 1 + docs/design/README.md | 1 + docs/design/tui/mouse-interactions.md | 117 ++++++++++++++++++++++++ docs/research/README.md | 1 + docs/research/tui/mouse-interactions.md | 84 +++++++++++++++++ 6 files changed, 210 insertions(+) create mode 100644 docs/design/tui/mouse-interactions.md create mode 100644 docs/research/tui/mouse-interactions.md diff --git a/.cspell/words.txt b/.cspell/words.txt index 1e28132f..5375eb3c 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -6,6 +6,7 @@ anthropic anthropics anyhow APFS +arboard atuin cachain catppuccin @@ -32,6 +33,7 @@ ESRCH feff frappe getpwuid +Ghostty gitui hakula impls @@ -40,6 +42,7 @@ insta isatty killpg Kobalte +Konsole latte macchiato misparse @@ -84,6 +87,7 @@ sysname SYSPROMPT tengu Tera +termio thiserror throbber tildified @@ -99,10 +103,12 @@ unresumable unrotated untap urandom +URXVT usize venv vtable writeln xhigh +Xresources xxhash yazi diff --git a/CLAUDE.md b/CLAUDE.md index bf5ecd4c..4fdc2943 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,7 @@ ox # Start an interactive session │ │ ├── list_picker.rs # Generic ListPicker: cursor + render primitive used by concrete pickers │ │ └── searchable_list.rs # Generic SearchableList: substring filter + scrollable viewport for searchable pickers │ ├── pending_calls.rs # Tool-call correlation state for streaming and transcript resume +│ ├── selection.rs # Mouse-drag text selection, line-rect materialization, OSC 52 clipboard write │ ├── snapshots/ # `cargo insta` baselines for full App frame render tests │ ├── terminal.rs # Terminal init / restore, synchronized output, panic hook │ ├── theme.rs # Theme palette (Slot{fg,bg,modifiers} per role) + style helpers + LazyLock-cached Mocha default diff --git a/docs/design/README.md b/docs/design/README.md index 99e7cd51..e1134c58 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -38,5 +38,6 @@ Organized by topic. Each subdirectory mirrors the corresponding directory in [`d | ---------------------------------------------------- | -------------------------------------------------------- | | [Overview](tui/overview.md) | Core stack, rendering strategy, streaming architecture | | [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and mid-turn queued prompts | +| [Mouse Interactions](tui/mouse-interactions.md) | Mouse capture, clickable affordances, copy-on-select | | [Status Line](tui/status-line.md) | Configurable status-line segments and usage display | | [Welcome Screen](tui/welcome.md) | Empty-state renderer, `WelcomeSnapshot`, width ladder | diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md new file mode 100644 index 00000000..ca7123ee --- /dev/null +++ b/docs/design/tui/mouse-interactions.md @@ -0,0 +1,117 @@ +# Mouse Interactions + +Design policy for mouse behavior in the TUI. + +## Goal + +Capture mouse events in service of useful app interactions. The TUI claims wheel scroll, app-owned click affordances (jump-to-bottom pill), and drag-to-select-and-copy. Native terminal selection is preserved through documented escape hatches and through copy-on-select via OSC 52, so users on any modern terminal can copy chat content with a normal drag. + +## Decision + +Keep `crossterm::EnableMouseCapture` enabled in `enter_tui_mode`. The full mode bundle (`?1000`, `?1002`, `?1003`, `?1006`, `?1015`) is required for crossterm to deliver wheel, click, drag, and motion events. + +Route events from `App::handle_crossterm_event` in priority order: + +1. Left-click on the cached jump-to-bottom pill rect → `ChatView::jump_to_bottom`. +2. Left-click inside the cached chat rect → arm the `Selection` state machine. +3. Left-button drag inside the chat rect → update the selection endpoint, clamped to chat bounds. +4. Left-button up → materialize the selection, emit OSC 52 set-clipboard, clear state. +5. Wheel and any other mouse event → `ChatView::handle_event` (wheel scroll up / down). + +## Selection geometry + +Terminal-convention line-rectangle: from `(start_row, start_col)` the selection extends to the end of that row, all of every intermediate row, and from column 0 to `(end_row, end_col)` on the final row. + +Wide CJK chars and emoji (`UnicodeWidthChar::width == 2`) are taken whole when their leading half lands inside the selection range, so multi-byte sequences never split. The slicing walks `Text<'static>` spans tracking `unicode-width` columns rather than byte indices. + +Block / column selection (Alt+drag in some terminals) is deferred. So is double-click word and triple-click line. + +## Visual feedback + +A `selection` theme slot was added. During render, after `ChatView::render` paints the chat into the frame buffer, `Selection::paint` walks the cached selection rect and applies `theme.selection.style()` to each cell. Per-built-in defaults pick a bg-only color one tier above `surface` so the highlight is legible against the chat background without colliding with diff or accent fills. + +## OSC 52 emission + +On left-button up, the app: + +1. Reads `ChatView::rendered_text(width)` to materialize the same wrapped lines that were on screen. +2. Calls `Selection::materialize(text, area, scroll_offset)` to extract the substring (line-rect, unicode-width-aware). +3. Builds `\x1b]52;c;\x07` over the raw UTF-8 bytes via `osc52_set_clipboard`. +4. Writes the bytes to stdout (the terminal forwards them to the OS clipboard when configured). +5. Pushes a system-message warning when the payload was clamped. + +Payload cap is 8 KB pre-base64 (xterm's conservative limit). kitty / iTerm2 / WezTerm tolerate more but the floor keeps the same selection working everywhere. + +Truncation walks back to a UTF-8 char boundary so the encoded string is always valid UTF-8. + +## OSC 8 hyperlinks + +The `pull-request` status segment renders `#NN` wrapped in OSC 8 hyperlink bytes (`\x1b]8;;\x1b\\#NN\x1b]8;;\x1b\\`). Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal) make the number Ctrl-clickable and open it via the user's browser. Older terminals print the bytes verbatim and just show `#NN` as plain text. + +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. + +## Selection escape hatches + +When the app captures mouse events, native terminal drag-select is suppressed by default. Users have several escape hatches: + +- **iTerm2**: hold ⌥ (Option) and drag for native selection. Cmd-click on OSC 8 hyperlinks. +- **WezTerm**: hold Shift and drag. Ctrl-Shift-click for OSC 8 hyperlinks. +- **kitty**: hold Shift and drag. Ctrl-Shift-click for OSC 8. +- **Alacritty**: hold Shift and drag. +- **macOS Terminal.app**: hold ⌥ (Option) and drag. +- **GNOME Terminal / Konsole**: hold Shift and drag. +- **Windows Terminal**: hold Shift and drag. +- **tmux**: `Ctrl-b z` to zoom out, then enter copy-mode (default `Ctrl-b [`) and use copy-mode bindings. + +Copy-on-select via OSC 52 makes this less necessary: a normal drag inside the chat now copies. The escape hatches are still useful for selecting status-bar / input / preview content (currently outside the selection scope). + +## Required terminal config for OSC 52 + +OSC 52 needs explicit opt-in on some terminals: + +- **xterm**: `XTerm*allowWindowOps: true` in `~/.Xresources`. +- **kitty**: `clipboard_control write-clipboard write-primary` in `kitty.conf` (default since 0.21). +- **tmux**: `set -g set-clipboard on` (already on by default since 3.2). +- **iTerm2**, **WezTerm**, **Alacritty**, **foot**, **Ghostty**: enabled by default. + +When OSC 52 is rejected by the terminal, the user gets no clipboard write and no error. The escape hatches above remain available. + +## Implementation files + +- `crates/oxide-code/src/tui/terminal.rs` — `EnableMouseCapture` in `enter_tui_mode`. +- `crates/oxide-code/src/tui/selection.rs` — `Selection` state, materialization, OSC 52 encoder. +- `crates/oxide-code/src/tui/app.rs` — event routing (`handle_mouse_event`, `copy_selection_to_clipboard`). +- `crates/oxide-code/src/tui/components/chat.rs` — wheel scroll arms (`MouseEventKind::ScrollUp`/`ScrollDown`), `rendered_text` accessor. +- `crates/oxide-code/src/tui/components/status/line.rs` — OSC 8 hyperlink wrapper for the `pull-request` segment. +- `crates/oxide-code/src/tui/theme.rs` — `selection` slot. + +## Out of scope (deferred follow-ups) + +- Click-to-expand on tool-result blocks (requires per-block click rect tracking). +- OSC 8 hyperlinks inside markdown body text (requires threading URLs through the markdown renderer). +- `OX_DISABLE_MOUSE` opt-out (modeled after `CLAUDE_CODE_DISABLE_MOUSE`). +- Native clipboard fallback via `arboard` (when OSC 52 is rejected). +- Block / column selection (Alt+drag). +- Drag auto-scroll past the viewport edge. +- Double-click word and triple-click line. +- Modifier-aware mouse events (Shift+click extend, Alt+click block). +- Selection over status bar, input box, and preview pane. + +## Verification + +Manual verification across terminals: + +1. Start `ox` and generate enough chat content to scroll. +2. Page up. Confirm the jump-to-bottom pill appears. +3. Click the pill. Confirm chat snaps to bottom and re-arms auto-scroll. +4. Drag-select a chat region containing ASCII + CJK + emoji. Mouse up. Paste somewhere external. Confirm bytes round-trip exactly. +5. With a `pull-request` status segment configured, Ctrl-click (or terminal-specific modifier) on `#NN`. Confirm the browser opens to the PR URL. +6. Try wheel scrolling. Confirm chat scrolls. +7. Try the terminal-specific selection escape hatch (e.g., Option+drag in iTerm2). Confirm native selection works for the status bar / input area. +8. Quit. Confirm the terminal is restored (alt-screen exited, mouse capture released). + +Automated tests: + +- `tui::selection::tests` — selection state, line-rect materialization, CJK round-trip, OSC 52 encoder, char-boundary clamping (14 tests). +- `tui::app::tests::left_click_on_jump_overlay_jumps_chat_to_bottom`, `drag_in_chat_area_arms_selection_state_machine`, `left_click_outside_chat_area_does_not_arm_selection`, `rect_contains_left_top_inclusive_right_bottom_exclusive`. +- `tui::components::status::line::tests::pull_request_segment_emits_osc8_open_and_close_around_visible_text`, `pull_request_segment_skips_osc8_when_absent`. diff --git a/docs/research/README.md b/docs/research/README.md index 228b95e8..0c6199b3 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -46,5 +46,6 @@ Organized by topic. Each subdirectory mirrors the corresponding directory in [`d | ---------------------------------------------------- | ---------------------------------------------------------------- | | [Overview](tui/overview.md) | Reference TUI patterns, flickering prevention, ecosystem | | [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and input queueing patterns | +| [Mouse Interactions](tui/mouse-interactions.md) | Mouse capture, click handling, OSC 8 / OSC 52 across coding CLIs | | [Status Line](tui/status-line.md) | Segment ordering, usage, and billing patterns across coding CLIs | | [Welcome Screen](tui/welcome.md) | Empty-state surfaces and layout primitives across the three CLIs | diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md new file mode 100644 index 00000000..4ab81b4d --- /dev/null +++ b/docs/research/tui/mouse-interactions.md @@ -0,0 +1,84 @@ +# Mouse Interactions (Reference) + +Research on mouse handling in terminal AI CLIs: capture defaults, click affordances, wheel scroll, text selection, copy-on-select strategies, and URL openers. + +## Claude Code + +The most polished mouse layer of the three peers. Claude Code's `src/utils/fullscreen.ts` reads `CLAUDE_CODE_DISABLE_MOUSE` to gate the entire mouse pipeline, with a separate `CLAUDE_CODE_DISABLE_MOUSE_CLICKS` env var that lets wheel work while blocking click events. + +The mode bundle enabled by `src/ink/termio/dec.ts` is `?1000h`, `?1002h`, `?1003h`, `?1006h` — same as crossterm's `EnableMouseCapture`. Disabled via the matching `…l` set on suspend / exit. + +Hit-testing lives in `src/ink/hit-test.ts`. Each render builds a Yoga DOM with rect-per-node, then `dispatchClick` bubbles from the deepest hit up the parent chain until `stopImmediatePropagation()`. Clickable elements include the jump-to-bottom pill (`FullscreenLayout.tsx:491`), expand / collapse on message rows (`VirtualMessageList.tsx:225`), background-task agent pills (`BackgroundTaskStatus.tsx:155`), and OSC 8 hyperlinks via ``. + +Selection uses an in-process model in `src/ink/selection.ts`. Drag-start / update / finish / clear plus double-click word and triple-click line are handled in app-side code. `useCopyOnSelect` writes the selection to the OS clipboard on mouse-up via OSC 52, `pbcopy`, or `tmux load-buffer` depending on environment. `` marks gutter cells (line numbers, diff sigils) as non-selectable so drag-copy yields clean text. + +URL opening: `src/utils/browser.ts` validates the URL to `http:` / `https:` first, then dispatches: `BROWSER` env override, else `rundll32 url,OpenURL` (Windows), `open` (macOS), `xdg-open` (Linux). Single-click on an OSC 8 hyperlink defers 500 ms so a second click within the window can start a word-selection drag instead. + +## OpenAI Codex + +Codex's Rust TUI does **not** enable `EnableMouseCapture`. `set_modes()` in `codex-rs/tui/src/tui.rs` enables `EnableBracketedPaste`, `enable_raw_mode`, `KeyboardEnhancement`, and `EnableFocusChange`, but skips mouse. The event mapper at `event_stream.rs` explicitly drops mouse events with a doc comment "skipping events we don't use (mouse events, etc.)". + +Wheel scroll uses DECSET `?1007` "alternate scroll" enabled in `tui.rs:621`, which tells the terminal emulator to translate physical wheel events into `\x1b[A` / `\x1b[B` arrow-key sequences. Codex receives them as keyboard events and never sees raw mouse. Trade-off: it works without claiming click / drag, but it loses every other mouse affordance. + +Click on URLs is handled via OSC 8: `set_status_line_hyperlink(url)` at `chatwidget.rs:1684` and `bottom_pane/mod.rs:1584` wrap the open-PR URL on the status line. `mark_url_hyperlink(buf, area, url)` is the helper that overlays OSC 8 cells across a ratatui buffer rect. The terminal's own Ctrl-click handler opens the URL — no app-side click routing. + +URL opening fallback uses the `webbrowser = "1.0"` crate via `webbrowser::open(&url)` at `app/history_ui.rs`. Triggered by an internal `AppEvent::OpenUrlInBrowser { url }` event for plugin auth and app-link views, but not for the OSC 8 hyperlinks (those go through the terminal). + +No selection support, no copy-on-select. Native terminal selection works because mouse capture isn't on. + +## opencode + +opencode is built on opentui (TypeScript / SolidJS), not bubbletea / Go. The `mouse` config field defaults to `true` and combines with `OPENCODE_DISABLE_MOUSE` (env var wins) to set `useMouse` on the opentui renderer config (`app.tsx:120-130`). + +The renderer exposes `` / `` element-level events. Click affordances include tool-output expand/collapse (`session/index.tsx:1678`), subagent inline tool navigation (`index.tsx:2055`), revert-message banner, subagent footer nav, question/option dialog rows, permission-dialog options, error-screen copy-issue-URL button. The clickable `` component fires `open(href)` from the npm `open` package on mouse-up — no allowlist or sanitization. + +Copy-on-select is implemented at `app.tsx:945-953`: `onMouseUp` on the root `` calls `Selection.copy(renderer, toast)` which calls `renderer.getSelection().getSelectedText()` then `Clipboard.copy()`. Default-on; can flip to right-click-to-copy + Ctrl+C-to-copy via `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` (default-on for Win32). + +Wheel is handled inside opentui's `` primitive with `stickyScroll={true}` and configurable `scroll_speed` / `scroll_acceleration`. When `mouse = false`, the renderer receives no events and wheel scroll is lost — there is no fallback to `?1007` alternate-scroll. + +No documented escape hatch for native terminal selection while mouse is captured. + +## OSC 52 protocol + +`\x1b]52;Pc;Pd\x07` where `Pc` is a clipboard selector (`c` = system clipboard, `p` = primary, `s` = selection, `q` = q-clipboard, `0`-`7` = numbered cut-buffers, with `c` being the most-supported choice) and `Pd` is base64-encoded text. The terminal decodes and writes to its OS-clipboard handler. + +Payload caps: + +- **xterm**: 8 KB pre-base64 (with `allowWindowOps` enabled). Default off. `~/.Xresources`: `XTerm*allowWindowOps: true`. +- **kitty**: 64 KB. Enabled by default since 0.21 via `clipboard_control write-clipboard`. +- **iTerm2**: ~74 KB. Enabled by default. +- **WezTerm**, **Alacritty**, **foot**, **Ghostty**: enabled by default, multi-MB caps. +- **Windows Terminal**: enabled by default. +- **tmux**: `set -g set-clipboard on` (default in 3.2+) passes the OSC through to the outer terminal. tmux 2.6+ can also handle the OSC itself with `set-clipboard external`. + +Failure modes: rejected payloads are silently dropped. The app cannot detect support; the user gets no clipboard write and no error. Falling back to native clipboard requires a separate channel like the `arboard` crate. + +## OSC 8 protocol + +`\x1b]8;params;URI\x1b\\\x1b]8;;\x1b\\` where `params` is `key=value:key=value` (often empty) and `URI` is the link target. ST is `\x1b\\` (or `\x07` on legacy terminals). + +Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VTE-based terminals. Legacy terminals print the escape bytes literally — the `` part is what users see, so the fallback is graceful as long as the visible text alone is meaningful (e.g., `#NN` works; an empty link doesn't). + +`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;…\\` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. + +## Mouse capture mode bundle + +`crossterm::EnableMouseCapture` writes five DECSETs: + +- `?1000h` — X10/normal tracking (button press / release). +- `?1002h` — button-event tracking (adds drag while button held). +- `?1003h` — any-event tracking (adds motion without button). +- `?1006h` — SGR encoding (`\x1b[ 223). +- `?1015h` — URXVT encoding (legacy fallback). + +Some terminals skip `?1003` for performance. SGR (`?1006`) is the only encoding modern crossterm reads, but the others are needed for older terminals that don't speak SGR. There's no portable terminal primitive that delivers wheel only without click / drag, so claiming wheel implies claiming the rest. + +## User-environment signal + +The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from the inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events; when oxide-code captures mouse, that gesture goes to oxide-code and tmux doesn't see it. Users who want tmux's wheel-to-copy-mode can `Ctrl-b z` to zoom out, or escape via `Ctrl-b [`. + +## Takeaway for oxide-code + +Capture mouse and build a small set of well-scoped affordances: jump-to-bottom click, OSC 8 PR hyperlink, drag-select-and-copy via OSC 52. Document the per-terminal selection-modifier escape hatches for content outside the chat (status bar, input box). Defer block selection, drag auto-scroll, double / triple-click word / line, click-to-expand, and an opt-out env var until usage validates the demand. + +Claude Code's hit-test framework is the right model long-term but overengineered for a first pass. opencode's per-element click handlers via opentui aren't reachable from ratatui without a similar framework. Codex's "no capture, alternate-scroll" approach trades affordances for native selection — workable but limits future click features. From a8ffbd0a53408c1625d6f8a5e62ba1aea2f4fe02 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 18:35:54 +0800 Subject: [PATCH 07/35] fix(tui): preserve combining marks and sanitize OSC 8 URL `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. --- .../src/tui/components/status/line.rs | 18 +++++++++++-- crates/oxide-code/src/tui/selection.rs | 27 ++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 13065fab..12ab37aa 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -222,9 +222,11 @@ fn non_empty_span(label: String, style: ratatui::style::Style) -> Option Span<'static> { - Span::raw(format!("\x1b]8;;{url}\x1b\\")) + let sanitized: String = url.chars().filter(|c| !c.is_control()).collect(); + Span::raw(format!("\x1b]8;;{sanitized}\x1b\\")) } fn osc8_close() -> Span<'static> { @@ -443,6 +445,18 @@ mod tests { ); let text: String = rendered.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(!text.contains("\x1b]8;;"), "no OSC 8 bytes when PR absent"); + assert!(!text.contains('#'), "no PR number rendered when absent"); + } + + // ── osc8_open ── + + #[test] + fn osc8_open_strips_control_chars_from_url() { + let span = osc8_open("https://example.com/\x1b\x07\x00ok"); + assert_eq!( + span.content.as_ref(), + "\x1b]8;;https://example.com/ok\x1b\\" + ); } // ── context_label ── diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs index e52dd787..de85e0b5 100644 --- a/crates/oxide-code/src/tui/selection.rs +++ b/crates/oxide-code/src/tui/selection.rs @@ -153,20 +153,30 @@ fn row_columns(row: u16, start: Cell, end: Cell, area_left: u16, area_right: u16 /// Walks the line's spans to extract the slice between cell columns `[col_start, col_end)`. /// Wide chars (`UnicodeWidthChar::width == 2`) are taken whole when their leading half lands -/// inside `[col_start, col_end)` so multi-byte sequences never split. +/// inside `[col_start, col_end)` so multi-byte sequences never split. Zero-width combining +/// marks attach to the preceding base char and are kept whenever that base char was kept. fn slice_line(line: &ratatui::text::Line<'_>, col_start: u16, col_end: u16) -> String { let mut out = String::new(); let mut col: u16 = 0; + let mut last_base_kept = false; 'spans: for span in &line.spans { for ch in span.content.chars() { let w = u16::try_from(UnicodeWidthChar::width(ch).unwrap_or(0)).unwrap_or(0); - let next = col.saturating_add(w); + if w == 0 { + if last_base_kept { + out.push(ch); + } + continue; + } if col >= col_end { break 'spans; } - if next > col_start && w > 0 { + let next = col.saturating_add(w); + let kept = next > col_start; + if kept { out.push(ch); } + last_base_kept = kept; col = next; } } @@ -330,6 +340,17 @@ mod tests { assert_eq!(slice_line(&line, 1, 5), "b好c"); } + #[test] + fn slice_line_preserves_zero_width_combining_marks() { + // NFD-decomposed `é` is `e` + COMBINING ACUTE ACCENT (U+0301, width 0). + let line = Line::from("e\u{0301}cho"); + // Selecting just the first column keeps the base char and its combining mark. + assert_eq!(slice_line(&line, 0, 1), "e\u{0301}"); + // Selecting from col 1 starts past the combining mark's base, so it's dropped along with + // the base it modified. + assert_eq!(slice_line(&line, 1, 4), "cho"); + } + #[test] fn osc52_round_trips_cjk_bytes() { let payload = "Hello 你好 🌏 World"; From 6d0315974d063a4a07712b2b2fd60ed97ec29399 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 18:41:02 +0800 Subject: [PATCH 08/35] refactor(tui): drop interior mutability and reuse Rect::contains `render_jump_overlay` and `draw_frame` already run with `&mut self`, so the `Cell>` indirection bought nothing. Switch to plain `Option` 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` per coordinate. --- crates/oxide-code/src/tui/app.rs | 92 ++++++++++++-------------------- 1 file changed, 35 insertions(+), 57 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index a116f724..946bce7b 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -4,7 +4,6 @@ //! loop multiplexing crossterm events, agent events, user actions, and a 60 FPS render tick. //! A dirty flag coalesces redraws so renders fire per state change rather than per event. -use std::cell::Cell; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -14,7 +13,7 @@ use crossterm::event::{ Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind, }; use futures::{Stream, StreamExt}; -use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use tokio::sync::mpsc; @@ -50,6 +49,12 @@ pub(crate) struct App { theme: Theme, status_bar: StatusBar, chat: ChatView, + /// Rect of the chat area on the most recent frame. Bounds drag-selection events. + chat_rect: Option, + /// Rect of the jump-to-bottom pill on the most recent frame, or `None` while it's hidden. + jump_overlay_rect: Option, + /// In-flight mouse-drag selection over the chat region. + selection: super::selection::Selection, input: InputArea, session_info: LiveSessionInfo, agent_rx: mpsc::Receiver, @@ -64,14 +69,6 @@ pub(crate) struct App { modals: ModalStack, /// Theme saved when a `/theme` picker opens. Restored if the modal cancels. preview_theme_snapshot: Option, - /// Rect of the jump-to-bottom pill on the most recent frame, set by `render_jump_overlay` - /// and read by `handle_crossterm_event` for left-click hit-testing. `None` while the pill - /// is hidden (auto-scroll on, viewport too narrow, or content fits). - jump_overlay_rect: Cell>, - /// Rect of the chat area on the most recent frame, set by `draw_frame`. Used as the bound - /// for mouse-drag text selection. - chat_rect: Cell>, - selection: super::selection::Selection, should_quit: bool, dirty: bool, } @@ -114,6 +111,9 @@ impl App { theme: theme.clone(), status_bar, chat, + chat_rect: None, + jump_overlay_rect: None, + selection: super::selection::Selection::default(), input: InputArea::new(theme), session_info, agent_rx, @@ -124,9 +124,6 @@ impl App { pending_prompts: VecDeque::new(), modals: ModalStack::new(), preview_theme_snapshot: None, - jump_overlay_rect: Cell::new(None), - chat_rect: Cell::new(None), - selection: super::selection::Selection::default(), should_quit: false, dirty: true, } @@ -232,28 +229,31 @@ impl App { /// Routes a mouse event in priority order: jump-overlay click → drag-selection state machine /// → wheel scroll on chat. Each branch returns silently when the click misses every target. fn handle_mouse_event(&mut self, event: &Event, mouse: MouseEvent) { + let position = Position::new(mouse.column, mouse.row); match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { - if let Some(rect) = self.jump_overlay_rect.get() - && rect_contains(rect, mouse.column, mouse.row) + if let Some(rect) = self.jump_overlay_rect + && rect.contains(position) { self.chat.jump_to_bottom(); return; } - if let Some(chat_rect) = self.chat_rect.get() - && rect_contains(chat_rect, mouse.column, mouse.row) + if let Some(chat_rect) = self.chat_rect + && chat_rect.contains(position) { self.selection.begin(mouse.column, mouse.row); } } MouseEventKind::Drag(MouseButton::Left) => { - if self.selection.is_dragging() { - let clamped_col = self.chat_rect.get().map_or(mouse.column, |r| { - mouse.column.clamp(r.x, r.x + r.width.saturating_sub(1)) - }); - let clamped_row = self.chat_rect.get().map_or(mouse.row, |r| { - mouse.row.clamp(r.y, r.y + r.height.saturating_sub(1)) - }); + if self.selection.is_dragging() + && let Some(rect) = self.chat_rect + { + let clamped_col = mouse + .column + .clamp(rect.x, rect.x + rect.width.saturating_sub(1)); + let clamped_row = mouse + .row + .clamp(rect.y, rect.y + rect.height.saturating_sub(1)); self.selection.update(clamped_col, clamped_row); } } @@ -272,7 +272,7 @@ impl App { /// Materializes the current selection from the chat's rendered text and emits an OSC 52 /// set-clipboard sequence. No-op when there's no selection or when materialization clips out. fn copy_selection_to_clipboard(&mut self) { - let Some(area) = self.chat_rect.get() else { + let Some(area) = self.chat_rect else { return; }; let text = self.chat.rendered_text(area.width); @@ -769,13 +769,13 @@ impl App { self.status_bar.render(frame, chunks[0]); if self.chat.is_empty() && self.session_info.config.show_welcome { - self.chat_rect.set(None); + self.chat_rect = None; let snap = WelcomeSnapshot::from_live(&self.session_info); welcome::paint(frame, chunks[1], &self.theme, &snap); } else { self.chat.render(frame, chunks[1]); self.render_jump_overlay(frame, chunks[1]); - self.chat_rect.set(Some(chunks[1])); + self.chat_rect = Some(chunks[1]); self.selection .paint(frame.buffer_mut(), chunks[1], &self.theme); } @@ -825,9 +825,9 @@ impl App { ); } - fn render_jump_overlay(&self, frame: &mut ratatui::Frame<'_>, area: Rect) { + fn render_jump_overlay(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) { if !self.chat.is_scrolled_up() || area.width < 25 || area.height == 0 { - self.jump_overlay_rect.set(None); + self.jump_overlay_rect = None; return; } @@ -842,7 +842,7 @@ impl App { // opaque surface bg so the chat content underneath stays readable. let pill_width = u16::try_from(label.width().saturating_add(2)).unwrap_or(u16::MAX); if pill_width > area.width { - self.jump_overlay_rect.set(None); + self.jump_overlay_rect = None; return; } let pill = Rect { @@ -851,7 +851,7 @@ impl App { width: pill_width, height: 1, }; - self.jump_overlay_rect.set(Some(pill)); + self.jump_overlay_rect = Some(pill); let block = Block::default().style(self.theme.surface()); let inner = block.inner(pill); frame.render_widget(block, pill); @@ -878,16 +878,6 @@ fn jump_overlay_label(new_count: u32, width: usize) -> String { center_truncate_to_width(&label, width.saturating_sub(2)) } -/// Cell-coordinate hit test: `Rect::contains` exists on ratatui but takes a `Position` whose -/// constructor we don't already use elsewhere; this avoids the wrapper boilerplate at every -/// call site. -fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { - col >= rect.x - && col < rect.x.saturating_add(rect.width) - && row >= rect.y - && row < rect.y.saturating_add(rect.height) -} - /// Renders a queued prompt as a dim ghost, capped at `body_width` columns. fn preview_line(prompt: &str, theme: &Theme, body_width: usize) -> Line<'static> { use ratatui::style::Modifier; @@ -1378,7 +1368,7 @@ mod tests { app.chat.set_viewport_for_test(20); app.chat.set_scroll_offset_for_test(10); app.chat.set_auto_scroll_for_test(false); - app.jump_overlay_rect.set(Some(Rect::new(60, 23, 18, 1))); + app.jump_overlay_rect = Some(Rect::new(60, 23, 18, 1)); app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -1400,7 +1390,7 @@ mod tests { app.chat.set_viewport_for_test(20); app.chat.set_scroll_offset_for_test(10); app.chat.set_auto_scroll_for_test(false); - app.jump_overlay_rect.set(Some(Rect::new(60, 23, 18, 1))); + app.jump_overlay_rect = Some(Rect::new(60, 23, 18, 1)); app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -1413,23 +1403,11 @@ mod tests { assert!(!app.chat.auto_scroll_for_test()); } - #[test] - fn rect_contains_left_top_inclusive_right_bottom_exclusive() { - let rect = Rect::new(2, 3, 4, 2); - assert!(rect_contains(rect, 2, 3), "left-top corner is inside"); - assert!(rect_contains(rect, 5, 4), "last cell inside"); - assert!(!rect_contains(rect, 1, 3), "left of x"); - assert!(!rect_contains(rect, 6, 3), "past right edge"); - assert!(!rect_contains(rect, 2, 5), "past bottom edge"); - assert!(!rect_contains(Rect::new(0, 0, 0, 1), 0, 0), "zero-width"); - assert!(!rect_contains(Rect::new(0, 0, 1, 0), 0, 0), "zero-height"); - } - #[test] fn drag_in_chat_area_arms_selection_state_machine() { // Down → Drag → Up over the cached chat rect must traverse the selection states. let (mut app, _rx, _agent_tx) = test_app(None); - app.chat_rect.set(Some(Rect::new(0, 2, 80, 20))); + app.chat_rect = Some(Rect::new(0, 2, 80, 20)); app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -1462,7 +1440,7 @@ mod tests { #[test] fn left_click_outside_chat_area_does_not_arm_selection() { let (mut app, _rx, _agent_tx) = test_app(None); - app.chat_rect.set(Some(Rect::new(0, 2, 80, 20))); + app.chat_rect = Some(Rect::new(0, 2, 80, 20)); // Click in the status-bar row 0 (above chat_rect.y == 2). app.handle_crossterm_event(&Event::Mouse(MouseEvent { From e23f50cc3fac5042083707215d05b30e353c383a Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 18:44:39 +0800 Subject: [PATCH 09/35] refactor(tui): tighten selection visibility and regroup chat accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/oxide-code/src/tui/components/chat.rs | 67 ++++++++++---------- crates/oxide-code/src/tui/selection.rs | 18 +++--- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index f327e1be..31afe316 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -317,36 +317,6 @@ impl ChatView { self.blocks.last().and_then(|b| b.system_text()) } - #[cfg(test)] - pub(crate) fn scroll_offset_for_test(&self) -> u16 { - self.scroll_offset - } - - #[cfg(test)] - pub(crate) fn auto_scroll_for_test(&self) -> bool { - self.auto_scroll - } - - #[cfg(test)] - pub(crate) fn set_scroll_offset_for_test(&mut self, offset: u16) { - self.scroll_offset = offset; - } - - #[cfg(test)] - pub(crate) fn set_viewport_for_test(&mut self, height: u16) { - self.viewport_height = height; - } - - #[cfg(test)] - pub(crate) fn set_auto_scroll_for_test(&mut self, on: bool) { - self.auto_scroll = on; - } - - #[cfg(test)] - pub(crate) fn content_height_for_test(&self) -> &Cell { - &self.content_height - } - /// Updates cached viewport size and syncs scroll. `true` if auto-scroll moved the offset. #[must_use] pub(crate) fn update_layout(&mut self, area: Rect) -> bool { @@ -428,18 +398,18 @@ impl ChatView { pub(crate) fn rendered_text(&self, width: u16) -> Text<'static> { self.build_text(width) } -} - -// ── Private Helpers ── -impl ChatView { /// Scrolls to the latest content and re-arms auto-scroll. Fires from `Ctrl+End` and from /// a left-click on the jump-to-bottom overlay. pub(crate) fn jump_to_bottom(&mut self) { self.scroll_to_bottom(); self.auto_scroll = true; } +} +// ── Private Helpers ── + +impl ChatView { fn scroll_to_bottom(&mut self) { self.scroll_offset = self .content_height @@ -553,6 +523,35 @@ impl ChatView { } } +// ── Test accessors ── + +#[cfg(test)] +impl ChatView { + pub(crate) fn scroll_offset_for_test(&self) -> u16 { + self.scroll_offset + } + + pub(crate) fn auto_scroll_for_test(&self) -> bool { + self.auto_scroll + } + + pub(crate) fn set_scroll_offset_for_test(&mut self, offset: u16) { + self.scroll_offset = offset; + } + + pub(crate) fn set_viewport_for_test(&mut self, height: u16) { + self.viewport_height = height; + } + + pub(crate) fn set_auto_scroll_for_test(&mut self, on: bool) { + self.auto_scroll = on; + } + + pub(crate) fn content_height_for_test(&self) -> &Cell { + &self.content_height + } +} + fn post_compact_display_messages(messages: &[Message]) -> Vec { let mut messages = messages.to_vec(); if let Some(first) = messages.first_mut() diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs index de85e0b5..d44faf87 100644 --- a/crates/oxide-code/src/tui/selection.rs +++ b/crates/oxide-code/src/tui/selection.rs @@ -22,13 +22,13 @@ use crate::tui::theme::Theme; const OSC52_PAYLOAD_BYTES: usize = 8 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct Cell { +pub(super) struct Cell { col: u16, row: u16, } #[derive(Debug, Default, Clone, PartialEq, Eq)] -pub(crate) enum Selection { +pub(super) enum Selection { #[default] Idle, /// Left button pressed; `end` updates on every drag event. @@ -36,13 +36,13 @@ pub(crate) enum Selection { } impl Selection { - pub(crate) fn is_dragging(&self) -> bool { + pub(super) fn is_dragging(&self) -> bool { matches!(self, Selection::Dragging { .. }) } /// Begins a drag. A subsequent `update` with the same coordinates leaves the selection empty, /// so the click-vs-drag distinction is "did `end` move from `start` before mouse-up?". - pub(crate) fn begin(&mut self, col: u16, row: u16) { + pub(super) fn begin(&mut self, col: u16, row: u16) { let cell = Cell { col, row }; *self = Selection::Dragging { start: cell, @@ -51,14 +51,14 @@ impl Selection { } /// Updates the drag endpoint. No-op when not currently dragging. - pub(crate) fn update(&mut self, col: u16, row: u16) { + pub(super) fn update(&mut self, col: u16, row: u16) { if let Selection::Dragging { end, .. } = self { *end = Cell { col, row }; } } /// Clears any in-flight drag. Returns the prior state so callers can finalize before clearing. - pub(crate) fn clear(&mut self) -> Selection { + pub(super) fn clear(&mut self) -> Selection { std::mem::replace(self, Selection::Idle) } @@ -80,7 +80,7 @@ impl Selection { /// Materializes the selected text from `text` clipped to `area` with `scroll_offset`. Returns /// `None` when there's no selection or when the drag misses the chat area entirely. - pub(crate) fn materialize( + pub(super) fn materialize( &self, text: &Text<'_>, area: Rect, @@ -107,7 +107,7 @@ impl Selection { /// Highlights selected cells in `buf` using `theme.selection`. Cells outside the chat `area` /// are clipped. No-op when not dragging. - pub(crate) fn paint(&self, buf: &mut Buffer, area: Rect, theme: &Theme) { + pub(super) fn paint(&self, buf: &mut Buffer, area: Rect, theme: &Theme) { let Some((start, end)) = self.normalized() else { return; }; @@ -185,7 +185,7 @@ fn slice_line(line: &ratatui::text::Line<'_>, col_start: u16, col_end: u16) -> S /// OSC 52 set-clipboard sequence. `c` selects the system clipboard (xterm `selection`). /// Returns the bytes plus a flag indicating whether the payload was clamped. -pub(crate) fn osc52_set_clipboard(text: &str) -> (Vec, bool) { +pub(super) fn osc52_set_clipboard(text: &str) -> (Vec, bool) { let bytes = text.as_bytes(); let (clipped, truncated) = if bytes.len() > OSC52_PAYLOAD_BYTES { ( From 3bbe4430511a4a9d26a8107c2df57ef2831c6a77 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 18:47:27 +0800 Subject: [PATCH 10/35] refactor(tui): align test section dividers with production fns `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. --- crates/oxide-code/src/tui/app.rs | 136 +++++++++++++------------ crates/oxide-code/src/tui/selection.rs | 31 +++--- 2 files changed, 86 insertions(+), 81 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 946bce7b..a13916ce 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -1359,6 +1359,75 @@ mod tests { assert!(app.dirty); } + #[test] + fn handle_crossterm_resize_schedules_dirty_for_relayout() { + // Resize falls through to `dirty = true` so the next tick re-splits with the new area. + let (mut app, _rx, _agent_tx) = test_app(None); + app.dirty = false; + app.handle_crossterm_event(&Event::Resize(80, 24)); + assert!(app.dirty, "Resize must trigger a re-layout render"); + } + + #[test] + fn handle_crossterm_unknown_event_is_a_noop() { + // The `_ => return` arm prevents FocusGained / FocusLost / Paste from forcing re-renders. + let (mut app, _rx, _agent_tx) = test_app(None); + app.dirty = false; + app.handle_crossterm_event(&Event::FocusGained); + assert!(!app.dirty); + } + + #[test] + fn handle_crossterm_scroll_key_routes_to_chat_while_input_disabled() { + // While input is disabled, arrow / page keys must still reach chat for scrolling. + let (mut app, _rx, _agent_tx) = test_app(None); + app.input.set_enabled(false); + app.handle_crossterm_event(&key_event(KeyCode::PageUp, KeyModifiers::NONE)); + assert!(app.dirty); + } + + #[tokio::test] + async fn handle_crossterm_popup_enter_dispatches_canonical_command() { + // `/h` filters to /help; Enter dispatches it. /help is read-only so chat + // lands a system message instead of forwarding to the agent. + let (mut app, mut rx, _agent_tx) = test_app(None); + app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); + app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!(app.input.popup_visible()); + + app.handle_crossterm_event(&key_event(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(!app.input.popup_visible(), "submit clears popup"); + assert!( + !app.chat.last_is_error(), + "/help must produce a system message, not an error", + ); + assert!( + matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)), + "slash command stays client-side", + ); + } + + #[test] + fn handle_crossterm_popup_tab_completes_canonical_name_into_buffer() { + // Tab inserts `/{name} ` and hides the popup. Filter to /help so the test pins + // the completion shape independent of BUILT_INS ordering. + let (mut app, _rx, _agent_tx) = test_app(None); + app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); + app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + + app.handle_crossterm_event(&key_event(KeyCode::Tab, KeyModifiers::NONE)); + + assert!(!app.input.popup_visible(), "Tab hides the popup"); + assert_eq!( + app.input.lines(), + vec!["/help ".to_owned()], + "buffer reflects the completed canonical name + space", + ); + } + + // ── handle_mouse_event ── + #[test] fn left_click_on_jump_overlay_jumps_chat_to_bottom() { // Cache a known jump-overlay rect, scroll the chat up, and confirm a left-click inside @@ -1452,73 +1521,6 @@ mod tests { assert!(!app.selection.is_dragging()); } - #[test] - fn handle_crossterm_resize_schedules_dirty_for_relayout() { - // Resize falls through to `dirty = true` so the next tick re-splits with the new area. - let (mut app, _rx, _agent_tx) = test_app(None); - app.dirty = false; - app.handle_crossterm_event(&Event::Resize(80, 24)); - assert!(app.dirty, "Resize must trigger a re-layout render"); - } - - #[test] - fn handle_crossterm_unknown_event_is_a_noop() { - // The `_ => return` arm prevents FocusGained / FocusLost / Paste from forcing re-renders. - let (mut app, _rx, _agent_tx) = test_app(None); - app.dirty = false; - app.handle_crossterm_event(&Event::FocusGained); - assert!(!app.dirty); - } - - #[test] - fn handle_crossterm_scroll_key_routes_to_chat_while_input_disabled() { - // While input is disabled, arrow / page keys must still reach chat for scrolling. - let (mut app, _rx, _agent_tx) = test_app(None); - app.input.set_enabled(false); - app.handle_crossterm_event(&key_event(KeyCode::PageUp, KeyModifiers::NONE)); - assert!(app.dirty); - } - - #[tokio::test] - async fn handle_crossterm_popup_enter_dispatches_canonical_command() { - // `/h` filters to /help; Enter dispatches it. /help is read-only so chat - // lands a system message instead of forwarding to the agent. - let (mut app, mut rx, _agent_tx) = test_app(None); - app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); - app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); - assert!(app.input.popup_visible()); - - app.handle_crossterm_event(&key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.input.popup_visible(), "submit clears popup"); - assert!( - !app.chat.last_is_error(), - "/help must produce a system message, not an error", - ); - assert!( - matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)), - "slash command stays client-side", - ); - } - - #[test] - fn handle_crossterm_popup_tab_completes_canonical_name_into_buffer() { - // Tab inserts `/{name} ` and hides the popup. Filter to /help so the test pins - // the completion shape independent of BUILT_INS ordering. - let (mut app, _rx, _agent_tx) = test_app(None); - app.handle_crossterm_event(&key_event(KeyCode::Char('/'), KeyModifiers::NONE)); - app.handle_crossterm_event(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); - - app.handle_crossterm_event(&key_event(KeyCode::Tab, KeyModifiers::NONE)); - - assert!(!app.input.popup_visible(), "Tab hides the popup"); - assert_eq!( - app.input.lines(), - vec!["/help ".to_owned()], - "buffer reflects the completed canonical name + space", - ); - } - // ── modal gate ── #[tokio::test] diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs index d44faf87..955bad75 100644 --- a/crates/oxide-code/src/tui/selection.rs +++ b/crates/oxide-code/src/tui/selection.rs @@ -223,7 +223,15 @@ mod tests { Cell { col, row } } - // ── Selection state ── + fn fixture_text() -> Text<'static> { + Text::from(vec![ + Line::from("hello world"), + Line::from("second line"), + Line::from("third"), + ]) + } + + // ── Selection ── #[test] fn begin_then_update_tracks_drag_endpoint() { @@ -267,15 +275,7 @@ mod tests { assert!(s.normalized().is_none(), "click without drag"); } - // ── Materialization (line-rect) ── - - fn fixture_text() -> Text<'static> { - Text::from(vec![ - Line::from("hello world"), - Line::from("second line"), - Line::from("third"), - ]) - } + // ── materialize ── #[test] fn materialize_single_row_returns_substring() { @@ -309,16 +309,15 @@ mod tests { } #[test] - fn materialize_clips_to_area_bounds() { + fn materialize_returns_none_when_drag_starts_below_area() { let mut s = Selection::default(); s.begin(0, 5); s.update(99, 99); let area = Rect::new(0, 0, 80, 3); - // Drag starts below the chat area entirely. assert!(s.materialize(&fixture_text(), area, 0).is_none()); } - // ── CJK round-trip ── + // ── slice_line ── #[test] fn slice_line_keeps_cjk_chars_whole_at_each_boundary() { @@ -351,6 +350,8 @@ mod tests { assert_eq!(slice_line(&line, 1, 4), "cho"); } + // ── osc52_set_clipboard ── + #[test] fn osc52_round_trips_cjk_bytes() { let payload = "Hello 你好 🌏 World"; @@ -376,6 +377,8 @@ mod tests { assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); } + // ── floor_to_char_boundary ── + #[test] fn floor_to_char_boundary_walks_back_through_multibyte() { // `好` is 3 bytes (e5 a5 bd). Cap inside the 2nd or 3rd byte must rewind to the start. @@ -385,7 +388,7 @@ mod tests { assert_eq!(floor_to_char_boundary(s, 4), 4, "between 好 and B"); } - // ── Painting ── + // ── paint ── #[test] fn paint_applies_selection_style_to_in_area_cells() { From 3994352c8639387af3a478d1ac4ac13ffaad7f0d Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 18:57:13 +0800 Subject: [PATCH 11/35] test(tui): plumb writable sink and cover OSC 52 boundary cases 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`. 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. --- .cspell/words.txt | 1 + crates/oxide-code/src/tui/app.rs | 216 ++++++++++++++++++++++++- crates/oxide-code/src/tui/selection.rs | 84 +++++++++- 3 files changed, 292 insertions(+), 9 deletions(-) diff --git a/.cspell/words.txt b/.cspell/words.txt index 5375eb3c..d8bdd08c 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -69,6 +69,7 @@ println pulldown RAII ratatui +rects replacen reqwest reserialized diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index a13916ce..aeab8743 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -272,22 +272,31 @@ impl App { /// Materializes the current selection from the chat's rendered text and emits an OSC 52 /// set-clipboard sequence. No-op when there's no selection or when materialization clips out. fn copy_selection_to_clipboard(&mut self) { + let mut stdout = std::io::stdout().lock(); + if let Err(error) = self.write_selection_to(&mut stdout) { + tracing::debug!(?error, "OSC 52 write failed"); + } + } + + /// Writes the OSC 52 sequence for the current selection into `sink`. Pushes a system message + /// when the payload was truncated. No-op when there's no selection or when materialization + /// clips out, in which case `sink` is not touched. + fn write_selection_to(&mut self, sink: &mut dyn std::io::Write) -> std::io::Result<()> { let Some(area) = self.chat_rect else { - return; + return Ok(()); }; let text = self.chat.rendered_text(area.width); let scroll = self.chat.scroll_offset(); let Some(payload) = self.selection.materialize(&text, area, scroll) else { - return; + return Ok(()); }; let (sequence, truncated) = super::selection::osc52_set_clipboard(&payload); - if let Err(error) = std::io::Write::write_all(&mut std::io::stdout(), &sequence) { - tracing::debug!(?error, "OSC 52 write failed"); - } + sink.write_all(&sequence)?; if truncated { self.chat .push_system_message("Selection truncated to ~8 KB for the clipboard.".to_owned()); } + Ok(()) } fn apply_modal_action(&mut self, action: ModalAction) { @@ -1453,7 +1462,8 @@ mod tests { #[test] fn left_click_outside_jump_overlay_does_not_jump_chat() { - // A click on the chat area but outside the cached pill rect must not snap to bottom. + // A click on the chat area but outside the cached pill rect must not snap to bottom and + // must not arm a selection (chat_rect is intentionally None here). let (mut app, _rx, _agent_tx) = test_app(None); app.chat.content_height_for_test().set(100); app.chat.set_viewport_for_test(20); @@ -1470,6 +1480,10 @@ mod tests { assert_eq!(app.chat.scroll_offset_for_test(), 10); assert!(!app.chat.auto_scroll_for_test()); + assert!( + !app.selection.is_dragging(), + "click outside both rects must not arm selection" + ); } #[test] @@ -1521,6 +1535,196 @@ mod tests { assert!(!app.selection.is_dragging()); } + #[test] + fn drag_outside_chat_clamps_endpoint_into_rect() { + // A drag that exits the chat rect must clamp the endpoint inside, so a subsequent + // mouse-up materializes against in-bounds coordinates instead of garbage. + use crate::tui::selection::Selection; + let (mut app, _rx, _agent_tx) = test_app(None); + let rect = Rect::new(0, 2, 80, 20); + app.chat_rect = Some(rect); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + })); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 500, + row: 500, + modifiers: KeyModifiers::NONE, + })); + + match &app.selection { + Selection::Dragging { end, .. } => { + let (col, row) = ((*end).col(), (*end).row()); + assert!( + col < rect.x + rect.width, + "endpoint clamped within rect width" + ); + assert!( + row < rect.y + rect.height, + "endpoint clamped within rect height" + ); + } + Selection::Idle => panic!("drag arm must keep the selection in Dragging"), + } + } + + #[test] + fn mouse_up_outside_chat_still_finalizes_drag() { + // Up's position doesn't gate copy: as long as a drag is in flight, mouse-up always + // clears the selection. Otherwise a release outside the chat rect would leak the highlight. + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat_rect = Some(Rect::new(0, 2, 80, 20)); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + })); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 12, + row: 7, + modifiers: KeyModifiers::NONE, + })); + // Up fires from far outside the chat rect. + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 999, + row: 999, + modifiers: KeyModifiers::NONE, + })); + + assert!( + !app.selection.is_dragging(), + "up always clears regardless of position" + ); + } + + // ── write_selection_to ── + + fn drag_app_with_chat_text(text: &str) -> App { + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat.push_user_message(text.to_owned()); + // The user-message block paints "❯ " on a single row; we drag from col 0 across. + app.chat_rect = Some(Rect::new(0, 0, 80, 5)); + app + } + + #[test] + fn write_selection_to_is_noop_without_chat_rect() { + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat_rect = None; + let mut sink = Vec::::new(); + app.write_selection_to(&mut sink).unwrap(); + assert!(sink.is_empty(), "no rect → no OSC 52 bytes"); + } + + #[test] + fn write_selection_to_is_noop_without_drag() { + let mut app = drag_app_with_chat_text("hello world"); + let mut sink = Vec::::new(); + app.write_selection_to(&mut sink).unwrap(); + assert!(sink.is_empty(), "Selection::Idle → no OSC 52 bytes"); + } + + #[test] + fn write_selection_to_emits_osc52_payload_for_dragged_text() { + let mut app = drag_app_with_chat_text("hello world"); + // Drag across columns spanning the rendered text. The chat block prefixes content, + // so we don't pin exact bytes — we assert the OSC 52 envelope is emitted. + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 50, + row: 0, + modifiers: KeyModifiers::NONE, + })); + let mut sink = Vec::::new(); + app.write_selection_to(&mut sink).unwrap(); + + assert!( + sink.starts_with(b"\x1b]52;c;"), + "OSC 52 envelope opens the sink" + ); + assert!( + sink.ends_with(b"\x07"), + "BEL terminates the OSC 52 sequence" + ); + } + + #[test] + fn write_selection_to_pushes_truncation_message_when_payload_clamped() { + let big = "A".repeat(crate::tui::selection::OSC52_PAYLOAD_BYTES + 100); + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat.push_user_message(big); + // 8 KB of "A" wraps to ~104 rows at 80 cols, so the chat rect must be tall enough for + // materialize to span the full content during the drag. + app.chat_rect = Some(Rect::new(0, 0, 80, 200)); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 79, + row: 199, + modifiers: KeyModifiers::NONE, + })); + let mut sink = Vec::::new(); + app.write_selection_to(&mut sink).unwrap(); + + let last = app.chat.last_system_text().unwrap_or_default(); + assert!( + last.contains("Selection truncated"), + "truncation pushes a system message: {last:?}", + ); + } + + #[test] + fn write_selection_to_uses_current_scroll_offset_for_materialization() { + let (mut app, _rx, _agent_tx) = test_app(None); + app.chat.push_user_message("first row content".to_owned()); + app.chat.push_user_message("second row content".to_owned()); + app.chat.push_user_message("third row content".to_owned()); + app.chat_rect = Some(Rect::new(0, 0, 80, 2)); + // Force the viewport to look 4 rows past the top so the second message is at row 0. + app.chat.set_viewport_for_test(2); + app.chat.set_scroll_offset_for_test(4); + + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 79, + row: 0, + modifiers: KeyModifiers::NONE, + })); + let mut sink = Vec::::new(); + app.write_selection_to(&mut sink).unwrap(); + assert!( + sink.starts_with(b"\x1b]52;c;"), + "scrolled-into-content drag still emits an OSC 52 payload", + ); + } + // ── modal gate ── #[tokio::test] diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs index 955bad75..4a125631 100644 --- a/crates/oxide-code/src/tui/selection.rs +++ b/crates/oxide-code/src/tui/selection.rs @@ -19,7 +19,7 @@ use crate::tui::theme::Theme; /// Conservative pre-base64 cap. xterm's OSC budget is ~8 KB; kitty / iTerm2 are larger. Pick the /// floor so the same selection works everywhere. -const OSC52_PAYLOAD_BYTES: usize = 8 * 1024; +pub(super) const OSC52_PAYLOAD_BYTES: usize = 8 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct Cell { @@ -27,6 +27,17 @@ pub(super) struct Cell { row: u16, } +#[cfg(test)] +impl Cell { + pub(super) fn col(self) -> u16 { + self.col + } + + pub(super) fn row(self) -> u16 { + self.row + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(super) enum Selection { #[default] @@ -377,6 +388,48 @@ mod tests { assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); } + #[test] + fn osc52_at_exact_cap_is_not_truncated() { + let payload = "A".repeat(OSC52_PAYLOAD_BYTES); + let (sequence, truncated) = osc52_set_clipboard(&payload); + assert!(!truncated, "exact-cap payload must pass through untouched"); + let prefix = b"\x1b]52;c;"; + let b64 = &sequence[prefix.len()..sequence.len() - 1]; + let decoded = BASE64.decode(b64).unwrap(); + assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); + } + + #[test] + fn osc52_one_byte_over_cap_truncates_to_cap() { + let payload = "A".repeat(OSC52_PAYLOAD_BYTES + 1); + let (sequence, truncated) = osc52_set_clipboard(&payload); + assert!( + truncated, + "payload one byte over the cap must report truncation" + ); + let prefix = b"\x1b]52;c;"; + let b64 = &sequence[prefix.len()..sequence.len() - 1]; + let decoded = BASE64.decode(b64).unwrap(); + assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); + } + + #[test] + fn osc52_truncates_at_cjk_char_boundary_not_mid_byte() { + // `好` is 3 bytes. Repeat enough to land the cap mid-char and force a boundary walk-back. + let payload = "好".repeat((OSC52_PAYLOAD_BYTES / 3) + 10); + let (sequence, truncated) = osc52_set_clipboard(&payload); + assert!(truncated); + let prefix = b"\x1b]52;c;"; + let b64 = &sequence[prefix.len()..sequence.len() - 1]; + let decoded = BASE64.decode(b64).unwrap(); + // Decoded bytes must be valid UTF-8: a mid-3-byte truncation would fail std::str. + let kept = std::str::from_utf8(&decoded).expect("truncation lands on a UTF-8 boundary"); + assert!(kept.chars().all(|c| c == '好')); + // Length is the largest multiple of 3 that fits inside the cap. + let expected = OSC52_PAYLOAD_BYTES - (OSC52_PAYLOAD_BYTES % 3); + assert_eq!(decoded.len(), expected); + } + // ── floor_to_char_boundary ── #[test] @@ -394,6 +447,13 @@ mod tests { fn paint_applies_selection_style_to_in_area_cells() { let area = Rect::new(0, 0, 10, 3); let mut buf = Buffer::empty(area); + // Pre-fill with a sentinel so "untouched" assertions distinguish a skipped paint from a + // cell that happened to default to `Color::Reset`. + for y in area.y..area.y + area.height { + for x in area.x..area.x + area.width { + buf[(x, y)].bg = Color::Cyan; + } + } let mut theme = Theme::default(); theme.selection.bg = Some(Color::Red); @@ -405,7 +465,25 @@ mod tests { for col in 2..=5 { assert_eq!(buf[(col, 1)].bg, Color::Red, "col {col} highlighted"); } - assert_eq!(buf[(1, 1)].bg, Color::Reset, "before selection"); - assert_eq!(buf[(6, 1)].bg, Color::Reset, "after selection"); + assert_eq!( + buf[(1, 1)].bg, + Color::Cyan, + "sentinel survives before selection" + ); + assert_eq!( + buf[(6, 1)].bg, + Color::Cyan, + "sentinel survives after selection" + ); + assert_eq!( + buf[(2, 0)].bg, + Color::Cyan, + "rows above selection untouched" + ); + assert_eq!( + buf[(2, 2)].bg, + Color::Cyan, + "rows below selection untouched" + ); } } From 5b70dec8f4e300d526f7c6a8802e7a10a7afb647 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 19:03:27 +0800 Subject: [PATCH 12/35] style(tui): tighten code comments per project prose rules 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. --- crates/oxide-code/src/tui/app.rs | 19 ++++++------------- crates/oxide-code/src/tui/components/chat.rs | 9 +++------ .../src/tui/components/status/line.rs | 9 ++++----- crates/oxide-code/src/tui/selection.rs | 12 ++++++------ 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index aeab8743..5de5a2d3 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -1357,7 +1357,7 @@ mod tests { #[test] fn handle_crossterm_mouse_is_forwarded_to_chat() { - // Wheel events still reach `ChatView::handle_event` for scroll; the dirty flag must flip. + // Wheel events reach `ChatView::handle_event` for scroll, so the dirty flag flips. let (mut app, _rx, _agent_tx) = test_app(None); app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, @@ -1439,8 +1439,6 @@ mod tests { #[test] fn left_click_on_jump_overlay_jumps_chat_to_bottom() { - // Cache a known jump-overlay rect, scroll the chat up, and confirm a left-click inside - // the rect snaps back to bottom and re-arms auto-scroll. let (mut app, _rx, _agent_tx) = test_app(None); app.chat.content_height_for_test().set(100); app.chat.set_viewport_for_test(20); @@ -1462,8 +1460,7 @@ mod tests { #[test] fn left_click_outside_jump_overlay_does_not_jump_chat() { - // A click on the chat area but outside the cached pill rect must not snap to bottom and - // must not arm a selection (chat_rect is intentionally None here). + // `chat_rect` is intentionally None so the click hits neither the pill nor the chat area. let (mut app, _rx, _agent_tx) = test_app(None); app.chat.content_height_for_test().set(100); app.chat.set_viewport_for_test(20); @@ -1488,7 +1485,6 @@ mod tests { #[test] fn drag_in_chat_area_arms_selection_state_machine() { - // Down → Drag → Up over the cached chat rect must traverse the selection states. let (mut app, _rx, _agent_tx) = test_app(None); app.chat_rect = Some(Rect::new(0, 2, 80, 20)); @@ -1537,8 +1533,6 @@ mod tests { #[test] fn drag_outside_chat_clamps_endpoint_into_rect() { - // A drag that exits the chat rect must clamp the endpoint inside, so a subsequent - // mouse-up materializes against in-bounds coordinates instead of garbage. use crate::tui::selection::Selection; let (mut app, _rx, _agent_tx) = test_app(None); let rect = Rect::new(0, 2, 80, 20); @@ -1575,8 +1569,7 @@ mod tests { #[test] fn mouse_up_outside_chat_still_finalizes_drag() { - // Up's position doesn't gate copy: as long as a drag is in flight, mouse-up always - // clears the selection. Otherwise a release outside the chat rect would leak the highlight. + // A release outside the chat rect would leak the highlight if Up gated on position. let (mut app, _rx, _agent_tx) = test_app(None); app.chat_rect = Some(Rect::new(0, 2, 80, 20)); @@ -1611,7 +1604,7 @@ mod tests { fn drag_app_with_chat_text(text: &str) -> App { let (mut app, _rx, _agent_tx) = test_app(None); app.chat.push_user_message(text.to_owned()); - // The user-message block paints "❯ " on a single row; we drag from col 0 across. + // The user-message block paints "❯ " on a single row, so drags start from col 0. app.chat_rect = Some(Rect::new(0, 0, 80, 5)); app } @@ -1636,8 +1629,8 @@ mod tests { #[test] fn write_selection_to_emits_osc52_payload_for_dragged_text() { let mut app = drag_app_with_chat_text("hello world"); - // Drag across columns spanning the rendered text. The chat block prefixes content, - // so we don't pin exact bytes — we assert the OSC 52 envelope is emitted. + // The chat block prefixes content with a glyph, so the assertion targets the OSC 52 + // envelope rather than exact bytes. app.handle_crossterm_event(&Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 0, diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 31afe316..3cb7b412 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -387,20 +387,17 @@ impl ChatView { frame.render_widget(paragraph, area); } - /// Current scroll offset in content rows. Used by [`crate::tui::selection`] to translate a - /// selection's screen-row coordinates into chat-content row indices. + /// Current scroll offset in content rows. pub(crate) fn scroll_offset(&self) -> u16 { self.scroll_offset } - /// Wrapped `Text` for the given viewport width. Materializes the same lines `render` paints, - /// so [`crate::tui::selection`] can extract substrings consistent with what's on screen. + /// Wrapped `Text` for the given viewport width. pub(crate) fn rendered_text(&self, width: u16) -> Text<'static> { self.build_text(width) } - /// Scrolls to the latest content and re-arms auto-scroll. Fires from `Ctrl+End` and from - /// a left-click on the jump-to-bottom overlay. + /// Scrolls to the latest content and re-arms auto-scroll. pub(crate) fn jump_to_bottom(&mut self) { self.scroll_to_bottom(); self.auto_scroll = true; diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 12ab37aa..fae06403 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -221,7 +221,7 @@ fn non_empty_span(label: String, style: ratatui::style::Style) -> Option Span<'static> { @@ -407,10 +407,9 @@ mod tests { full.contains("#86") && full.contains("main") && full.ends_with("Ready"), "wide width keeps every segment: {full}", ); - // Width 22 forces time (utility 1) to drop before PR (2) and branch (3). Width 14 - // narrows further until only branch and run state remain. The PR span is wrapped in - // OSC 8 hyperlink bytes that `unicode_width` measures as zero, so column math is - // unaffected even though the printed string carries the escape sequence. + // Width 22 drops time (utility 1) before PR (2) and branch (3). Width 14 narrows further + // until only branch and run state remain. The OSC 8 wrapper measures as zero columns, so + // budget math doesn't change even with the escape bytes embedded. let pr_open = "\x1b]8;;https://github.com/o/r/pull/86\x1b\\"; let pr_close = "\x1b]8;;\x1b\\"; assert_eq!( diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs index 4a125631..c4d438d9 100644 --- a/crates/oxide-code/src/tui/selection.rs +++ b/crates/oxide-code/src/tui/selection.rs @@ -17,8 +17,8 @@ use unicode_width::UnicodeWidthChar; use crate::tui::theme::Theme; -/// Conservative pre-base64 cap. xterm's OSC budget is ~8 KB; kitty / iTerm2 are larger. Pick the -/// floor so the same selection works everywhere. +/// Conservative pre-base64 cap. xterm's OSC budget is ~8 KB. kitty and iTerm2 are larger, so +/// picking the floor lets the same selection work on every supported terminal. pub(super) const OSC52_PAYLOAD_BYTES: usize = 8 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,7 +42,7 @@ impl Cell { pub(super) enum Selection { #[default] Idle, - /// Left button pressed; `end` updates on every drag event. + /// Left button is pressed. `end` updates on every drag event. Dragging { start: Cell, end: Cell }, } @@ -51,8 +51,8 @@ impl Selection { matches!(self, Selection::Dragging { .. }) } - /// Begins a drag. A subsequent `update` with the same coordinates leaves the selection empty, - /// so the click-vs-drag distinction is "did `end` move from `start` before mouse-up?". + /// Begins a drag at `(col, row)`. The first matching `update` distinguishes the gesture from + /// a bare click by moving `end` away from `start`. pub(super) fn begin(&mut self, col: u16, row: u16) { let cell = Cell { col, row }; *self = Selection::Dragging { @@ -74,7 +74,7 @@ impl Selection { } /// Returns the normalized `(start, end)` cells with `start` always above-or-left of `end`. - /// Returns `None` when not dragging or when start == end (a click, not a drag). + /// `None` when idle or when `start == end`. fn normalized(&self) -> Option<(Cell, Cell)> { match self { Selection::Dragging { start, end } if start != end => { From a395a6ef9b4e5d9b8735fdb3793d14c2ee62f05b Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 19:06:10 +0800 Subject: [PATCH 13/35] docs(tui): clean prose in mouse-interactions research note 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. --- docs/research/tui/mouse-interactions.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index 4ab81b4d..f841f655 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -6,7 +6,7 @@ Research on mouse handling in terminal AI CLIs: capture defaults, click affordan The most polished mouse layer of the three peers. Claude Code's `src/utils/fullscreen.ts` reads `CLAUDE_CODE_DISABLE_MOUSE` to gate the entire mouse pipeline, with a separate `CLAUDE_CODE_DISABLE_MOUSE_CLICKS` env var that lets wheel work while blocking click events. -The mode bundle enabled by `src/ink/termio/dec.ts` is `?1000h`, `?1002h`, `?1003h`, `?1006h` — same as crossterm's `EnableMouseCapture`. Disabled via the matching `…l` set on suspend / exit. +The mode bundle enabled by `src/ink/termio/dec.ts` is `?1000h`, `?1002h`, `?1003h`, `?1006h` — same as crossterm's `EnableMouseCapture`. Disabled via the matching `...l` set on suspend / exit. Hit-testing lives in `src/ink/hit-test.ts`. Each render builds a Yoga DOM with rect-per-node, then `dispatchClick` bubbles from the deepest hit up the parent chain until `stopImmediatePropagation()`. Clickable elements include the jump-to-bottom pill (`FullscreenLayout.tsx:491`), expand / collapse on message rows (`VirtualMessageList.tsx:225`), background-task agent pills (`BackgroundTaskStatus.tsx:155`), and OSC 8 hyperlinks via ``. @@ -20,7 +20,7 @@ Codex's Rust TUI does **not** enable `EnableMouseCapture`. `set_modes()` in `cod Wheel scroll uses DECSET `?1007` "alternate scroll" enabled in `tui.rs:621`, which tells the terminal emulator to translate physical wheel events into `\x1b[A` / `\x1b[B` arrow-key sequences. Codex receives them as keyboard events and never sees raw mouse. Trade-off: it works without claiming click / drag, but it loses every other mouse affordance. -Click on URLs is handled via OSC 8: `set_status_line_hyperlink(url)` at `chatwidget.rs:1684` and `bottom_pane/mod.rs:1584` wrap the open-PR URL on the status line. `mark_url_hyperlink(buf, area, url)` is the helper that overlays OSC 8 cells across a ratatui buffer rect. The terminal's own Ctrl-click handler opens the URL — no app-side click routing. +Click on URLs is handled via OSC 8: `set_status_line_hyperlink(url)` at `chatwidget.rs:1684` and `bottom_pane/mod.rs:1584` wrap the open-PR URL on the status line. `mark_url_hyperlink(buf, area, url)` is the helper that overlays OSC 8 cells across a ratatui buffer rect. The terminal's own Ctrl-click handler opens the URL, with no app-side click routing. URL opening fallback uses the `webbrowser = "1.0"` crate via `webbrowser::open(&url)` at `app/history_ui.rs`. Triggered by an internal `AppEvent::OpenUrlInBrowser { url }` event for plugin auth and app-link views, but not for the OSC 8 hyperlinks (those go through the terminal). @@ -30,11 +30,11 @@ No selection support, no copy-on-select. Native terminal selection works because opencode is built on opentui (TypeScript / SolidJS), not bubbletea / Go. The `mouse` config field defaults to `true` and combines with `OPENCODE_DISABLE_MOUSE` (env var wins) to set `useMouse` on the opentui renderer config (`app.tsx:120-130`). -The renderer exposes `` / `` element-level events. Click affordances include tool-output expand/collapse (`session/index.tsx:1678`), subagent inline tool navigation (`index.tsx:2055`), revert-message banner, subagent footer nav, question/option dialog rows, permission-dialog options, error-screen copy-issue-URL button. The clickable `` component fires `open(href)` from the npm `open` package on mouse-up — no allowlist or sanitization. +The renderer exposes `` / `` element-level events. Click affordances include tool-output expand / collapse (`session/index.tsx:1678`), subagent inline tool navigation (`index.tsx:2055`), revert-message banner, subagent footer nav, question / option dialog rows, permission-dialog options, error-screen copy-issue-URL button. The clickable `` component fires `open(href)` from the npm `open` package on mouse-up, with no allowlist or sanitization. -Copy-on-select is implemented at `app.tsx:945-953`: `onMouseUp` on the root `` calls `Selection.copy(renderer, toast)` which calls `renderer.getSelection().getSelectedText()` then `Clipboard.copy()`. Default-on; can flip to right-click-to-copy + Ctrl+C-to-copy via `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` (default-on for Win32). +Copy-on-select is implemented at `app.tsx:945-953`: `onMouseUp` on the root `` calls `Selection.copy(renderer, toast)` which calls `renderer.getSelection().getSelectedText()` then `Clipboard.copy()`. Default-on, but flippable to right-click-to-copy + Ctrl+C-to-copy via `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT` (default-on for Win32). -Wheel is handled inside opentui's `` primitive with `stickyScroll={true}` and configurable `scroll_speed` / `scroll_acceleration`. When `mouse = false`, the renderer receives no events and wheel scroll is lost — there is no fallback to `?1007` alternate-scroll. +Wheel is handled inside opentui's `` primitive with `stickyScroll={true}` and configurable `scroll_speed` / `scroll_acceleration`. When `mouse = false`, the renderer receives no events and wheel scroll is lost, since there is no fallback to `?1007` alternate-scroll. No documented escape hatch for native terminal selection while mouse is captured. @@ -51,15 +51,15 @@ Payload caps: - **Windows Terminal**: enabled by default. - **tmux**: `set -g set-clipboard on` (default in 3.2+) passes the OSC through to the outer terminal. tmux 2.6+ can also handle the OSC itself with `set-clipboard external`. -Failure modes: rejected payloads are silently dropped. The app cannot detect support; the user gets no clipboard write and no error. Falling back to native clipboard requires a separate channel like the `arboard` crate. +Failure modes: rejected payloads are silently dropped. The app cannot detect support, so the user gets no clipboard write and no error. Falling back to native clipboard requires a separate channel like the `arboard` crate. ## OSC 8 protocol `\x1b]8;params;URI\x1b\\\x1b]8;;\x1b\\` where `params` is `key=value:key=value` (often empty) and `URI` is the link target. ST is `\x1b\\` (or `\x07` on legacy terminals). -Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VTE-based terminals. Legacy terminals print the escape bytes literally — the `` part is what users see, so the fallback is graceful as long as the visible text alone is meaningful (e.g., `#NN` works; an empty link doesn't). +Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VTE-based terminals. Legacy terminals print the escape bytes literally. The `` part is what users see, so the fallback is graceful as long as the visible text alone is meaningful (e.g., `#NN` works, while an empty link doesn't). -`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;…\\` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. +`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;...\\` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. ## Mouse capture mode bundle @@ -75,10 +75,10 @@ Some terminals skip `?1003` for performance. SGR (`?1006`) is the only encoding ## User-environment signal -The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from the inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events; when oxide-code captures mouse, that gesture goes to oxide-code and tmux doesn't see it. Users who want tmux's wheel-to-copy-mode can `Ctrl-b z` to zoom out, or escape via `Ctrl-b [`. +The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from the inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events. When oxide-code captures mouse, that gesture goes to oxide-code and tmux doesn't see it. Users who want tmux's wheel-to-copy-mode can `Ctrl-b z` to zoom out, or escape via `Ctrl-b [`. ## Takeaway for oxide-code Capture mouse and build a small set of well-scoped affordances: jump-to-bottom click, OSC 8 PR hyperlink, drag-select-and-copy via OSC 52. Document the per-terminal selection-modifier escape hatches for content outside the chat (status bar, input box). Defer block selection, drag auto-scroll, double / triple-click word / line, click-to-expand, and an opt-out env var until usage validates the demand. -Claude Code's hit-test framework is the right model long-term but overengineered for a first pass. opencode's per-element click handlers via opentui aren't reachable from ratatui without a similar framework. Codex's "no capture, alternate-scroll" approach trades affordances for native selection — workable but limits future click features. +Claude Code's hit-test framework is the right model long-term but overengineered for a first pass. opencode's per-element click handlers via opentui aren't reachable from ratatui without a similar framework. Codex's "no capture, alternate-scroll" approach trades affordances for native selection. Workable, but limits future click features. From f29671990ef0ab83ae1af0c618a038f8159542c6 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 19:20:23 +0800 Subject: [PATCH 14/35] fix(tui): paint OSC 8 PR hyperlink via Cell::set_symbol 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;;\#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. --- .../oxide-code/src/tui/components/status.rs | 23 +- .../src/tui/components/status/line.rs | 206 ++++++++++++++---- docs/design/tui/mouse-interactions.md | 4 +- 3 files changed, 178 insertions(+), 55 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 01c25cb3..555b1ad2 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -16,7 +16,7 @@ use crate::tui::glyphs::SPINNER_FRAMES; use crate::tui::theme::Theme; use crate::util::git; -use self::line::{StatusLine, StatusLineState}; +use self::line::{RenderedStatusLine, StatusLine, StatusLineState, mark_url_hyperlink}; const TICKS_PER_FRAME: usize = 5; @@ -233,10 +233,21 @@ impl StatusBar { .borders(Borders::BOTTOM) .border_style(self.theme.border_unfocused()) .style(self.theme.surface()); - frame.render_widget( - Paragraph::new(self.render_line(area.width)).block(block), - area, - ); + let rendered = self.render_line(area.width); + // Inside the block the content row is `area.y` (no top border) starting at `area.x`. + let content_y = area.y; + let content_x = area.x; + frame.render_widget(Paragraph::new(rendered.line).block(block), area); + for link in &rendered.hyperlinks { + let link_x = content_x.saturating_add(link.col); + let max_width = area.x.saturating_add(area.width).saturating_sub(link_x); + let width = link.width.min(max_width); + if width == 0 { + continue; + } + let link_rect = Rect::new(link_x, content_y, width, 1); + mark_url_hyperlink(frame.buffer_mut(), link_rect, &link.url); + } } } @@ -263,7 +274,7 @@ impl StatusBar { Span::styled(format!("{spinner} {label}"), self.theme.info()) } - fn render_line(&self, width: u16) -> ratatui::text::Line<'static> { + fn render_line(&self, width: u16) -> RenderedStatusLine { self.line.render( &self.theme, &StatusLineState { diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index fae06403..8e205892 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -1,3 +1,5 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use time::OffsetDateTime; use unicode_width::UnicodeWidthStr; @@ -11,6 +13,8 @@ use crate::util::time::local_offset; const MAX_CURRENT_DIR_WIDTH: usize = 40; const MAX_GIT_BRANCH_WIDTH: usize = 32; const MAX_TITLE_WIDTH: usize = 40; +/// Leading margin (cells) inside the status bar that lines up content with the chat block. +const STATUS_LINE_MARGIN: u16 = 2; /// Ordered segment roster for one status-line render. #[derive(Debug, Clone)] @@ -18,6 +22,22 @@ pub(super) struct StatusLine { segments: Vec, } +/// A rendered status line plus the buffer ranges that should be wrapped in OSC 8 hyperlinks. +#[derive(Debug)] +pub(super) struct RenderedStatusLine { + pub(super) line: Line<'static>, + pub(super) hyperlinks: Vec, +} + +/// Where a hyperlinked segment landed inside the rendered line. `col` is the cell column from +/// the start of the line, before any block / area offset is applied. +#[derive(Debug, Clone)] +pub(super) struct RenderedHyperlink { + pub(super) col: u16, + pub(super) width: u16, + pub(super) url: String, +} + impl StatusLine { pub(super) fn new(segments: Vec) -> Self { Self { segments } @@ -28,7 +48,7 @@ impl StatusLine { theme: &Theme, state: &StatusLineState<'_>, width: u16, - ) -> Line<'static> { + ) -> RenderedStatusLine { let sep = theme.separator_span(); let sep_width = UnicodeWidthStr::width(sep.content.as_ref()); let mut rendered = self @@ -38,22 +58,33 @@ impl StatusLine { .collect::>(); fit_segments(&mut rendered, usize::from(width), sep_width); + // Leading margin lines up content with the chat block underneath. let mut spans = vec![Span::raw(" ")]; - let mut first = true; - for segment in rendered { - if !first { + let mut hyperlinks = Vec::new(); + let mut col: u16 = STATUS_LINE_MARGIN; + let sep_w = u16::try_from(sep_width).unwrap_or(0); + for (index, segment) in rendered.iter().enumerate() { + if index > 0 { spans.push(sep.clone()); + col = col.saturating_add(sep_w); } - if let Some(url) = segment.hyperlink { - spans.push(osc8_open(&url)); - spans.push(segment.span); - spans.push(osc8_close()); - } else { - spans.push(segment.span); + let span_width = u16::try_from(segment.width()).unwrap_or(0); + if let Some(url) = &segment.hyperlink + && span_width > 0 + { + hyperlinks.push(RenderedHyperlink { + col, + width: span_width, + url: url.clone(), + }); } - first = false; + spans.push(segment.span.clone()); + col = col.saturating_add(span_width); + } + RenderedStatusLine { + line: Line::from(spans), + hyperlinks, } - Line::from(spans) } fn render_segment( @@ -220,17 +251,28 @@ fn non_empty_span(label: String, style: ratatui::style::Style) -> Option Span<'static> { - let sanitized: String = url.chars().filter(|c| !c.is_control()).collect(); - Span::raw(format!("\x1b]8;;{sanitized}\x1b\\")) -} - -fn osc8_close() -> Span<'static> { - Span::raw("\x1b]8;;\x1b\\") +/// Wraps each non-empty cell symbol in `area` with an OSC 8 hyperlink envelope. Control chars in +/// `url` are stripped so a malformed value cannot break out of the envelope. Cells whose symbol +/// is whitespace are skipped so the link doesn't extend over the surrounding margin. +/// +/// This works because crossterm's `Print` renders the cell symbol verbatim, while ratatui's +/// `Buffer::set_string` filters control chars before they reach the cell. Setting the symbol +/// directly via `Cell::set_symbol` bypasses that filter. +pub(super) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + let safe_url: String = url.chars().filter(|c| !c.is_control()).collect(); + if safe_url.is_empty() { + return; + } + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let cell = &mut buf[(x, y)]; + let sym = cell.symbol().to_string(); + if sym.trim().is_empty() { + continue; + } + cell.set_symbol(&format!("\x1b]8;;{safe_url}\x1b\\{sym}\x1b]8;;\x1b\\")); + } + } } fn model_with_effort(model: &str, effort: Option) -> String { @@ -294,7 +336,7 @@ mod tests { number: 86, url: "https://github.com/o/r/pull/86".to_owned(), }; - let line = StatusLine::new(segments).render( + let rendered = StatusLine::new(segments).render( &Theme::default(), &StatusLineState { model: "m", @@ -312,7 +354,9 @@ mod tests { }, width, ); - line.spans + rendered + .line + .spans .into_iter() .map(|span| span.content) .collect::() @@ -338,7 +382,7 @@ mod tests { #[test] fn render_truncates_single_oversized_segment_to_width() { - let line = StatusLine::new(vec![StatusLineSegment::RunState]).render( + let rendered = StatusLine::new(vec![StatusLineSegment::RunState]).render( &Theme::default(), &StatusLineState { model: "m", @@ -352,7 +396,8 @@ mod tests { }, 12, ); - let text = line + let text = rendered + .line .spans .into_iter() .map(|span| span.content) @@ -408,26 +453,44 @@ mod tests { "wide width keeps every segment: {full}", ); // Width 22 drops time (utility 1) before PR (2) and branch (3). Width 14 narrows further - // until only branch and run state remain. The OSC 8 wrapper measures as zero columns, so - // budget math doesn't change even with the escape bytes embedded. - let pr_open = "\x1b]8;;https://github.com/o/r/pull/86\x1b\\"; - let pr_close = "\x1b]8;;\x1b\\"; - assert_eq!( - render_text(segments.clone(), 22), - format!(" main │ {pr_open}#86{pr_close} │ Ready"), - ); + // until only branch and run state remain. + assert_eq!(render_text(segments.clone(), 22), " main │ #86 │ Ready"); assert_eq!(render_text(segments, 14), " main │ Ready"); } + fn pr_state() -> crate::util::git::PullRequest { + crate::util::git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + } + } + #[test] - fn pull_request_segment_emits_osc8_open_and_close_around_visible_text() { - let rendered = render_text(vec![StatusLineSegment::PullRequest], 80); - let url = "https://github.com/o/r/pull/86"; - assert!(rendered.contains(&format!("\x1b]8;;{url}\x1b\\#86\x1b]8;;\x1b\\"))); + fn pull_request_segment_reports_hyperlink_range_for_post_render_marking() { + let pr = pr_state(); + let rendered = StatusLine::new(vec![StatusLineSegment::PullRequest]).render( + &Theme::default(), + &StatusLineState { + model: "m", + effort: None, + title: None, + usage: None, + cwd: "~/repo", + git_branch: None, + pull_request: Some(&pr), + status_span: Span::raw("Ready"), + }, + 80, + ); + // After the leading " " margin the `#86` segment lives at col 2, width 3. + assert_eq!(rendered.hyperlinks.len(), 1); + assert_eq!(rendered.hyperlinks[0].col, 2); + assert_eq!(rendered.hyperlinks[0].width, 3); + assert_eq!(rendered.hyperlinks[0].url, pr.url); } #[test] - fn pull_request_segment_skips_osc8_when_absent() { + fn pull_request_segment_reports_no_hyperlink_when_absent() { let rendered = StatusLine::new(vec![StatusLineSegment::PullRequest]).render( &Theme::default(), &StatusLineState { @@ -442,19 +505,68 @@ mod tests { }, 80, ); - let text: String = rendered.spans.iter().map(|s| s.content.as_ref()).collect(); - assert!(!text.contains("\x1b]8;;"), "no OSC 8 bytes when PR absent"); + let text: String = rendered + .line + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(rendered.hyperlinks.is_empty(), "no PR → no hyperlink range"); assert!(!text.contains('#'), "no PR number rendered when absent"); } - // ── osc8_open ── + // ── mark_url_hyperlink ── #[test] - fn osc8_open_strips_control_chars_from_url() { - let span = osc8_open("https://example.com/\x1b\x07\x00ok"); + fn mark_url_hyperlink_wraps_each_non_blank_cell_with_osc8() { + let area = Rect::new(0, 0, 5, 1); + let mut buf = Buffer::empty(area); + // Pre-paint "ab cd" in the buffer (col 2 is whitespace). + buf[(0, 0)].set_symbol("a"); + buf[(1, 0)].set_symbol("b"); + buf[(2, 0)].set_symbol(" "); + buf[(3, 0)].set_symbol("c"); + buf[(4, 0)].set_symbol("d"); + + mark_url_hyperlink(&mut buf, area, "https://example.com"); + + assert_eq!( + buf[(0, 0)].symbol(), + "\x1b]8;;https://example.com\x1b\\a\x1b]8;;\x1b\\", + ); + assert_eq!(buf[(2, 0)].symbol(), " ", "whitespace cells stay untouched"); + assert_eq!( + buf[(4, 0)].symbol(), + "\x1b]8;;https://example.com\x1b\\d\x1b]8;;\x1b\\", + ); + } + + #[test] + fn mark_url_hyperlink_strips_control_chars_from_url() { + let area = Rect::new(0, 0, 1, 1); + let mut buf = Buffer::empty(area); + buf[(0, 0)].set_symbol("x"); + + mark_url_hyperlink(&mut buf, area, "https://example.com/\x1b\x07\x00ok"); + + assert_eq!( + buf[(0, 0)].symbol(), + "\x1b]8;;https://example.com/ok\x1b\\x\x1b]8;;\x1b\\", + ); + } + + #[test] + fn mark_url_hyperlink_with_empty_url_is_noop() { + let area = Rect::new(0, 0, 1, 1); + let mut buf = Buffer::empty(area); + buf[(0, 0)].set_symbol("x"); + + mark_url_hyperlink(&mut buf, area, "\x1b\x07"); + assert_eq!( - span.content.as_ref(), - "\x1b]8;;https://example.com/ok\x1b\\" + buf[(0, 0)].symbol(), + "x", + "no wrap when sanitized URL is empty" ); } diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index ca7123ee..5d29e831 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -46,9 +46,9 @@ Truncation walks back to a UTF-8 char boundary so the encoded string is always v ## OSC 8 hyperlinks -The `pull-request` status segment renders `#NN` wrapped in OSC 8 hyperlink bytes (`\x1b]8;;\x1b\\#NN\x1b]8;;\x1b\\`). Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal) make the number Ctrl-clickable and open it via the user's browser. Older terminals print the bytes verbatim and just show `#NN` as plain text. +The `pull-request` status segment renders `#NN` as plain spans, then the parent `StatusBar` post-paints OSC 8 escape bytes onto each cell of the segment so modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal) make the number Ctrl-clickable and open it via the user's browser. Older terminals print the bytes verbatim and just show `#NN` as plain text. -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. +Direct embedding of escape bytes inside `Span::raw` does not survive ratatui's renderer: `Buffer::set_string` filters out any grapheme that contains a control char, so the leading and trailing `\x1b` are stripped while the printable bytes (`]8;;\\#NN]8;;\\`) leak into the output as visible text. The fix is to bypass that filter via `Cell::set_symbol`, which stores the symbol verbatim and the crossterm Backend prints it unchanged. `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line, and `StatusBar::render` runs `mark_url_hyperlink` over each range after the line is painted. Control chars in the URL are stripped to keep a malformed value from breaking out of the OSC 8 envelope. ## Selection escape hatches From 30f22fc71e98273c9cc6ca6be8201ff2067a616d Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 19:27:10 +0800 Subject: [PATCH 15/35] fix(tui): forward OSC 52 through tmux DCS pass-through and flush stdout 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. --- .cspell/words.txt | 1 + crates/oxide-code/src/tui/app.rs | 58 +++++++++++++++++++++++--- crates/oxide-code/src/tui/selection.rs | 36 ++++++++++++++++ docs/design/tui/mouse-interactions.md | 4 +- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/.cspell/words.txt b/.cspell/words.txt index d8bdd08c..e5717d96 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -66,6 +66,7 @@ pgid pkgs pname println +Ptmux pulldown RAII ratatui diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 5de5a2d3..f081da52 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -280,7 +280,8 @@ impl App { /// Writes the OSC 52 sequence for the current selection into `sink`. Pushes a system message /// when the payload was truncated. No-op when there's no selection or when materialization - /// clips out, in which case `sink` is not touched. + /// clips out, in which case `sink` is not touched. Inside tmux, the sequence is also emitted + /// wrapped in DCS pass-through so it survives configurations without `set-clipboard on`. fn write_selection_to(&mut self, sink: &mut dyn std::io::Write) -> std::io::Result<()> { let Some(area) = self.chat_rect else { return Ok(()); @@ -292,6 +293,10 @@ impl App { }; let (sequence, truncated) = super::selection::osc52_set_clipboard(&payload); sink.write_all(&sequence)?; + if std::env::var_os("TMUX").is_some() { + sink.write_all(&super::selection::tmux_passthrough(&sequence))?; + } + sink.flush()?; if truncated { self.chat .push_system_message("Selection truncated to ~8 KB for the clipboard.".to_owned()); @@ -1644,7 +1649,9 @@ mod tests { modifiers: KeyModifiers::NONE, })); let mut sink = Vec::::new(); - app.write_selection_to(&mut sink).unwrap(); + temp_env::with_var_unset("TMUX", || { + app.write_selection_to(&mut sink).unwrap(); + }); assert!( sink.starts_with(b"\x1b]52;c;"), @@ -1652,7 +1659,44 @@ mod tests { ); assert!( sink.ends_with(b"\x07"), - "BEL terminates the OSC 52 sequence" + "BEL terminates the OSC 52 sequence outside tmux", + ); + } + + #[test] + fn write_selection_to_inside_tmux_also_emits_dcs_wrapped_payload() { + let mut app = drag_app_with_chat_text("hello world"); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 50, + row: 0, + modifiers: KeyModifiers::NONE, + })); + let mut sink = Vec::::new(); + temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { + app.write_selection_to(&mut sink).unwrap(); + }); + + // Bare OSC 52 first so a tmux config that already passes through gets the clipboard. + assert!( + sink.starts_with(b"\x1b]52;c;"), + "bare OSC 52 still opens the sink", + ); + // DCS pass-through wrapper follows so a tmux config without `set-clipboard on` still + // forwards the inner OSC 52 to the outer terminal. + assert!( + sink.windows(7).any(|w| w == b"\x1bPtmux;"), + "tmux DCS opener present in the emitted bytes", + ); + assert!( + sink.ends_with(b"\x1b\\"), + "DCS pass-through terminates the sink with ST", ); } @@ -1678,7 +1722,9 @@ mod tests { modifiers: KeyModifiers::NONE, })); let mut sink = Vec::::new(); - app.write_selection_to(&mut sink).unwrap(); + temp_env::with_var_unset("TMUX", || { + app.write_selection_to(&mut sink).unwrap(); + }); let last = app.chat.last_system_text().unwrap_or_default(); assert!( @@ -1711,7 +1757,9 @@ mod tests { modifiers: KeyModifiers::NONE, })); let mut sink = Vec::::new(); - app.write_selection_to(&mut sink).unwrap(); + temp_env::with_var_unset("TMUX", || { + app.write_selection_to(&mut sink).unwrap(); + }); assert!( sink.starts_with(b"\x1b]52;c;"), "scrolled-into-content drag still emits an OSC 52 payload", diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs index c4d438d9..85997fda 100644 --- a/crates/oxide-code/src/tui/selection.rs +++ b/crates/oxide-code/src/tui/selection.rs @@ -214,6 +214,22 @@ pub(super) fn osc52_set_clipboard(text: &str) -> (Vec, bool) { (out, truncated) } +/// Wraps an escape sequence in tmux's DCS pass-through (`\x1bPtmux;...\x1b\\`) so a tmux session +/// that doesn't have `set-clipboard on` still forwards the bytes to the outer terminal. Every +/// `\x1b` inside the payload must be doubled per the tmux spec. +pub(super) fn tmux_passthrough(escape: &[u8]) -> Vec { + let mut out = Vec::with_capacity(escape.len() + 8); + out.extend_from_slice(b"\x1bPtmux;"); + for &b in escape { + if b == 0x1b { + out.push(0x1b); + } + out.push(b); + } + out.extend_from_slice(b"\x1b\\"); + out +} + /// Largest byte index `<= cap` that lies on a UTF-8 char boundary. Walks back at most 3 bytes. fn floor_to_char_boundary(s: &str, cap: usize) -> usize { let mut i = cap.min(s.len()); @@ -430,6 +446,26 @@ mod tests { assert_eq!(decoded.len(), expected); } + // ── tmux_passthrough ── + + #[test] + fn tmux_passthrough_doubles_inner_escape_and_wraps_in_dcs() { + let inner = b"\x1b]52;c;abc\x07"; + let wrapped = tmux_passthrough(inner); + // DCS opener `\x1bPtmux;`, doubled inner ESC, BEL, DCS closer `\x1b\\`. + let expected = b"\x1bPtmux;\x1b\x1b]52;c;abc\x07\x1b\\"; + assert_eq!(&wrapped, expected); + } + + #[test] + fn tmux_passthrough_doubles_every_inner_escape() { + // Multiple ESCs (e.g. an OSC followed by another OSC) all need doubling so tmux strips its + // own DCS wrapper without consuming the inner sequences. + let inner = b"\x1b]A\x1b]B"; + let wrapped = tmux_passthrough(inner); + assert_eq!(&wrapped, b"\x1bPtmux;\x1b\x1b]A\x1b\x1b]B\x1b\\"); + } + // ── floor_to_char_boundary ── #[test] diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 5d29e831..931f1ed5 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -37,9 +37,11 @@ On left-button up, the app: 1. Reads `ChatView::rendered_text(width)` to materialize the same wrapped lines that were on screen. 2. Calls `Selection::materialize(text, area, scroll_offset)` to extract the substring (line-rect, unicode-width-aware). 3. Builds `\x1b]52;c;\x07` over the raw UTF-8 bytes via `osc52_set_clipboard`. -4. Writes the bytes to stdout (the terminal forwards them to the OS clipboard when configured). +4. Writes the bytes to stdout, then flushes so the sequence reaches the terminal before the next frame. 5. Pushes a system-message warning when the payload was clamped. +When `TMUX` is set in the environment, the app also emits a tmux DCS pass-through copy (`\x1bPtmux;\x1b\x1b]52;c;...\x07\x1b\\`) right after the bare sequence. tmux configurations without `set -g set-clipboard on` swallow the bare OSC 52, but the DCS-wrapped variant always reaches the outer terminal because tmux strips its DCS envelope and forwards the inner bytes verbatim. Configurations that already pass OSC 52 through receive both variants. The terminal's clipboard handler is idempotent, so a duplicate write is harmless. + Payload cap is 8 KB pre-base64 (xterm's conservative limit). kitty / iTerm2 / WezTerm tolerate more but the floor keeps the same selection working everywhere. Truncation walks back to a UTF-8 char boundary so the encoded string is always valid UTF-8. From 9b6a055b03aecb00eb221b804a2a5f40105e72bc Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 11:33:33 +0800 Subject: [PATCH 16/35] fix(tui): terminate OSC 8 cells with BEL for xterm.js compatibility 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. --- crates/oxide-code/src/tui/components/status/line.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 8e205892..cb11000c 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -258,6 +258,11 @@ fn non_empty_span(label: String, style: ratatui::style::Style) -> Option Date: Mon, 18 May 2026 11:52:54 +0800 Subject: [PATCH 17/35] refactor(tui): drop OSC 52 drag-selection in favor of native terminal 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). --- Cargo.lock | 1 - Cargo.toml | 1 - crates/oxide-code/Cargo.toml | 1 - crates/oxide-code/src/tui.rs | 1 - crates/oxide-code/src/tui/app.rs | 389 +------------- crates/oxide-code/src/tui/components/chat.rs | 10 - crates/oxide-code/src/tui/selection.rs | 525 ------------------- crates/oxide-code/src/tui/theme.rs | 2 - crates/oxide-code/themes/frappe.toml | 1 - crates/oxide-code/themes/latte.toml | 1 - crates/oxide-code/themes/macchiato.toml | 1 - crates/oxide-code/themes/material.toml | 1 - crates/oxide-code/themes/mocha.toml | 1 - 13 files changed, 27 insertions(+), 908 deletions(-) delete mode 100644 crates/oxide-code/src/tui/selection.rs diff --git a/Cargo.lock b/Cargo.lock index 8d248e77..86d6f9b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,7 +1575,6 @@ name = "oxide-code" version = "0.1.0-alpha.1" dependencies = [ "anyhow", - "base64", "clap", "crossterm", "dirs", diff --git a/Cargo.toml b/Cargo.toml index cda3397d..8b8fff54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ pedantic = { level = "warn", priority = -1 } [workspace.dependencies] anyhow = "1" -base64 = "0.22" clap = { version = "4", features = ["derive"] } crossterm = { version = "0.29", features = ["event-stream"] } dirs = "6" diff --git a/crates/oxide-code/Cargo.toml b/crates/oxide-code/Cargo.toml index 7915d275..fa3791ac 100644 --- a/crates/oxide-code/Cargo.toml +++ b/crates/oxide-code/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] anyhow.workspace = true -base64.workspace = true clap.workspace = true crossterm.workspace = true dirs.workspace = true diff --git a/crates/oxide-code/src/tui.rs b/crates/oxide-code/src/tui.rs index 3d57e942..d8389272 100644 --- a/crates/oxide-code/src/tui.rs +++ b/crates/oxide-code/src/tui.rs @@ -12,7 +12,6 @@ pub(crate) mod glyphs; pub(crate) mod markdown; pub(crate) mod modal; pub(crate) mod pending_calls; -pub(crate) mod selection; pub(crate) mod terminal; pub(crate) mod theme; pub(crate) mod wrap; diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index f081da52..dfde1a9f 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -9,9 +9,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; -use crossterm::event::{ - Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind, -}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEventKind}; use futures::{Stream, StreamExt}; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::text::{Line, Span}; @@ -49,12 +47,8 @@ pub(crate) struct App { theme: Theme, status_bar: StatusBar, chat: ChatView, - /// Rect of the chat area on the most recent frame. Bounds drag-selection events. - chat_rect: Option, /// Rect of the jump-to-bottom pill on the most recent frame, or `None` while it's hidden. jump_overlay_rect: Option, - /// In-flight mouse-drag selection over the chat region. - selection: super::selection::Selection, input: InputArea, session_info: LiveSessionInfo, agent_rx: mpsc::Receiver, @@ -111,9 +105,7 @@ impl App { theme: theme.clone(), status_bar, chat, - chat_rect: None, jump_overlay_rect: None, - selection: super::selection::Selection::default(), input: InputArea::new(theme), session_info, agent_rx, @@ -217,8 +209,8 @@ impl App { self.chat.handle_event(event); } } - Event::Mouse(mouse) => { - self.handle_mouse_event(event, *mouse); + Event::Mouse(_) => { + self.handle_mouse_event(event); } Event::Resize(..) => {} _ => return, @@ -226,82 +218,20 @@ impl App { self.dirty = true; } - /// Routes a mouse event in priority order: jump-overlay click → drag-selection state machine - /// → wheel scroll on chat. Each branch returns silently when the click misses every target. - fn handle_mouse_event(&mut self, event: &Event, mouse: MouseEvent) { - let position = Position::new(mouse.column, mouse.row); - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(rect) = self.jump_overlay_rect - && rect.contains(position) - { - self.chat.jump_to_bottom(); - return; - } - if let Some(chat_rect) = self.chat_rect - && chat_rect.contains(position) - { - self.selection.begin(mouse.column, mouse.row); - } - } - MouseEventKind::Drag(MouseButton::Left) => { - if self.selection.is_dragging() - && let Some(rect) = self.chat_rect - { - let clamped_col = mouse - .column - .clamp(rect.x, rect.x + rect.width.saturating_sub(1)); - let clamped_row = mouse - .row - .clamp(rect.y, rect.y + rect.height.saturating_sub(1)); - self.selection.update(clamped_col, clamped_row); - } - } - MouseEventKind::Up(MouseButton::Left) => { - if self.selection.is_dragging() { - self.copy_selection_to_clipboard(); - _ = self.selection.clear(); - } - } - _ => { - self.chat.handle_event(event); - } - } - } - - /// Materializes the current selection from the chat's rendered text and emits an OSC 52 - /// set-clipboard sequence. No-op when there's no selection or when materialization clips out. - fn copy_selection_to_clipboard(&mut self) { - let mut stdout = std::io::stdout().lock(); - if let Err(error) = self.write_selection_to(&mut stdout) { - tracing::debug!(?error, "OSC 52 write failed"); - } - } - - /// Writes the OSC 52 sequence for the current selection into `sink`. Pushes a system message - /// when the payload was truncated. No-op when there's no selection or when materialization - /// clips out, in which case `sink` is not touched. Inside tmux, the sequence is also emitted - /// wrapped in DCS pass-through so it survives configurations without `set-clipboard on`. - fn write_selection_to(&mut self, sink: &mut dyn std::io::Write) -> std::io::Result<()> { - let Some(area) = self.chat_rect else { - return Ok(()); - }; - let text = self.chat.rendered_text(area.width); - let scroll = self.chat.scroll_offset(); - let Some(payload) = self.selection.materialize(&text, area, scroll) else { - return Ok(()); - }; - let (sequence, truncated) = super::selection::osc52_set_clipboard(&payload); - sink.write_all(&sequence)?; - if std::env::var_os("TMUX").is_some() { - sink.write_all(&super::selection::tmux_passthrough(&sequence))?; - } - sink.flush()?; - if truncated { - self.chat - .push_system_message("Selection truncated to ~8 KB for the clipboard.".to_owned()); + /// Routes a mouse event. The TUI does not capture mouse input, so the only events it sees are + /// app-side affordances delivered through DECSET 1007 (alternate-scroll, which delivers wheel + /// as arrow-key sequences). Left-click on the cached jump-pill rect snaps to bottom; every + /// other event flows to chat for wheel scroll. Native drag-select-and-copy is preserved. + fn handle_mouse_event(&mut self, event: &Event) { + if let Event::Mouse(mouse) = event + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && let Some(rect) = self.jump_overlay_rect + && rect.contains(Position::new(mouse.column, mouse.row)) + { + self.chat.jump_to_bottom(); + return; } - Ok(()) + self.chat.handle_event(event); } fn apply_modal_action(&mut self, action: ModalAction) { @@ -783,15 +713,11 @@ impl App { self.status_bar.render(frame, chunks[0]); if self.chat.is_empty() && self.session_info.config.show_welcome { - self.chat_rect = None; let snap = WelcomeSnapshot::from_live(&self.session_info); welcome::paint(frame, chunks[1], &self.theme, &snap); } else { self.chat.render(frame, chunks[1]); self.render_jump_overlay(frame, chunks[1]); - self.chat_rect = Some(chunks[1]); - self.selection - .paint(frame.buffer_mut(), chunks[1], &self.theme); } if preview_height > 0 { self.render_preview(frame, chunks[2]); @@ -1465,7 +1391,9 @@ mod tests { #[test] fn left_click_outside_jump_overlay_does_not_jump_chat() { - // `chat_rect` is intentionally None so the click hits neither the pill nor the chat area. + // Without mouse capture the TUI never sees Down(Left) events from the terminal in real + // sessions, but DECSET 1007 lets simulated tests flow through `handle_mouse_event` so the + // pill hit-test stays exercised end-to-end. let (mut app, _rx, _agent_tx) = test_app(None); app.chat.content_height_for_test().set(100); app.chat.set_viewport_for_test(20); @@ -1482,288 +1410,25 @@ mod tests { assert_eq!(app.chat.scroll_offset_for_test(), 10); assert!(!app.chat.auto_scroll_for_test()); - assert!( - !app.selection.is_dragging(), - "click outside both rects must not arm selection" - ); - } - - #[test] - fn drag_in_chat_area_arms_selection_state_machine() { - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat_rect = Some(Rect::new(0, 2, 80, 20)); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 5, - row: 5, - modifiers: KeyModifiers::NONE, - })); - assert!(app.selection.is_dragging(), "down arms drag"); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 12, - row: 7, - modifiers: KeyModifiers::NONE, - })); - assert!(app.selection.is_dragging(), "drag updates endpoint"); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Up(MouseButton::Left), - column: 12, - row: 7, - modifiers: KeyModifiers::NONE, - })); - assert!( - !app.selection.is_dragging(), - "up clears the in-flight selection" - ); - } - - #[test] - fn left_click_outside_chat_area_does_not_arm_selection() { - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat_rect = Some(Rect::new(0, 2, 80, 20)); - - // Click in the status-bar row 0 (above chat_rect.y == 2). - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 5, - row: 0, - modifiers: KeyModifiers::NONE, - })); - assert!(!app.selection.is_dragging()); - } - - #[test] - fn drag_outside_chat_clamps_endpoint_into_rect() { - use crate::tui::selection::Selection; - let (mut app, _rx, _agent_tx) = test_app(None); - let rect = Rect::new(0, 2, 80, 20); - app.chat_rect = Some(rect); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 5, - row: 5, - modifiers: KeyModifiers::NONE, - })); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 500, - row: 500, - modifiers: KeyModifiers::NONE, - })); - - match &app.selection { - Selection::Dragging { end, .. } => { - let (col, row) = ((*end).col(), (*end).row()); - assert!( - col < rect.x + rect.width, - "endpoint clamped within rect width" - ); - assert!( - row < rect.y + rect.height, - "endpoint clamped within rect height" - ); - } - Selection::Idle => panic!("drag arm must keep the selection in Dragging"), - } } #[test] - fn mouse_up_outside_chat_still_finalizes_drag() { - // A release outside the chat rect would leak the highlight if Up gated on position. + fn wheel_scroll_event_routes_to_chat_view() { + // DECSET 1007 lets the terminal translate physical wheel into arrow-key sequences in real + // sessions, but tests inject ScrollUp / ScrollDown directly to exercise the routing. let (mut app, _rx, _agent_tx) = test_app(None); - app.chat_rect = Some(Rect::new(0, 2, 80, 20)); + app.chat.content_height_for_test().set(100); + app.chat.set_viewport_for_test(20); + app.chat.set_scroll_offset_for_test(10); app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), + kind: MouseEventKind::ScrollUp, column: 5, row: 5, modifiers: KeyModifiers::NONE, })); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 12, - row: 7, - modifiers: KeyModifiers::NONE, - })); - // Up fires from far outside the chat rect. - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Up(MouseButton::Left), - column: 999, - row: 999, - modifiers: KeyModifiers::NONE, - })); - assert!( - !app.selection.is_dragging(), - "up always clears regardless of position" - ); - } - - // ── write_selection_to ── - - fn drag_app_with_chat_text(text: &str) -> App { - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat.push_user_message(text.to_owned()); - // The user-message block paints "❯ " on a single row, so drags start from col 0. - app.chat_rect = Some(Rect::new(0, 0, 80, 5)); - app - } - - #[test] - fn write_selection_to_is_noop_without_chat_rect() { - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat_rect = None; - let mut sink = Vec::::new(); - app.write_selection_to(&mut sink).unwrap(); - assert!(sink.is_empty(), "no rect → no OSC 52 bytes"); - } - - #[test] - fn write_selection_to_is_noop_without_drag() { - let mut app = drag_app_with_chat_text("hello world"); - let mut sink = Vec::::new(); - app.write_selection_to(&mut sink).unwrap(); - assert!(sink.is_empty(), "Selection::Idle → no OSC 52 bytes"); - } - - #[test] - fn write_selection_to_emits_osc52_payload_for_dragged_text() { - let mut app = drag_app_with_chat_text("hello world"); - // The chat block prefixes content with a glyph, so the assertion targets the OSC 52 - // envelope rather than exact bytes. - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - })); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 50, - row: 0, - modifiers: KeyModifiers::NONE, - })); - let mut sink = Vec::::new(); - temp_env::with_var_unset("TMUX", || { - app.write_selection_to(&mut sink).unwrap(); - }); - - assert!( - sink.starts_with(b"\x1b]52;c;"), - "OSC 52 envelope opens the sink" - ); - assert!( - sink.ends_with(b"\x07"), - "BEL terminates the OSC 52 sequence outside tmux", - ); - } - - #[test] - fn write_selection_to_inside_tmux_also_emits_dcs_wrapped_payload() { - let mut app = drag_app_with_chat_text("hello world"); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - })); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 50, - row: 0, - modifiers: KeyModifiers::NONE, - })); - let mut sink = Vec::::new(); - temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { - app.write_selection_to(&mut sink).unwrap(); - }); - - // Bare OSC 52 first so a tmux config that already passes through gets the clipboard. - assert!( - sink.starts_with(b"\x1b]52;c;"), - "bare OSC 52 still opens the sink", - ); - // DCS pass-through wrapper follows so a tmux config without `set-clipboard on` still - // forwards the inner OSC 52 to the outer terminal. - assert!( - sink.windows(7).any(|w| w == b"\x1bPtmux;"), - "tmux DCS opener present in the emitted bytes", - ); - assert!( - sink.ends_with(b"\x1b\\"), - "DCS pass-through terminates the sink with ST", - ); - } - - #[test] - fn write_selection_to_pushes_truncation_message_when_payload_clamped() { - let big = "A".repeat(crate::tui::selection::OSC52_PAYLOAD_BYTES + 100); - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat.push_user_message(big); - // 8 KB of "A" wraps to ~104 rows at 80 cols, so the chat rect must be tall enough for - // materialize to span the full content during the drag. - app.chat_rect = Some(Rect::new(0, 0, 80, 200)); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - })); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 79, - row: 199, - modifiers: KeyModifiers::NONE, - })); - let mut sink = Vec::::new(); - temp_env::with_var_unset("TMUX", || { - app.write_selection_to(&mut sink).unwrap(); - }); - - let last = app.chat.last_system_text().unwrap_or_default(); - assert!( - last.contains("Selection truncated"), - "truncation pushes a system message: {last:?}", - ); - } - - #[test] - fn write_selection_to_uses_current_scroll_offset_for_materialization() { - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat.push_user_message("first row content".to_owned()); - app.chat.push_user_message("second row content".to_owned()); - app.chat.push_user_message("third row content".to_owned()); - app.chat_rect = Some(Rect::new(0, 0, 80, 2)); - // Force the viewport to look 4 rows past the top so the second message is at row 0. - app.chat.set_viewport_for_test(2); - app.chat.set_scroll_offset_for_test(4); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - })); - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Drag(MouseButton::Left), - column: 79, - row: 0, - modifiers: KeyModifiers::NONE, - })); - let mut sink = Vec::::new(); - temp_env::with_var_unset("TMUX", || { - app.write_selection_to(&mut sink).unwrap(); - }); - assert!( - sink.starts_with(b"\x1b]52;c;"), - "scrolled-into-content drag still emits an OSC 52 payload", - ); + assert_eq!(app.chat.scroll_offset_for_test(), 9); } // ── modal gate ── diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 3cb7b412..f6700955 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -387,16 +387,6 @@ impl ChatView { frame.render_widget(paragraph, area); } - /// Current scroll offset in content rows. - pub(crate) fn scroll_offset(&self) -> u16 { - self.scroll_offset - } - - /// Wrapped `Text` for the given viewport width. - pub(crate) fn rendered_text(&self, width: u16) -> Text<'static> { - self.build_text(width) - } - /// Scrolls to the latest content and re-arms auto-scroll. pub(crate) fn jump_to_bottom(&mut self) { self.scroll_to_bottom(); diff --git a/crates/oxide-code/src/tui/selection.rs b/crates/oxide-code/src/tui/selection.rs deleted file mode 100644 index 85997fda..00000000 --- a/crates/oxide-code/src/tui/selection.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! Mouse-drag text selection over the chat viewport. -//! -//! [`Selection`] is a small state machine driven by the app's mouse handler. It tracks the start -//! and current cell coordinates of a left-button drag, materializes the selected text from the -//! chat's rendered `Text` buffer, and writes the result to the system clipboard via OSC 52. -//! -//! Selection geometry follows terminal convention: from `(start_row, start_col)` the selection -//! extends to the end of that row, all of every intermediate row, and from column 0 to -//! `(end_row, end_col)` on the final row. Block / column selection is deferred. - -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::text::Text; -use unicode_width::UnicodeWidthChar; - -use crate::tui::theme::Theme; - -/// Conservative pre-base64 cap. xterm's OSC budget is ~8 KB. kitty and iTerm2 are larger, so -/// picking the floor lets the same selection work on every supported terminal. -pub(super) const OSC52_PAYLOAD_BYTES: usize = 8 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Cell { - col: u16, - row: u16, -} - -#[cfg(test)] -impl Cell { - pub(super) fn col(self) -> u16 { - self.col - } - - pub(super) fn row(self) -> u16 { - self.row - } -} - -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub(super) enum Selection { - #[default] - Idle, - /// Left button is pressed. `end` updates on every drag event. - Dragging { start: Cell, end: Cell }, -} - -impl Selection { - pub(super) fn is_dragging(&self) -> bool { - matches!(self, Selection::Dragging { .. }) - } - - /// Begins a drag at `(col, row)`. The first matching `update` distinguishes the gesture from - /// a bare click by moving `end` away from `start`. - pub(super) fn begin(&mut self, col: u16, row: u16) { - let cell = Cell { col, row }; - *self = Selection::Dragging { - start: cell, - end: cell, - }; - } - - /// Updates the drag endpoint. No-op when not currently dragging. - pub(super) fn update(&mut self, col: u16, row: u16) { - if let Selection::Dragging { end, .. } = self { - *end = Cell { col, row }; - } - } - - /// Clears any in-flight drag. Returns the prior state so callers can finalize before clearing. - pub(super) fn clear(&mut self) -> Selection { - std::mem::replace(self, Selection::Idle) - } - - /// Returns the normalized `(start, end)` cells with `start` always above-or-left of `end`. - /// `None` when idle or when `start == end`. - fn normalized(&self) -> Option<(Cell, Cell)> { - match self { - Selection::Dragging { start, end } if start != end => { - let (a, b) = if (start.row, start.col) <= (end.row, end.col) { - (*start, *end) - } else { - (*end, *start) - }; - Some((a, b)) - } - _ => None, - } - } - - /// Materializes the selected text from `text` clipped to `area` with `scroll_offset`. Returns - /// `None` when there's no selection or when the drag misses the chat area entirely. - pub(super) fn materialize( - &self, - text: &Text<'_>, - area: Rect, - scroll_offset: u16, - ) -> Option { - let (start, end) = self.normalized()?; - let row_range = clip_rows(start, end, area)?; - let mut out = String::new(); - for screen_row in row_range { - let line_idx = usize::from(scroll_offset) + usize::from(screen_row - area.y); - let Some(line) = text.lines.get(line_idx) else { - break; - }; - let (col_start, col_end) = - row_columns(screen_row, start, end, area.x, area.x + area.width); - let segment = slice_line(line, col_start - area.x, col_end - area.x); - if !out.is_empty() { - out.push('\n'); - } - out.push_str(&segment); - } - if out.is_empty() { None } else { Some(out) } - } - - /// Highlights selected cells in `buf` using `theme.selection`. Cells outside the chat `area` - /// are clipped. No-op when not dragging. - pub(super) fn paint(&self, buf: &mut Buffer, area: Rect, theme: &Theme) { - let Some((start, end)) = self.normalized() else { - return; - }; - let Some(rows) = clip_rows(start, end, area) else { - return; - }; - let style = theme.selection.style(); - for row in rows { - let (col_start, col_end) = row_columns(row, start, end, area.x, area.x + area.width); - for col in col_start..col_end { - if col < buf.area.x + buf.area.width && row < buf.area.y + buf.area.height { - buf[(col, row)].set_style(style); - } - } - } - } -} - -/// Clip the selection's row span to `area`. `None` when entirely above or below. -fn clip_rows(start: Cell, end: Cell, area: Rect) -> Option> { - let area_top = area.y; - let area_bottom = area.y + area.height; - let lo = start.row.max(area_top); - let hi = (end.row + 1).min(area_bottom); - if lo >= hi { None } else { Some(lo..hi) } -} - -/// Per-row column range. The first row starts at `start.col`, the last row ends at `end.col + 1`, -/// every other row spans the full chat-area width. -fn row_columns(row: u16, start: Cell, end: Cell, area_left: u16, area_right: u16) -> (u16, u16) { - let lo = if row == start.row { - start.col.max(area_left) - } else { - area_left - }; - let hi = if row == end.row { - (end.col + 1).min(area_right) - } else { - area_right - }; - (lo.min(area_right), hi.min(area_right)) -} - -/// Walks the line's spans to extract the slice between cell columns `[col_start, col_end)`. -/// Wide chars (`UnicodeWidthChar::width == 2`) are taken whole when their leading half lands -/// inside `[col_start, col_end)` so multi-byte sequences never split. Zero-width combining -/// marks attach to the preceding base char and are kept whenever that base char was kept. -fn slice_line(line: &ratatui::text::Line<'_>, col_start: u16, col_end: u16) -> String { - let mut out = String::new(); - let mut col: u16 = 0; - let mut last_base_kept = false; - 'spans: for span in &line.spans { - for ch in span.content.chars() { - let w = u16::try_from(UnicodeWidthChar::width(ch).unwrap_or(0)).unwrap_or(0); - if w == 0 { - if last_base_kept { - out.push(ch); - } - continue; - } - if col >= col_end { - break 'spans; - } - let next = col.saturating_add(w); - let kept = next > col_start; - if kept { - out.push(ch); - } - last_base_kept = kept; - col = next; - } - } - out -} - -/// OSC 52 set-clipboard sequence. `c` selects the system clipboard (xterm `selection`). -/// Returns the bytes plus a flag indicating whether the payload was clamped. -pub(super) fn osc52_set_clipboard(text: &str) -> (Vec, bool) { - let bytes = text.as_bytes(); - let (clipped, truncated) = if bytes.len() > OSC52_PAYLOAD_BYTES { - ( - &bytes[..floor_to_char_boundary(text, OSC52_PAYLOAD_BYTES)], - true, - ) - } else { - (bytes, false) - }; - let encoded = BASE64.encode(clipped); - let mut out = Vec::with_capacity(encoded.len() + 8); - out.extend_from_slice(b"\x1b]52;c;"); - out.extend_from_slice(encoded.as_bytes()); - out.push(0x07); - (out, truncated) -} - -/// Wraps an escape sequence in tmux's DCS pass-through (`\x1bPtmux;...\x1b\\`) so a tmux session -/// that doesn't have `set-clipboard on` still forwards the bytes to the outer terminal. Every -/// `\x1b` inside the payload must be doubled per the tmux spec. -pub(super) fn tmux_passthrough(escape: &[u8]) -> Vec { - let mut out = Vec::with_capacity(escape.len() + 8); - out.extend_from_slice(b"\x1bPtmux;"); - for &b in escape { - if b == 0x1b { - out.push(0x1b); - } - out.push(b); - } - out.extend_from_slice(b"\x1b\\"); - out -} - -/// Largest byte index `<= cap` that lies on a UTF-8 char boundary. Walks back at most 3 bytes. -fn floor_to_char_boundary(s: &str, cap: usize) -> usize { - let mut i = cap.min(s.len()); - while i > 0 && !s.is_char_boundary(i) { - i -= 1; - } - i -} - -#[cfg(test)] -mod tests { - use ratatui::style::Color; - use ratatui::text::{Line, Span}; - - use super::*; - - fn cell(col: u16, row: u16) -> Cell { - Cell { col, row } - } - - fn fixture_text() -> Text<'static> { - Text::from(vec![ - Line::from("hello world"), - Line::from("second line"), - Line::from("third"), - ]) - } - - // ── Selection ── - - #[test] - fn begin_then_update_tracks_drag_endpoint() { - let mut s = Selection::default(); - s.begin(2, 3); - s.update(7, 5); - assert_eq!( - s, - Selection::Dragging { - start: cell(2, 3), - end: cell(7, 5), - } - ); - assert!(s.is_dragging()); - } - - #[test] - fn clear_returns_prior_state_and_resets_to_idle() { - let mut s = Selection::default(); - s.begin(0, 0); - let prior = s.clear(); - assert!(matches!(prior, Selection::Dragging { .. })); - assert_eq!(s, Selection::Idle); - } - - #[test] - fn normalized_orders_start_before_end() { - let mut s = Selection::default(); - s.begin(7, 5); - s.update(2, 3); - let (start, end) = s.normalized().expect("normalizes despite reversed drag"); - assert_eq!((start.col, start.row), (2, 3)); - assert_eq!((end.col, end.row), (7, 5)); - } - - #[test] - fn normalized_is_none_for_zero_length_drag() { - let mut s = Selection::default(); - s.begin(4, 4); - s.update(4, 4); - assert!(s.normalized().is_none(), "click without drag"); - } - - // ── materialize ── - - #[test] - fn materialize_single_row_returns_substring() { - let mut s = Selection::default(); - s.begin(2, 0); - s.update(7, 0); - let area = Rect::new(0, 0, 80, 3); - assert_eq!(s.materialize(&fixture_text(), area, 0).unwrap(), "llo wo"); - } - - #[test] - fn materialize_multi_row_joins_with_newlines() { - let mut s = Selection::default(); - s.begin(6, 0); - s.update(5, 1); - let area = Rect::new(0, 0, 80, 3); - assert_eq!( - s.materialize(&fixture_text(), area, 0).unwrap(), - "world\nsecond", - ); - } - - #[test] - fn materialize_respects_scroll_offset() { - let mut s = Selection::default(); - s.begin(0, 0); - s.update(4, 0); - let area = Rect::new(0, 0, 80, 3); - // scroll_offset 2 puts content row "third" at screen row 0. - assert_eq!(s.materialize(&fixture_text(), area, 2).unwrap(), "third"); - } - - #[test] - fn materialize_returns_none_when_drag_starts_below_area() { - let mut s = Selection::default(); - s.begin(0, 5); - s.update(99, 99); - let area = Rect::new(0, 0, 80, 3); - assert!(s.materialize(&fixture_text(), area, 0).is_none()); - } - - // ── slice_line ── - - #[test] - fn slice_line_keeps_cjk_chars_whole_at_each_boundary() { - let line = Line::from("Hello 你好 World"); - // Columns: H=0 e=1 l=2 l=3 o=4 ' '=5 你=6,7(wide) 好=8,9(wide) ' '=10 W=11 ... - // Slice from col 6 to col 10 should include both CJK chars in full. - assert_eq!(slice_line(&line, 6, 10), "你好"); - // Slice that begins inside the trailing half of `你` is greedy: the wide char's leading - // half at col 6 contributes content past the 7-col boundary, so the whole char is taken. - assert_eq!(slice_line(&line, 7, 10), "你好"); - // Slice that begins exactly at `好`'s leading half (col 8) skips `你`. - assert_eq!(slice_line(&line, 8, 10), "好"); - } - - #[test] - fn slice_line_handles_emoji_and_mixed_widths() { - let line = Line::from(vec![Span::raw("ab"), Span::raw("好"), Span::raw("c")]); - // a=0 b=1 好=2,3 c=4. Selecting cols 1..=4 should pick "b好c". - assert_eq!(slice_line(&line, 1, 5), "b好c"); - } - - #[test] - fn slice_line_preserves_zero_width_combining_marks() { - // NFD-decomposed `é` is `e` + COMBINING ACUTE ACCENT (U+0301, width 0). - let line = Line::from("e\u{0301}cho"); - // Selecting just the first column keeps the base char and its combining mark. - assert_eq!(slice_line(&line, 0, 1), "e\u{0301}"); - // Selecting from col 1 starts past the combining mark's base, so it's dropped along with - // the base it modified. - assert_eq!(slice_line(&line, 1, 4), "cho"); - } - - // ── osc52_set_clipboard ── - - #[test] - fn osc52_round_trips_cjk_bytes() { - let payload = "Hello 你好 🌏 World"; - let (sequence, truncated) = osc52_set_clipboard(payload); - assert!(!truncated); - let prefix = b"\x1b]52;c;"; - let suffix = b"\x07"; - assert!(sequence.starts_with(prefix)); - assert!(sequence.ends_with(suffix)); - let b64 = &sequence[prefix.len()..sequence.len() - suffix.len()]; - let decoded = BASE64.decode(b64).expect("valid base64"); - assert_eq!(decoded, payload.as_bytes(), "UTF-8 round-trip preserved"); - } - - #[test] - fn osc52_clamps_oversize_payload_at_char_boundary() { - let big = "A".repeat(OSC52_PAYLOAD_BYTES + 1024); - let (sequence, truncated) = osc52_set_clipboard(&big); - assert!(truncated); - let prefix = b"\x1b]52;c;"; - let b64 = &sequence[prefix.len()..sequence.len() - 1]; - let decoded = BASE64.decode(b64).unwrap(); - assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); - } - - #[test] - fn osc52_at_exact_cap_is_not_truncated() { - let payload = "A".repeat(OSC52_PAYLOAD_BYTES); - let (sequence, truncated) = osc52_set_clipboard(&payload); - assert!(!truncated, "exact-cap payload must pass through untouched"); - let prefix = b"\x1b]52;c;"; - let b64 = &sequence[prefix.len()..sequence.len() - 1]; - let decoded = BASE64.decode(b64).unwrap(); - assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); - } - - #[test] - fn osc52_one_byte_over_cap_truncates_to_cap() { - let payload = "A".repeat(OSC52_PAYLOAD_BYTES + 1); - let (sequence, truncated) = osc52_set_clipboard(&payload); - assert!( - truncated, - "payload one byte over the cap must report truncation" - ); - let prefix = b"\x1b]52;c;"; - let b64 = &sequence[prefix.len()..sequence.len() - 1]; - let decoded = BASE64.decode(b64).unwrap(); - assert_eq!(decoded.len(), OSC52_PAYLOAD_BYTES); - } - - #[test] - fn osc52_truncates_at_cjk_char_boundary_not_mid_byte() { - // `好` is 3 bytes. Repeat enough to land the cap mid-char and force a boundary walk-back. - let payload = "好".repeat((OSC52_PAYLOAD_BYTES / 3) + 10); - let (sequence, truncated) = osc52_set_clipboard(&payload); - assert!(truncated); - let prefix = b"\x1b]52;c;"; - let b64 = &sequence[prefix.len()..sequence.len() - 1]; - let decoded = BASE64.decode(b64).unwrap(); - // Decoded bytes must be valid UTF-8: a mid-3-byte truncation would fail std::str. - let kept = std::str::from_utf8(&decoded).expect("truncation lands on a UTF-8 boundary"); - assert!(kept.chars().all(|c| c == '好')); - // Length is the largest multiple of 3 that fits inside the cap. - let expected = OSC52_PAYLOAD_BYTES - (OSC52_PAYLOAD_BYTES % 3); - assert_eq!(decoded.len(), expected); - } - - // ── tmux_passthrough ── - - #[test] - fn tmux_passthrough_doubles_inner_escape_and_wraps_in_dcs() { - let inner = b"\x1b]52;c;abc\x07"; - let wrapped = tmux_passthrough(inner); - // DCS opener `\x1bPtmux;`, doubled inner ESC, BEL, DCS closer `\x1b\\`. - let expected = b"\x1bPtmux;\x1b\x1b]52;c;abc\x07\x1b\\"; - assert_eq!(&wrapped, expected); - } - - #[test] - fn tmux_passthrough_doubles_every_inner_escape() { - // Multiple ESCs (e.g. an OSC followed by another OSC) all need doubling so tmux strips its - // own DCS wrapper without consuming the inner sequences. - let inner = b"\x1b]A\x1b]B"; - let wrapped = tmux_passthrough(inner); - assert_eq!(&wrapped, b"\x1bPtmux;\x1b\x1b]A\x1b\x1b]B\x1b\\"); - } - - // ── floor_to_char_boundary ── - - #[test] - fn floor_to_char_boundary_walks_back_through_multibyte() { - // `好` is 3 bytes (e5 a5 bd). Cap inside the 2nd or 3rd byte must rewind to the start. - let s = "A好B"; - assert_eq!(floor_to_char_boundary(s, 2), 1, "between A and 好"); - assert_eq!(floor_to_char_boundary(s, 3), 1, "inside 好's 2nd byte"); - assert_eq!(floor_to_char_boundary(s, 4), 4, "between 好 and B"); - } - - // ── paint ── - - #[test] - fn paint_applies_selection_style_to_in_area_cells() { - let area = Rect::new(0, 0, 10, 3); - let mut buf = Buffer::empty(area); - // Pre-fill with a sentinel so "untouched" assertions distinguish a skipped paint from a - // cell that happened to default to `Color::Reset`. - for y in area.y..area.y + area.height { - for x in area.x..area.x + area.width { - buf[(x, y)].bg = Color::Cyan; - } - } - let mut theme = Theme::default(); - theme.selection.bg = Some(Color::Red); - - let mut s = Selection::default(); - s.begin(2, 1); - s.update(5, 1); - s.paint(&mut buf, area, &theme); - - for col in 2..=5 { - assert_eq!(buf[(col, 1)].bg, Color::Red, "col {col} highlighted"); - } - assert_eq!( - buf[(1, 1)].bg, - Color::Cyan, - "sentinel survives before selection" - ); - assert_eq!( - buf[(6, 1)].bg, - Color::Cyan, - "sentinel survives after selection" - ); - assert_eq!( - buf[(2, 0)].bg, - Color::Cyan, - "rows above selection untouched" - ); - assert_eq!( - buf[(2, 2)].bg, - Color::Cyan, - "rows below selection untouched" - ); - } -} diff --git a/crates/oxide-code/src/tui/theme.rs b/crates/oxide-code/src/tui/theme.rs index ece83ad6..65c048f1 100644 --- a/crates/oxide-code/src/tui/theme.rs +++ b/crates/oxide-code/src/tui/theme.rs @@ -100,7 +100,6 @@ macro_rules! for_each_slot { (border_focused, "Focused component border"), (border_unfocused, "Unfocused component border (default-aligned with `dim`)"), (separator, "Status-line segment separator"), - (selection, "Mouse-drag selection highlight (bg-only)"), } }; } @@ -329,7 +328,6 @@ mod tests { assert_ne!(t.user.fg, t.assistant.fg); assert_ne!(t.success.fg, t.error.fg); assert_ne!(t.diff_add.bg, t.diff_del.bg); - assert!(t.selection.bg.is_some(), "selection slot must define a bg"); } // ── Style helpers ── diff --git a/crates/oxide-code/themes/frappe.toml b/crates/oxide-code/themes/frappe.toml index e51a6e22..78fba13a 100644 --- a/crates/oxide-code/themes/frappe.toml +++ b/crates/oxide-code/themes/frappe.toml @@ -52,4 +52,3 @@ tool_icon = "#8caaee" border_focused = "#8caaee" border_unfocused = "#626880" separator = "#626880" -selection = { bg = "#51576d" } diff --git a/crates/oxide-code/themes/latte.toml b/crates/oxide-code/themes/latte.toml index 55218543..89a96e62 100644 --- a/crates/oxide-code/themes/latte.toml +++ b/crates/oxide-code/themes/latte.toml @@ -52,4 +52,3 @@ tool_icon = "#1e66f5" border_focused = "#1e66f5" border_unfocused = "#acb0be" separator = "#acb0be" -selection = { bg = "#bcc0cc" } diff --git a/crates/oxide-code/themes/macchiato.toml b/crates/oxide-code/themes/macchiato.toml index cc44c595..69a45975 100644 --- a/crates/oxide-code/themes/macchiato.toml +++ b/crates/oxide-code/themes/macchiato.toml @@ -52,4 +52,3 @@ tool_icon = "#8aadf4" border_focused = "#8aadf4" border_unfocused = "#5b6078" separator = "#5b6078" -selection = { bg = "#494d64" } diff --git a/crates/oxide-code/themes/material.toml b/crates/oxide-code/themes/material.toml index bf1f927f..f616cb56 100644 --- a/crates/oxide-code/themes/material.toml +++ b/crates/oxide-code/themes/material.toml @@ -56,4 +56,3 @@ tool_icon = "#bb86fc" border_focused = "#bb86fc" border_unfocused = "#424242" separator = "#424242" -selection = { bg = "#3c3c3c" } diff --git a/crates/oxide-code/themes/mocha.toml b/crates/oxide-code/themes/mocha.toml index 19c2d8d8..7b6f9775 100644 --- a/crates/oxide-code/themes/mocha.toml +++ b/crates/oxide-code/themes/mocha.toml @@ -52,4 +52,3 @@ tool_icon = "#89b4fa" border_focused = "#89b4fa" border_unfocused = "#585b70" separator = "#585b70" -selection = { bg = "#45475a" } From 1c58f029066a3df656f736f71cc08a44c1507d3f Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 11:53:40 +0800 Subject: [PATCH 18/35] feat(tui): drop mouse capture and route wheel via DECSET 1007 `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. --- crates/oxide-code/src/tui/terminal.rs | 84 +++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/oxide-code/src/tui/terminal.rs b/crates/oxide-code/src/tui/terminal.rs index 2956df41..efcf60dc 100644 --- a/crates/oxide-code/src/tui/terminal.rs +++ b/crates/oxide-code/src/tui/terminal.rs @@ -1,11 +1,12 @@ //! Terminal initialization, restore, and panic hook. +use std::fmt; use std::io::{self, Stdout, Write}; use anyhow::Result; +use crossterm::Command; use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::terminal::{ self, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, @@ -16,8 +17,8 @@ use ratatui::prelude::CrosstermBackend; pub(crate) type Tui = Terminal>; -/// Enters raw mode + alt screen + mouse + Kitty keyboard. Caller must invoke [`restore`] on exit -/// (including panics). +/// Enters raw mode + alt screen + alternate-scroll + Kitty keyboard. Caller must invoke [`restore`] +/// on exit (including panics). pub(crate) fn init() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); @@ -32,7 +33,7 @@ fn enter_tui_mode(stdout: &mut impl Write) -> Result<()> { execute!( stdout, EnterAlternateScreen, - EnableMouseCapture, + EnableAlternateScroll, PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES), terminal::Clear(terminal::ClearType::All), )?; @@ -53,7 +54,7 @@ pub(crate) fn restore() { fn leave_tui_mode(stdout: &mut impl Write) -> Result<()> { execute!( stdout, - DisableMouseCapture, + DisableAlternateScroll, PopKeyboardEnhancementFlags, LeaveAlternateScreen, crossterm::cursor::Show, @@ -61,6 +62,52 @@ fn leave_tui_mode(stdout: &mut impl Write) -> Result<()> { Ok(()) } +/// DECSET `?1007h`. Tells the terminal emulator to translate physical wheel-mouse events into +/// arrow-key sequences while the alternate screen is active. Wheel scroll keeps working without +/// claiming `EnableMouseCapture`, so native drag-select-and-copy stays available to the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EnableAlternateScroll; + +impl Command for EnableAlternateScroll { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[?1007h") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::other( + "EnableAlternateScroll requires ANSI sequences", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// DECSET `?1007l`. Pairs with [`EnableAlternateScroll`] on shutdown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DisableAlternateScroll; + +impl Command for DisableAlternateScroll { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[?1007l") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::other( + "DisableAlternateScroll requires ANSI sequences", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + /// Brackets a render closure with DEC synchronized-update (mode 2026) sequences so the terminal /// presents the new frame atomically; without this, fast successive renders can show partial /// frames with mid-line tearing. @@ -100,6 +147,8 @@ mod tests { const ENTER_ALT_SCREEN: &[u8] = b"\x1b[?1049h"; const CLEAR_SCREEN: &[u8] = b"\x1b[2J"; + const ENABLE_ALT_SCROLL: &[u8] = b"\x1b[?1007h"; + const ENABLE_MOUSE_BASIC: &[u8] = b"\x1b[?1000h"; #[test] fn enter_tui_mode_writes_setup_sequences() { @@ -113,12 +162,31 @@ mod tests { enter < clear, "setup should enter alternate screen before clearing" ); + assert!( + index_of(&buf, ENABLE_ALT_SCROLL).is_some(), + "alternate-scroll must be enabled so wheel still scrolls without mouse capture" + ); + } + + #[test] + fn enter_tui_mode_does_not_enable_mouse_capture() { + // Native drag-select-and-copy depends on the terminal seeing the mouse, so the TUI must + // never claim it. Pin the negative case so a future refactor can't silently re-enable it. + let mut buf = Vec::new(); + + enter_tui_mode(&mut buf).unwrap(); + + assert!( + index_of(&buf, ENABLE_MOUSE_BASIC).is_none(), + "EnableMouseCapture would suppress native terminal drag-select" + ); } // ── leave_tui_mode ── const LEAVE_ALT_SCREEN: &[u8] = b"\x1b[?1049l"; const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; + const DISABLE_ALT_SCROLL: &[u8] = b"\x1b[?1007l"; #[test] fn leave_tui_mode_writes_restore_sequences() { @@ -132,6 +200,10 @@ mod tests { leave < show, "restore should leave alternate screen before showing cursor" ); + assert!( + index_of(&buf, DISABLE_ALT_SCROLL).is_some(), + "alternate-scroll must be disabled when leaving the TUI" + ); } // ── draw_sync ── From 901877d536cf07a74fa79aa384fec1522f5d913b Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 11:59:47 +0800 Subject: [PATCH 19/35] docs(tui): rewrite mouse-interactions notes around the no-capture design 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. --- .cspell/words.txt | 3 +- CLAUDE.md | 3 +- docs/design/README.md | 14 +-- docs/design/tui/mouse-interactions.md | 125 +++++++++--------------- docs/research/README.md | 14 +-- docs/research/tui/mouse-interactions.md | 19 ++-- 6 files changed, 76 insertions(+), 102 deletions(-) diff --git a/.cspell/words.txt b/.cspell/words.txt index e5717d96..f48e9fe8 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -46,6 +46,7 @@ Konsole latte macchiato misparse +misparses MMIX mplan mpsc @@ -66,11 +67,9 @@ pgid pkgs pname println -Ptmux pulldown RAII ratatui -rects replacen reqwest reserialized diff --git a/CLAUDE.md b/CLAUDE.md index 4fdc2943..04478dff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,9 +150,8 @@ ox # Start an interactive session │ │ ├── list_picker.rs # Generic ListPicker: cursor + render primitive used by concrete pickers │ │ └── searchable_list.rs # Generic SearchableList: substring filter + scrollable viewport for searchable pickers │ ├── pending_calls.rs # Tool-call correlation state for streaming and transcript resume -│ ├── selection.rs # Mouse-drag text selection, line-rect materialization, OSC 52 clipboard write │ ├── snapshots/ # `cargo insta` baselines for full App frame render tests -│ ├── terminal.rs # Terminal init / restore, synchronized output, panic hook +│ ├── terminal.rs # Terminal init / restore, alternate-scroll (DECSET 1007), synchronized output, panic hook │ ├── theme.rs # Theme palette (Slot{fg,bg,modifiers} per role) + style helpers + LazyLock-cached Mocha default │ ├── theme/ │ │ ├── builtin.rs # Built-in TOML catalogue (Mocha / Macchiato / Frappe / Latte / Material via include_str!) + lookup diff --git a/docs/design/README.md b/docs/design/README.md index e1134c58..6d1bdaa9 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -34,10 +34,10 @@ Organized by topic. Each subdirectory mirrors the corresponding directory in [`d ## Terminal UI -| Document | Description | -| ---------------------------------------------------- | -------------------------------------------------------- | -| [Overview](tui/overview.md) | Core stack, rendering strategy, streaming architecture | -| [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and mid-turn queued prompts | -| [Mouse Interactions](tui/mouse-interactions.md) | Mouse capture, clickable affordances, copy-on-select | -| [Status Line](tui/status-line.md) | Configurable status-line segments and usage display | -| [Welcome Screen](tui/welcome.md) | Empty-state renderer, `WelcomeSnapshot`, width ladder | +| Document | Description | +| ---------------------------------------------------- | ------------------------------------------------------------ | +| [Overview](tui/overview.md) | Core stack, rendering strategy, streaming architecture | +| [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and mid-turn queued prompts | +| [Mouse Interactions](tui/mouse-interactions.md) | OSC 8 PR hyperlink, native terminal drag-select, DECSET 1007 | +| [Status Line](tui/status-line.md) | Configurable status-line segments and usage display | +| [Welcome Screen](tui/welcome.md) | Empty-state renderer, `WelcomeSnapshot`, width ladder | diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 931f1ed5..0caf6239 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -4,100 +4,70 @@ Design policy for mouse behavior in the TUI. ## Goal -Capture mouse events in service of useful app interactions. The TUI claims wheel scroll, app-owned click affordances (jump-to-bottom pill), and drag-to-select-and-copy. Native terminal selection is preserved through documented escape hatches and through copy-on-select via OSC 52, so users on any modern terminal can copy chat content with a normal drag. +Two user-visible features: -## Decision - -Keep `crossterm::EnableMouseCapture` enabled in `enter_tui_mode`. The full mode bundle (`?1000`, `?1002`, `?1003`, `?1006`, `?1015`) is required for crossterm to deliver wheel, click, drag, and motion events. - -Route events from `App::handle_crossterm_event` in priority order: - -1. Left-click on the cached jump-to-bottom pill rect → `ChatView::jump_to_bottom`. -2. Left-click inside the cached chat rect → arm the `Selection` state machine. -3. Left-button drag inside the chat rect → update the selection endpoint, clamped to chat bounds. -4. Left-button up → materialize the selection, emit OSC 52 set-clipboard, clear state. -5. Wheel and any other mouse event → `ChatView::handle_event` (wheel scroll up / down). - -## Selection geometry +1. **Click `#NN` in the status bar to open the pull request in the browser**, even though no app code routes the click. +2. **Drag-select chat content with the mouse and paste it elsewhere**, in any terminal the user runs `ox` in (iTerm2, WezTerm, kitty, Alacritty, Terminal.app, GNOME Terminal, Konsole, Ghostty, Windows Terminal, VS Code's integrated terminal, Cursor's integrated terminal, ...). -Terminal-convention line-rectangle: from `(start_row, start_col)` the selection extends to the end of that row, all of every intermediate row, and from column 0 to `(end_row, end_col)` on the final row. +The cleanest way to deliver both is to let the terminal do the work. The TUI does not enable mouse capture, so the terminal's own selection layer is intact. The status-bar PR segment is wrapped in an OSC 8 hyperlink envelope that every modern terminal already knows how to make Ctrl-clickable. -Wide CJK chars and emoji (`UnicodeWidthChar::width == 2`) are taken whole when their leading half lands inside the selection range, so multi-byte sequences never split. The slicing walks `Text<'static>` spans tracking `unicode-width` columns rather than byte indices. - -Block / column selection (Alt+drag in some terminals) is deferred. So is double-click word and triple-click line. +## Decision -## Visual feedback +`enter_tui_mode` enables raw mode, the alternate screen, Kitty keyboard disambiguation, and DECSET 1007 (alternate-scroll). It does **not** enable `EnableMouseCapture`. Pairs unwind on `leave_tui_mode`. -A `selection` theme slot was added. During render, after `ChatView::render` paints the chat into the frame buffer, `Selection::paint` walks the cached selection rect and applies `theme.selection.style()` to each cell. Per-built-in defaults pick a bg-only color one tier above `surface` so the highlight is legible against the chat background without colliding with diff or accent fills. +`App::handle_mouse_event` only routes a left-click on the cached jump-to-bottom pill rect. Every other mouse event flows to `ChatView::handle_event` for wheel scroll. Wheel events arrive as keyboard arrow-key sequences via DECSET 1007 in real sessions, so the path that exercises `MouseEventKind::ScrollUp` / `ScrollDown` is mostly test-side. Both routes are kept for portability. -## OSC 52 emission +## DECSET 1007 (alternate-scroll) -On left-button up, the app: +`\x1b[?1007h` on enter, `\x1b[?1007l` on leave. While the alt-screen is active the terminal translates physical wheel events into `\x1b[A` / `\x1b[B` arrow-key sequences, so `ChatView::handle_event` sees `KeyCode::Up` / `KeyCode::Down` and scrolls. The user's terminal handles the wheel without `EnableMouseCapture`, so native drag-select stays available. -1. Reads `ChatView::rendered_text(width)` to materialize the same wrapped lines that were on screen. -2. Calls `Selection::materialize(text, area, scroll_offset)` to extract the substring (line-rect, unicode-width-aware). -3. Builds `\x1b]52;c;\x07` over the raw UTF-8 bytes via `osc52_set_clipboard`. -4. Writes the bytes to stdout, then flushes so the sequence reaches the terminal before the next frame. -5. Pushes a system-message warning when the payload was clamped. +Modern emulators (iTerm2, WezTerm, kitty, Alacritty, foot, Ghostty, Windows Terminal, VS Code / Cursor's xterm.js, recent GNOME Terminal, Konsole, Terminal.app via vim-mode) implement 1007. Older emulators ignore it without falling back to anything; for those the user uses keyboard scroll (`PageUp` / `PageDown` / `Ctrl+End`). -When `TMUX` is set in the environment, the app also emits a tmux DCS pass-through copy (`\x1bPtmux;\x1b\x1b]52;c;...\x07\x1b\\`) right after the bare sequence. tmux configurations without `set -g set-clipboard on` swallow the bare OSC 52, but the DCS-wrapped variant always reaches the outer terminal because tmux strips its DCS envelope and forwards the inner bytes verbatim. Configurations that already pass OSC 52 through receive both variants. The terminal's clipboard handler is idempotent, so a duplicate write is harmless. +## OSC 8 hyperlink on the PR status segment -Payload cap is 8 KB pre-base64 (xterm's conservative limit). kitty / iTerm2 / WezTerm tolerate more but the floor keeps the same selection working everywhere. +The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph::render` paints the line into the frame buffer, `StatusBar::render` walks each `RenderedHyperlink` range and runs `mark_url_hyperlink` over it. -Truncation walks back to a UTF-8 char boundary so the encoded string is always valid UTF-8. +`mark_url_hyperlink` rewrites every non-whitespace cell in the range so that `Cell::symbol()` carries the OSC 8 envelope inline: -## OSC 8 hyperlinks +```text +\x1b]8;;\x07\x1b]8;;\x07 +``` -The `pull-request` status segment renders `#NN` as plain spans, then the parent `StatusBar` post-paints OSC 8 escape bytes onto each cell of the segment so modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal) make the number Ctrl-clickable and open it via the user's browser. Older terminals print the bytes verbatim and just show `#NN` as plain text. +Two non-obvious mechanics: -Direct embedding of escape bytes inside `Span::raw` does not survive ratatui's renderer: `Buffer::set_string` filters out any grapheme that contains a control char, so the leading and trailing `\x1b` are stripped while the printable bytes (`]8;;\\#NN]8;;\\`) leak into the output as visible text. The fix is to bypass that filter via `Cell::set_symbol`, which stores the symbol verbatim and the crossterm Backend prints it unchanged. `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line, and `StatusBar::render` runs `mark_url_hyperlink` over each range after the line is painted. Control chars in the URL are stripped to keep a malformed value from breaking out of the OSC 8 envelope. +- **`Cell::set_symbol` instead of `Span::raw`.** ratatui's `Buffer::set_string` filters out any grapheme that contains a control char, so embedding ESC bytes in a `Span` is silently stripped while the printable middle leaks through as visible text. Setting the symbol directly bypasses that filter, and crossterm's `Print(cell.symbol())` writes the bytes verbatim. +- **BEL (`\x07`) terminator instead of ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. -## Selection escape hatches +Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally; the visible `#NN` still reads correctly because BEL is non-printable. -When the app captures mouse events, native terminal drag-select is suppressed by default. Users have several escape hatches: +URLs are sanitized — every control char is filtered out before the envelope is built — so a malformed value can't break out of the OSC 8 sequence. -- **iTerm2**: hold ⌥ (Option) and drag for native selection. Cmd-click on OSC 8 hyperlinks. -- **WezTerm**: hold Shift and drag. Ctrl-Shift-click for OSC 8 hyperlinks. -- **kitty**: hold Shift and drag. Ctrl-Shift-click for OSC 8. -- **Alacritty**: hold Shift and drag. -- **macOS Terminal.app**: hold ⌥ (Option) and drag. -- **GNOME Terminal / Konsole**: hold Shift and drag. -- **Windows Terminal**: hold Shift and drag. -- **tmux**: `Ctrl-b z` to zoom out, then enter copy-mode (default `Ctrl-b [`) and use copy-mode bindings. +## Native drag-select-and-copy -Copy-on-select via OSC 52 makes this less necessary: a normal drag inside the chat now copies. The escape hatches are still useful for selecting status-bar / input / preview content (currently outside the selection scope). +Without `EnableMouseCapture`, the terminal sees every mouse event itself. Drag-select uses the user's existing terminal selection model: which keys to hold, what the highlight looks like, what gets copied, and how it gets onto the clipboard are all the user's choice (or the user's terminal's defaults). -## Required terminal config for OSC 52 +This means we don't need: -OSC 52 needs explicit opt-in on some terminals: +- A `Selection` state machine in the app. +- An app-side highlight overlay. +- An OSC 52 encoder. +- A `selection` theme slot. +- Per-terminal escape hatches (Option+drag, Shift+drag, ...) — the terminal's normal drag is the primary path, not an escape hatch. +- `set -g set-clipboard on` in tmux (the user's tmux selection model is whatever the user already configured). -- **xterm**: `XTerm*allowWindowOps: true` in `~/.Xresources`. -- **kitty**: `clipboard_control write-clipboard write-primary` in `kitty.conf` (default since 0.21). -- **tmux**: `set -g set-clipboard on` (already on by default since 3.2). -- **iTerm2**, **WezTerm**, **Alacritty**, **foot**, **Ghostty**: enabled by default. +## Implementation files -When OSC 52 is rejected by the terminal, the user gets no clipboard write and no error. The escape hatches above remain available. +- `crates/oxide-code/src/tui/terminal.rs` — `enter_tui_mode` / `leave_tui_mode` write the alt-screen + alternate-scroll + Kitty keyboard sequences. +- `crates/oxide-code/src/tui/app.rs` — `handle_mouse_event` routes the jump-pill click and forwards everything else to chat. +- `crates/oxide-code/src/tui/components/status.rs` — `StatusBar::render` paints the line, then walks `RenderedStatusLine::hyperlinks` and applies `mark_url_hyperlink` over each range. +- `crates/oxide-code/src/tui/components/status/line.rs` — `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line; `mark_url_hyperlink` rewrites each cell's symbol with the OSC 8 envelope. +- `crates/oxide-code/src/util/git.rs` — `current_pull_request` returns `Option` parsed from `gh pr view --json number,url`, so the status bar has the URL ready when the PR refresh fires. -## Implementation files +## Out of scope -- `crates/oxide-code/src/tui/terminal.rs` — `EnableMouseCapture` in `enter_tui_mode`. -- `crates/oxide-code/src/tui/selection.rs` — `Selection` state, materialization, OSC 52 encoder. -- `crates/oxide-code/src/tui/app.rs` — event routing (`handle_mouse_event`, `copy_selection_to_clipboard`). -- `crates/oxide-code/src/tui/components/chat.rs` — wheel scroll arms (`MouseEventKind::ScrollUp`/`ScrollDown`), `rendered_text` accessor. -- `crates/oxide-code/src/tui/components/status/line.rs` — OSC 8 hyperlink wrapper for the `pull-request` segment. -- `crates/oxide-code/src/tui/theme.rs` — `selection` slot. - -## Out of scope (deferred follow-ups) - -- Click-to-expand on tool-result blocks (requires per-block click rect tracking). -- OSC 8 hyperlinks inside markdown body text (requires threading URLs through the markdown renderer). -- `OX_DISABLE_MOUSE` opt-out (modeled after `CLAUDE_CODE_DISABLE_MOUSE`). -- Native clipboard fallback via `arboard` (when OSC 52 is rejected). -- Block / column selection (Alt+drag). -- Drag auto-scroll past the viewport edge. -- Double-click word and triple-click line. -- Modifier-aware mouse events (Shift+click extend, Alt+click block). -- Selection over status bar, input box, and preview pane. +- Click-to-expand on tool-result blocks (would require capturing mouse). +- OSC 8 hyperlinks inside markdown body text (would require threading URLs through the markdown renderer). +- App-driven copy-on-select with OSC 52 / arboard fallback (rejected: native terminal selection covers it). ## Verification @@ -106,14 +76,13 @@ Manual verification across terminals: 1. Start `ox` and generate enough chat content to scroll. 2. Page up. Confirm the jump-to-bottom pill appears. 3. Click the pill. Confirm chat snaps to bottom and re-arms auto-scroll. -4. Drag-select a chat region containing ASCII + CJK + emoji. Mouse up. Paste somewhere external. Confirm bytes round-trip exactly. -5. With a `pull-request` status segment configured, Ctrl-click (or terminal-specific modifier) on `#NN`. Confirm the browser opens to the PR URL. -6. Try wheel scrolling. Confirm chat scrolls. -7. Try the terminal-specific selection escape hatch (e.g., Option+drag in iTerm2). Confirm native selection works for the status bar / input area. -8. Quit. Confirm the terminal is restored (alt-screen exited, mouse capture released). +4. Drag-select a chat region. Confirm the highlight uses the terminal's native selection style. Mouse up. Paste somewhere external. Confirm bytes round-trip. +5. With a `pull-request` status segment configured, Ctrl-click (Cmd-click on iTerm2 / Terminal.app) on `#NN`. Confirm the browser opens to the PR URL. +6. Wheel scroll. Confirm chat scrolls (DECSET 1007 in a supporting terminal). +7. Quit. Confirm alt-screen restored. Automated tests: -- `tui::selection::tests` — selection state, line-rect materialization, CJK round-trip, OSC 52 encoder, char-boundary clamping (14 tests). -- `tui::app::tests::left_click_on_jump_overlay_jumps_chat_to_bottom`, `drag_in_chat_area_arms_selection_state_machine`, `left_click_outside_chat_area_does_not_arm_selection`, `rect_contains_left_top_inclusive_right_bottom_exclusive`. -- `tui::components::status::line::tests::pull_request_segment_emits_osc8_open_and_close_around_visible_text`, `pull_request_segment_skips_osc8_when_absent`. +- `tui::terminal::tests::enter_tui_mode_writes_setup_sequences`, `enter_tui_mode_does_not_enable_mouse_capture`, `leave_tui_mode_writes_restore_sequences` — pin the DECSET 1007 enable / disable and the absence of `EnableMouseCapture`. +- `tui::app::tests::left_click_on_jump_overlay_jumps_chat_to_bottom`, `left_click_outside_jump_overlay_does_not_jump_chat`, `wheel_scroll_event_routes_to_chat_view` — pin the mouse-routing surface. +- `tui::components::status::line::tests::mark_url_hyperlink_wraps_each_non_blank_cell_with_osc8`, `mark_url_hyperlink_strips_control_chars_from_url`, `mark_url_hyperlink_with_empty_url_is_noop`, `pull_request_segment_reports_hyperlink_range_for_post_render_marking`, `pull_request_segment_reports_no_hyperlink_when_absent` — pin the OSC 8 envelope shape. diff --git a/docs/research/README.md b/docs/research/README.md index 0c6199b3..bedece31 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -42,10 +42,10 @@ Organized by topic. Each subdirectory mirrors the corresponding directory in [`d ## Terminal UI -| Document | Description | -| ---------------------------------------------------- | ---------------------------------------------------------------- | -| [Overview](tui/overview.md) | Reference TUI patterns, flickering prevention, ecosystem | -| [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and input queueing patterns | -| [Mouse Interactions](tui/mouse-interactions.md) | Mouse capture, click handling, OSC 8 / OSC 52 across coding CLIs | -| [Status Line](tui/status-line.md) | Segment ordering, usage, and billing patterns across coding CLIs | -| [Welcome Screen](tui/welcome.md) | Empty-state surfaces and layout primitives across the three CLIs | +| Document | Description | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| [Overview](tui/overview.md) | Reference TUI patterns, flickering prevention, ecosystem | +| [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and input queueing patterns | +| [Mouse Interactions](tui/mouse-interactions.md) | Mouse capture, click handling, OSC 8 / OSC 52 across coding CLIs, xterm.js parser quirks | +| [Status Line](tui/status-line.md) | Segment ordering, usage, and billing patterns across coding CLIs | +| [Welcome Screen](tui/welcome.md) | Empty-state surfaces and layout primitives across the three CLIs | diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index f841f655..7ff0980b 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -55,11 +55,11 @@ Failure modes: rejected payloads are silently dropped. The app cannot detect sup ## OSC 8 protocol -`\x1b]8;params;URI\x1b\\\x1b]8;;\x1b\\` where `params` is `key=value:key=value` (often empty) and `URI` is the link target. ST is `\x1b\\` (or `\x07` on legacy terminals). +`\x1b]8;params;URI\x1b]8;;` where `params` is `key=value:key=value` (often empty), `URI` is the link target, and `` is either `\x1b\\` (ST, two bytes) or `\x07` (BEL, one byte). xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers and leak visible bytes into adjacent cells, so the BEL form is the universally-safe choice. -Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VTE-based terminals. Legacy terminals print the escape bytes literally. The `` part is what users see, so the fallback is graceful as long as the visible text alone is meaningful (e.g., `#NN` works, while an empty link doesn't). +Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VTE-based terminals, VS Code's terminal, Cursor's terminal. Legacy terminals print the escape bytes literally. The `` part is what users see, so the fallback is graceful as long as the visible text alone is meaningful (e.g., `#NN` works, while an empty link doesn't). -`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;...\\` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. +`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;...\x07` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. ratatui's `Buffer::set_string` filters out any grapheme that contains a control char, so embedding ESC in a `Span` strips the leading and trailing escape and leaks the printable middle. The fix is to bypass that filter via `Cell::set_symbol`, which stores the symbol verbatim. crossterm's `Print(cell.symbol())` writes the bytes unchanged. ## Mouse capture mode bundle @@ -75,10 +75,17 @@ Some terminals skip `?1003` for performance. SGR (`?1006`) is the only encoding ## User-environment signal -The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from the inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events. When oxide-code captures mouse, that gesture goes to oxide-code and tmux doesn't see it. Users who want tmux's wheel-to-copy-mode can `Ctrl-b z` to zoom out, or escape via `Ctrl-b [`. +The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from an inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events. An app that captures mouse intercepts those gestures. The author also runs `ox` inside VS Code's and Cursor's integrated terminals, which are xterm.js-based; those have looser OSC parsing than xterm proper, so any per-cell escape sequence has to use the safest forms (BEL terminator, no relying on OSC 52 reaching the OS clipboard). ## Takeaway for oxide-code -Capture mouse and build a small set of well-scoped affordances: jump-to-bottom click, OSC 8 PR hyperlink, drag-select-and-copy via OSC 52. Document the per-terminal selection-modifier escape hatches for content outside the chat (status bar, input box). Defer block selection, drag auto-scroll, double / triple-click word / line, click-to-expand, and an opt-out env var until usage validates the demand. +Two features are worth supporting: clicking the PR number to open it in the browser, and drag-selecting chat content to copy. The `EnableMouseCapture` + OSC 52 approach trades native drag-select for an app-side reimplementation that fights the terminal in every layer above (tmux behavior, OSC 52 acceptance, in-app highlight, payload caps). The author's terminals (Cursor's and VS Code's xterm.js, plus tmux without `set-clipboard on`) make that trade unfavorable. -Claude Code's hit-test framework is the right model long-term but overengineered for a first pass. opencode's per-element click handlers via opentui aren't reachable from ratatui without a similar framework. Codex's "no capture, alternate-scroll" approach trades affordances for native selection. Workable, but limits future click features. +The shipped design defers selection entirely to the terminal: + +- **No mouse capture.** `enter_tui_mode` skips `EnableMouseCapture` so the terminal sees every drag itself. The terminal's native selection layer renders the highlight, decides what gets copied, and writes to the OS clipboard the way the user already expects. +- **DECSET 1007 (alternate-scroll).** Wheel events arrive as arrow-key sequences in the alt-screen, so chat still scrolls without claiming the mouse. iTerm2, WezTerm, kitty, Alacritty, foot, Ghostty, Windows Terminal, recent GNOME Terminal, Konsole, and xterm.js-based terminals (VS Code, Cursor) implement 1007. Older terminals fall back to keyboard scroll. +- **OSC 8 on the PR segment, painted post-render via `Cell::set_symbol`.** ratatui's `Buffer::set_string` filters control chars out of `Span::raw`, so the envelope has to be written at the cell level after the line is painted. BEL (`\x07`) terminator, not ST (`\x1b\\`), because xterm.js misparses the self-contained per-cell ST closers and leaks visible bytes into adjacent cells. +- **No app-side selection.** The `Selection` state machine, OSC 52 encoder, tmux DCS pass-through, `selection` theme slot, and `arboard` fallback all become unnecessary. + +The remaining trade-off is the same one Codex makes: app-side click affordances beyond the jump-pill are out of reach, because there are no mouse events to route. That's the right trade until concrete demand for click-to-expand or similar arrives. From e21f4d078feef48812929f7187bb275e29430303 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 16:11:00 +0800 Subject: [PATCH 20/35] fix(tui): emit PR-segment OSC 8 hyperlink out-of-band after flush 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. --- .cspell/words.txt | 1 + crates/oxide-code/src/tui/app.rs | 205 ++++++++++++++++++ .../oxide-code/src/tui/components/status.rs | 146 ++++++++++++- .../src/tui/components/status/line.rs | 86 -------- docs/design/tui/mouse-interactions.md | 19 +- docs/research/tui/mouse-interactions.md | 11 +- 6 files changed, 366 insertions(+), 102 deletions(-) diff --git a/.cspell/words.txt b/.cspell/words.txt index f48e9fe8..26412cd5 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -70,6 +70,7 @@ println pulldown RAII ratatui +rects replacen reqwest reserialized diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index dfde1a9f..c4dd2da3 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -10,6 +10,11 @@ use std::time::{Duration, Instant}; use anyhow::Result; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEventKind}; +use crossterm::queue; +use crossterm::style::{ + Attribute as CtAttribute, Color as CtColor, Print, SetAttribute, SetBackgroundColor, + SetForegroundColor, +}; use futures::{Stream, StreamExt}; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::text::{Line, Span}; @@ -676,14 +681,34 @@ impl App { draw_sync(terminal, |frame| { chat_area = self.draw_frame(frame); })?; + self.emit_status_hyperlinks(terminal)?; if self.chat.update_layout(chat_area) { draw_sync(terminal, |frame| { self.draw_frame(frame); })?; + self.emit_status_hyperlinks(terminal)?; } Ok(()) } + /// Re-emits any hyperlink ranges captured by the status bar wrapped in OSC 8 envelopes. + /// Runs out-of-band after `terminal.draw()` has flushed because storing the envelope inside + /// cell symbols would inflate `unicode-width` (URLs are plain ASCII), and ratatui's diff + /// reads that width to compute `to_skip` for trailing cells. With a 30-byte URL the diff + /// would skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into + /// the gap. Replaying the visible bytes after the flush bypasses the diff entirely. + fn emit_status_hyperlinks( + &mut self, + terminal: &mut ratatui::Terminal>, + ) -> Result<()> { + let links = self.status_bar.take_pending_hyperlinks(); + if links.is_empty() { + return Ok(()); + } + write_status_hyperlinks(terminal.backend_mut(), &links)?; + Ok(()) + } + /// Returns the chat area so the scroll cache can refresh its layout. fn draw_frame(&mut self, frame: &mut ratatui::Frame<'_>) -> ratatui::layout::Rect { let preview_height = self.preview_height(); @@ -806,6 +831,97 @@ impl App { } } +/// Writes each link's OSC 8 envelope (with replayed visible chars) to `out`. Pure I/O — accepts +/// any `Write` so tests can capture the bytes via a `Vec` and inspect them. Strips control +/// chars from each URL so a malformed value can't break out of the envelope, and uses BEL (`\x07`) +/// as the OSC terminator because xterm.js misparses self-contained per-cell ST closers. +pub(super) fn write_status_hyperlinks( + out: &mut W, + links: &[super::components::status::StatusHyperlink], +) -> std::io::Result<()> { + for link in links { + let safe_url: String = link.url.chars().filter(|c| !c.is_control()).collect(); + if safe_url.is_empty() { + continue; + } + // CUP uses 1-based coordinates. + let row = link.rect.y.saturating_add(1); + let col = link.rect.x.saturating_add(1); + write!(out, "\x1b[{row};{col}H")?; + write!(out, "\x1b]8;;{safe_url}\x07")?; + for cell in &link.cells { + write_styled_symbol(out, cell)?; + } + write!(out, "\x1b]8;;\x07")?; + // Reset SGR so subsequent terminal output isn't tinted by the last cell's style. + write!(out, "\x1b[0m")?; + } + out.flush()?; + Ok(()) +} + +/// Writes a single buffer cell — symbol + foreground / background / SGR attributes — directly to +/// `out`. Used by `App::emit_status_hyperlinks` to replay link cells inside an OSC 8 envelope +/// after `terminal.draw()` has flushed. +fn write_styled_symbol( + out: &mut W, + cell: &super::components::status::HyperlinkCell, +) -> std::io::Result<()> { + if let Some(fg) = cell.style.fg { + queue!(out, SetForegroundColor(ratatui_color_to_crossterm(fg)))?; + } else { + queue!(out, SetForegroundColor(CtColor::Reset))?; + } + if let Some(bg) = cell.style.bg { + queue!(out, SetBackgroundColor(ratatui_color_to_crossterm(bg)))?; + } else { + queue!(out, SetBackgroundColor(CtColor::Reset))?; + } + let modifier = cell.style.add_modifier; + if modifier.contains(ratatui::style::Modifier::BOLD) { + queue!(out, SetAttribute(CtAttribute::Bold))?; + } + if modifier.contains(ratatui::style::Modifier::DIM) { + queue!(out, SetAttribute(CtAttribute::Dim))?; + } + if modifier.contains(ratatui::style::Modifier::ITALIC) { + queue!(out, SetAttribute(CtAttribute::Italic))?; + } + if modifier.contains(ratatui::style::Modifier::UNDERLINED) { + queue!(out, SetAttribute(CtAttribute::Underlined))?; + } + if modifier.contains(ratatui::style::Modifier::REVERSED) { + queue!(out, SetAttribute(CtAttribute::Reverse))?; + } + queue!(out, Print(&cell.symbol))?; + Ok(()) +} + +fn ratatui_color_to_crossterm(c: ratatui::style::Color) -> CtColor { + use ratatui::style::Color as RC; + match c { + RC::Reset => CtColor::Reset, + RC::Black => CtColor::Black, + RC::Red => CtColor::DarkRed, + RC::Green => CtColor::DarkGreen, + RC::Yellow => CtColor::DarkYellow, + RC::Blue => CtColor::DarkBlue, + RC::Magenta => CtColor::DarkMagenta, + RC::Cyan => CtColor::DarkCyan, + RC::Gray => CtColor::Grey, + RC::DarkGray => CtColor::DarkGrey, + RC::LightRed => CtColor::Red, + RC::LightGreen => CtColor::Green, + RC::LightYellow => CtColor::Yellow, + RC::LightBlue => CtColor::Blue, + RC::LightMagenta => CtColor::Magenta, + RC::LightCyan => CtColor::Cyan, + RC::White => CtColor::White, + RC::Indexed(i) => CtColor::AnsiValue(i), + RC::Rgb(r, g, b) => CtColor::Rgb { r, g, b }, + } +} + fn jump_overlay_label(new_count: u32, width: usize) -> String { if width < 40 { return "↓ (ctrl+End)".to_owned(); @@ -3363,6 +3479,95 @@ mod tests { } } + // ── write_status_hyperlinks ── + + #[test] + fn write_status_hyperlinks_emits_cup_then_envelope_per_link() { + // Pins the on-the-wire byte sequence: CUP (1-based) → OSC 8 opener → replayed cells → + // OSC 8 closer (BEL terminator) → SGR reset. Drift here breaks the PR-click affordance + // in xterm.js-based terminals (Cursor / VS Code) where the parser is least forgiving. + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; + + let link = StatusHyperlink { + rect: ratatui::layout::Rect::new(2, 0, 3, 1), + url: "https://example.com/pull/86".to_owned(), + cells: vec![ + HyperlinkCell { + symbol: "#".to_owned(), + style: ratatui::style::Style::default(), + }, + HyperlinkCell { + symbol: "8".to_owned(), + style: ratatui::style::Style::default(), + }, + HyperlinkCell { + symbol: "6".to_owned(), + style: ratatui::style::Style::default(), + }, + ], + }; + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!( + output.contains("\x1b[1;3H"), + "CUP to row 1 col 3 (1-based): {output:?}" + ); + assert!( + output.contains("\x1b]8;;https://example.com/pull/86\x07"), + "OSC 8 opener with URL: {output:?}", + ); + let osc8_open_at = output.find("\x1b]8;;https").unwrap(); + let osc8_close_at = output.rfind("\x1b]8;;\x07").unwrap(); + let between = &output[osc8_open_at..osc8_close_at]; + assert!( + between.contains('#') && between.contains('8') && between.contains('6'), + "visible bytes #, 8, 6 sit between opener and closer: {between:?}", + ); + assert!( + output.ends_with("\x1b]8;;\x07\x1b[0m"), + "closer + SGR reset: {output:?}" + ); + } + + #[test] + fn write_status_hyperlinks_strips_control_chars_from_url() { + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; + + let link = StatusHyperlink { + rect: ratatui::layout::Rect::new(0, 0, 1, 1), + url: "https://x.com/\x1b\x07\x00ok".to_owned(), + cells: vec![HyperlinkCell { + symbol: "x".to_owned(), + style: ratatui::style::Style::default(), + }], + }; + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!( + output.contains("\x1b]8;;https://x.com/ok\x07"), + "ESC, BEL, NUL stripped from URL: {output:?}", + ); + } + + #[test] + fn write_status_hyperlinks_skips_link_with_empty_safe_url() { + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; + + let link = StatusHyperlink { + rect: ratatui::layout::Rect::new(0, 0, 1, 1), + url: "\x1b\x07".to_owned(), + cells: vec![HyperlinkCell { + symbol: "x".to_owned(), + style: ratatui::style::Style::default(), + }], + }; + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + assert!(buf.is_empty(), "no bytes when sanitized URL is empty"); + } + // ── jump_overlay_label ── #[test] diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 555b1ad2..7c732b7c 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -16,7 +16,7 @@ use crate::tui::glyphs::SPINNER_FRAMES; use crate::tui::theme::Theme; use crate::util::git; -use self::line::{RenderedStatusLine, StatusLine, StatusLineState, mark_url_hyperlink}; +use self::line::{RenderedStatusLine, StatusLine, StatusLineState}; const TICKS_PER_FRAME: usize = 5; @@ -28,6 +28,23 @@ const GIT_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(5); /// probe because `gh pr view` hits the network. const PR_REFRESH_INTERVAL: Duration = Duration::from_mins(1); +/// A hyperlink the status bar wants the App to wrap in OSC 8 after `terminal.draw()` flushes. +/// `rect` is in absolute screen coordinates (relative to the area passed to `render`). `cells` +/// holds a snapshot of the visible chars and their styles taken at render time so the post-draw +/// emission can replay them without having to re-read the buffer. +#[derive(Debug, Clone)] +pub(crate) struct StatusHyperlink { + pub(crate) rect: Rect, + pub(crate) url: String, + pub(crate) cells: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct HyperlinkCell { + pub(crate) symbol: String, + pub(crate) style: ratatui::style::Style, +} + /// Status bar at the top of the TUI. pub(crate) struct StatusBar { theme: Theme, @@ -50,6 +67,9 @@ pub(crate) struct StatusBar { status: Status, spinner_frame: usize, tick_counter: usize, + /// Absolute screen rects of hyperlink segments from the most recent `render` call. Drained by + /// the App after `terminal.draw()` flushes, then re-emitted as out-of-band OSC 8 envelopes. + pending_hyperlinks: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -94,6 +114,7 @@ impl StatusBar { status: Status::Idle, spinner_frame: 0, tick_counter: 0, + pending_hyperlinks: Vec::new(), } } @@ -228,7 +249,7 @@ fn should_probe(last: Option, now: Instant, interval: Duration) -> bool } impl StatusBar { - pub(crate) fn render(&self, frame: &mut Frame, area: Rect) { + pub(crate) fn render(&mut self, frame: &mut Frame, area: Rect) { let block = Block::default() .borders(Borders::BOTTOM) .border_style(self.theme.border_unfocused()) @@ -237,7 +258,13 @@ impl StatusBar { // Inside the block the content row is `area.y` (no top border) starting at `area.x`. let content_y = area.y; let content_x = area.x; - frame.render_widget(Paragraph::new(rendered.line).block(block), area); + // Capture link rects for the App to wrap in OSC 8 *after* `terminal.draw()` flushes. + // Storing the envelope inside cell symbols would inflate `unicode-width` (the URL is + // plain ASCII), and ratatui's diff uses that width to compute `to_skip` for trailing + // cells. With a 30-byte URL the diff would skip ~30 cells worth of trailing text and + // shift unrelated chars into the gap (e.g., `#86` rendering as `#pus`). + self.pending_hyperlinks.clear(); + let mut hyperlink_rects = Vec::with_capacity(rendered.hyperlinks.len()); for link in &rendered.hyperlinks { let link_x = content_x.saturating_add(link.col); let max_width = area.x.saturating_add(area.width).saturating_sub(link_x); @@ -245,9 +272,31 @@ impl StatusBar { if width == 0 { continue; } - let link_rect = Rect::new(link_x, content_y, width, 1); - mark_url_hyperlink(frame.buffer_mut(), link_rect, &link.url); + hyperlink_rects.push((Rect::new(link_x, content_y, width, 1), link.url.clone())); } + frame.render_widget(Paragraph::new(rendered.line).block(block), area); + // After the Paragraph paints into the buffer, snapshot the cells under each link rect so + // the App can replay them with an OSC 8 envelope wrapped around them post-flush. + let buf = frame.buffer_mut(); + for (rect, url) in hyperlink_rects { + let mut cells = Vec::with_capacity(usize::from(rect.width)); + for x in rect.x..rect.x.saturating_add(rect.width) { + if let Some(cell) = buf.cell((x, rect.y)) { + cells.push(HyperlinkCell { + symbol: cell.symbol().to_owned(), + style: cell.style(), + }); + } + } + self.pending_hyperlinks + .push(StatusHyperlink { rect, url, cells }); + } + } + + /// Drain hyperlink rects captured during the most recent `render` so the App can re-emit them + /// out-of-band as OSC 8 sequences after `terminal.draw()` has flushed. + pub(crate) fn take_pending_hyperlinks(&mut self) -> Vec { + std::mem::take(&mut self.pending_hyperlinks) } } @@ -563,6 +612,23 @@ mod tests { assert!(bar.last_pr_probe.is_none(), "must skip without cwd"); } + #[test] + fn refresh_pull_request_marks_dirty_when_value_changes() { + // Mirrors `tick_marks_dirty_when_git_branch_changes` for the PR slot. The fail mode is + // the same: a refactor that only flips dirty when the probe yields `Some(_)` would leave + // the rendered `#NN` segment stale after the user closes the PR. + let dir = tempfile::tempdir().unwrap(); + let mut bar = test_bar(); + bar.track_pull_request = true; + bar.git_cwd = Some(dir.path().to_path_buf()); + bar.pull_request = Some(git::PullRequest { + number: 1, + url: "https://example.com/pull/1".to_owned(), + }); + assert!(bar.refresh_pull_request(Instant::now())); + assert_eq!(bar.pull_request, None); + } + #[test] fn refresh_pull_request_arms_throttle_when_probe_returns_none() { // Same throttle invariant as the git branch probe. A non-repo cwd (or a cwd where `gh` @@ -608,6 +674,76 @@ mod tests { )); } + // ── take_pending_hyperlinks ── + + #[test] + fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { + // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes; the bar's + // job is to record the link rect + visible cells so the App can replay them. Storing the + // envelope inside cell symbols would inflate `unicode-width` to ~30 (URL is plain ASCII) + // and ratatui's diff would skip ~30 trailing cells, producing the `#86 → #pus` bug. + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], + "Opus 4.7".into(), + None, + String::new(), + None, + None, + ); + bar.pull_request = Some(git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }); + let _backend = render_status(&mut bar, 40); + let links = bar.take_pending_hyperlinks(); + assert_eq!( + links.len(), + 1, + "PR segment reports exactly one link: {links:?}" + ); + let link = &links[0]; + assert_eq!(link.url, "https://github.com/o/r/pull/86"); + // Leading " " margin → `#86` lives at col 2, width 3. + assert_eq!(link.rect.x, 2); + assert_eq!(link.rect.width, 3); + let visible: String = link.cells.iter().map(|c| c.symbol.as_str()).collect(); + assert_eq!(visible, "#86"); + // Drained on take. + assert!( + bar.take_pending_hyperlinks().is_empty(), + "drain leaves nothing behind" + ); + } + + #[test] + fn render_records_no_hyperlinks_when_pull_request_absent() { + let mut bar = test_bar(); + let _backend = render_status(&mut bar, 40); + assert!(bar.take_pending_hyperlinks().is_empty()); + } + + #[test] + fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { + // Narrow renders drop the PR segment via the fit pass; the recorded hyperlinks list + // must mirror the rendered line and not surface a link rect that points at empty cells. + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], + "Opus 4.7".into(), + None, + String::new(), + None, + None, + ); + bar.pull_request = Some(git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }); + let _backend = render_status(&mut bar, 10); + assert!(bar.take_pending_hyperlinks().is_empty()); + } + // ── render ── fn render_status(bar: &mut StatusBar, width: u16) -> TestBackend { diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index cb11000c..0f81738e 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -1,5 +1,3 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use time::OffsetDateTime; use unicode_width::UnicodeWidthStr; @@ -251,35 +249,6 @@ fn non_empty_span(label: String, style: ratatui::style::Style) -> Option) -> String { match effort { Some(effort) => format!("{model} ({effort})"), @@ -520,61 +489,6 @@ mod tests { assert!(!text.contains('#'), "no PR number rendered when absent"); } - // ── mark_url_hyperlink ── - - #[test] - fn mark_url_hyperlink_wraps_each_non_blank_cell_with_osc8() { - let area = Rect::new(0, 0, 5, 1); - let mut buf = Buffer::empty(area); - // Pre-paint "ab cd" in the buffer (col 2 is whitespace). - buf[(0, 0)].set_symbol("a"); - buf[(1, 0)].set_symbol("b"); - buf[(2, 0)].set_symbol(" "); - buf[(3, 0)].set_symbol("c"); - buf[(4, 0)].set_symbol("d"); - - mark_url_hyperlink(&mut buf, area, "https://example.com"); - - assert_eq!( - buf[(0, 0)].symbol(), - "\x1b]8;;https://example.com\x07a\x1b]8;;\x07", - ); - assert_eq!(buf[(2, 0)].symbol(), " ", "whitespace cells stay untouched"); - assert_eq!( - buf[(4, 0)].symbol(), - "\x1b]8;;https://example.com\x07d\x1b]8;;\x07", - ); - } - - #[test] - fn mark_url_hyperlink_strips_control_chars_from_url() { - let area = Rect::new(0, 0, 1, 1); - let mut buf = Buffer::empty(area); - buf[(0, 0)].set_symbol("x"); - - mark_url_hyperlink(&mut buf, area, "https://example.com/\x1b\x07\x00ok"); - - assert_eq!( - buf[(0, 0)].symbol(), - "\x1b]8;;https://example.com/ok\x07x\x1b]8;;\x07", - ); - } - - #[test] - fn mark_url_hyperlink_with_empty_url_is_noop() { - let area = Rect::new(0, 0, 1, 1); - let mut buf = Buffer::empty(area); - buf[(0, 0)].set_symbol("x"); - - mark_url_hyperlink(&mut buf, area, "\x1b\x07"); - - assert_eq!( - buf[(0, 0)].symbol(), - "x", - "no wrap when sanitized URL is empty" - ); - } - // ── context_label ── #[test] diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 0caf6239..627f643a 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -25,17 +25,17 @@ Modern emulators (iTerm2, WezTerm, kitty, Alacritty, foot, Ghostty, Windows Term ## OSC 8 hyperlink on the PR status segment -The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph::render` paints the line into the frame buffer, `StatusBar::render` walks each `RenderedHyperlink` range and runs `mark_url_hyperlink` over it. - -`mark_url_hyperlink` rewrites every non-whitespace cell in the range so that `Cell::symbol()` carries the OSC 8 envelope inline: +The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph::render` paints the line into the frame buffer, `StatusBar::render` records each `RenderedHyperlink` range — its absolute screen rect, URL, and a snapshot of the visible chars + styles in the link cells — into a `pending_hyperlinks` queue. `App::render` drains that queue after `terminal.draw()` flushes and replays each link as an OSC 8 envelope written directly to the crossterm backend. ```text -\x1b]8;;\x07\x1b]8;;\x07 +\x1b[;H\x1b]8;;\x07\x1b]8;;\x07\x1b[0m ``` +The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap (the original `#86 → #pus` bug). Replaying the envelope after the flush bypasses the diff entirely; the just-painted cells stay plain, and the next frame's diff sees them as plain symbols of width 1. + Two non-obvious mechanics: -- **`Cell::set_symbol` instead of `Span::raw`.** ratatui's `Buffer::set_string` filters out any grapheme that contains a control char, so embedding ESC bytes in a `Span` is silently stripped while the printable middle leaks through as visible text. Setting the symbol directly bypasses that filter, and crossterm's `Print(cell.symbol())` writes the bytes verbatim. +- **Out-of-band emission, not `Cell::set_symbol`.** Storing the envelope in the cell symbol breaks ratatui's diff math (`unicode-width` over-counts the URL bytes). After `terminal.draw()` returns, `App::emit_status_hyperlinks` walks the captured link list, positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. - **BEL (`\x07`) terminator instead of ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally; the visible `#NN` still reads correctly because BEL is non-printable. @@ -58,9 +58,9 @@ This means we don't need: ## Implementation files - `crates/oxide-code/src/tui/terminal.rs` — `enter_tui_mode` / `leave_tui_mode` write the alt-screen + alternate-scroll + Kitty keyboard sequences. -- `crates/oxide-code/src/tui/app.rs` — `handle_mouse_event` routes the jump-pill click and forwards everything else to chat. -- `crates/oxide-code/src/tui/components/status.rs` — `StatusBar::render` paints the line, then walks `RenderedStatusLine::hyperlinks` and applies `mark_url_hyperlink` over each range. -- `crates/oxide-code/src/tui/components/status/line.rs` — `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line; `mark_url_hyperlink` rewrites each cell's symbol with the OSC 8 envelope. +- `crates/oxide-code/src/tui/app.rs` — `handle_mouse_event` routes the jump-pill click and forwards everything else to chat. `emit_status_hyperlinks` drains the bar's pending link list after `terminal.draw()` flushes; `write_status_hyperlinks` emits the CUP + OSC 8 envelope + replayed cells + SGR reset to any `Write`. +- `crates/oxide-code/src/tui/components/status.rs` — `StatusBar::render` paints the line with plain cell symbols, then snapshots each link rect's visible chars + styles into `pending_hyperlinks` for `App::emit_status_hyperlinks` to drain post-flush. +- `crates/oxide-code/src/tui/components/status/line.rs` — `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line. - `crates/oxide-code/src/util/git.rs` — `current_pull_request` returns `Option` parsed from `gh pr view --json number,url`, so the status bar has the URL ready when the PR refresh fires. ## Out of scope @@ -85,4 +85,5 @@ Automated tests: - `tui::terminal::tests::enter_tui_mode_writes_setup_sequences`, `enter_tui_mode_does_not_enable_mouse_capture`, `leave_tui_mode_writes_restore_sequences` — pin the DECSET 1007 enable / disable and the absence of `EnableMouseCapture`. - `tui::app::tests::left_click_on_jump_overlay_jumps_chat_to_bottom`, `left_click_outside_jump_overlay_does_not_jump_chat`, `wheel_scroll_event_routes_to_chat_view` — pin the mouse-routing surface. -- `tui::components::status::line::tests::mark_url_hyperlink_wraps_each_non_blank_cell_with_osc8`, `mark_url_hyperlink_strips_control_chars_from_url`, `mark_url_hyperlink_with_empty_url_is_noop`, `pull_request_segment_reports_hyperlink_range_for_post_render_marking`, `pull_request_segment_reports_no_hyperlink_when_absent` — pin the OSC 8 envelope shape. +- `tui::app::tests::write_status_hyperlinks_emits_cup_then_envelope_per_link`, `write_status_hyperlinks_strips_control_chars_from_url`, `write_status_hyperlinks_skips_link_with_empty_safe_url` — pin the on-the-wire byte sequence (CUP + OSC 8 opener + replayed cells + closer + SGR reset). +- `tui::components::status::tests::render_records_pull_request_hyperlink_rect_for_post_flush_emission`, `render_records_no_hyperlinks_when_pull_request_absent`, `render_drops_hyperlink_when_segment_clipped_to_zero_width`, `pull_request_segment_reports_hyperlink_range_for_post_render_marking`, `pull_request_segment_reports_no_hyperlink_when_absent` — pin what the bar surfaces to the App. diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index 7ff0980b..eabd5c22 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -59,7 +59,14 @@ Failure modes: rejected payloads are silently dropped. The app cannot detect sup Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VTE-based terminals, VS Code's terminal, Cursor's terminal. Legacy terminals print the escape bytes literally. The `` part is what users see, so the fallback is graceful as long as the visible text alone is meaningful (e.g., `#NN` works, while an empty link doesn't). -`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;...\x07` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. ratatui's `Buffer::set_string` filters out any grapheme that contains a control char, so embedding ESC in a `Span` strips the leading and trailing escape and leaks the printable middle. The fix is to bypass that filter via `Cell::set_symbol`, which stores the symbol verbatim. crossterm's `Print(cell.symbol())` writes the bytes unchanged. +`unicode-width` reports 0 for ESC and the printable bytes inside a `]8;;...\x07` sequence are also non-printable, so layout math sees the whole sequence as zero-width when wrapped in `Span::raw`. Truncation logic that measures `Span::content` width via `unicode_width::UnicodeWidthStr::width` is unaffected. + +Two ratatui-specific traps appear when embedding the envelope inside a frame buffer cell: + +1. **`Buffer::set_string` strips control chars.** Embedding ESC in a `Span::raw` strips the leading and trailing escape and leaks the printable middle. `Cell::set_symbol` bypasses that filter and stores the symbol verbatim; crossterm's `Print(cell.symbol())` writes the bytes unchanged. +2. **`Buffer::diff` over-counts the cell's width.** The diff reads `cell.symbol().width()` to compute `to_skip` for trailing cells (multi-width handling). A symbol like `\x1b]8;;https://github.com/o/r/pull/86\x07#\x1b]8;;\x07` is mostly plain ASCII (the URL), so `unicode-width` reports ~30. The diff then skips ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap. Codex's `mark_url_hyperlink` writes the envelope into cell symbols too, but no Codex test exercises `Terminal::flush`, so the same bug lurks there as well. + +The clean workaround: emit the OSC 8 envelope **out-of-band**, after `terminal.draw()` has flushed. Capture the link rect + visible cells at render time, then write CUP + `\x1b]8;;\x07` + replayed styled cells + `\x1b]8;;\x07` + SGR reset directly to the crossterm backend. Buffer cells stay plain (width 1) and the diff math behaves. ## Mouse capture mode bundle @@ -85,7 +92,7 @@ The shipped design defers selection entirely to the terminal: - **No mouse capture.** `enter_tui_mode` skips `EnableMouseCapture` so the terminal sees every drag itself. The terminal's native selection layer renders the highlight, decides what gets copied, and writes to the OS clipboard the way the user already expects. - **DECSET 1007 (alternate-scroll).** Wheel events arrive as arrow-key sequences in the alt-screen, so chat still scrolls without claiming the mouse. iTerm2, WezTerm, kitty, Alacritty, foot, Ghostty, Windows Terminal, recent GNOME Terminal, Konsole, and xterm.js-based terminals (VS Code, Cursor) implement 1007. Older terminals fall back to keyboard scroll. -- **OSC 8 on the PR segment, painted post-render via `Cell::set_symbol`.** ratatui's `Buffer::set_string` filters control chars out of `Span::raw`, so the envelope has to be written at the cell level after the line is painted. BEL (`\x07`) terminator, not ST (`\x1b\\`), because xterm.js misparses the self-contained per-cell ST closers and leaks visible bytes into adjacent cells. +- **OSC 8 on the PR segment, emitted out-of-band after `terminal.draw()` flushes.** Capturing the link rect + visible cells at render time and writing the envelope directly to the crossterm backend avoids both ratatui traps: `Buffer::set_string` no longer filters our control chars (we never go through it), and `Buffer::diff`'s `to_skip` math no longer reads a 30-byte URL out of a single cell symbol and drops the trailing characters. BEL (`\x07`) terminator, not ST (`\x1b\\`), because xterm.js misparses the self-contained per-cell ST closers and leaks visible bytes into adjacent cells. - **No app-side selection.** The `Selection` state machine, OSC 52 encoder, tmux DCS pass-through, `selection` theme slot, and `arboard` fallback all become unnecessary. The remaining trade-off is the same one Codex makes: app-side click affordances beyond the jump-pill are out of reach, because there are no mouse events to route. That's the right trade until concrete demand for click-to-expand or similar arrives. From 31683f1f61b7a33c1ef2a0e3759a127f066ee495 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 16:35:00 +0800 Subject: [PATCH 21/35] docs(tui): note tmux mouse-mode off-by-one in mouse-interactions research 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. --- docs/research/tui/mouse-interactions.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index eabd5c22..92f87789 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -84,6 +84,15 @@ Some terminals skip `?1003` for performance. SGR (`?1006`) is the only encoding The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from an inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events. An app that captures mouse intercepts those gestures. The author also runs `ox` inside VS Code's and Cursor's integrated terminals, which are xterm.js-based; those have looser OSC parsing than xterm proper, so any per-cell escape sequence has to use the safest forms (BEL terminator, no relying on OSC 52 reaching the OS clipboard). +### tmux mouse mode and the off-by-one click + +When the user is inside `tmux` with `set -g mouse on`, tmux is the **first** layer to interpret mouse events. Click + drag automatically enters tmux's copy-mode, and tmux's own highlight layer (independent of the host terminal's selection) is what the user sees. tmux's hit-test against the pane is row-aware and is sometimes off-by-one in xterm.js-based hosts (Cursor, VS Code's integrated terminals): a click on the row the user perceives as "row N of chat" registers as "row N+1" inside copy-mode, so the highlight starts on the line below. + +This is independent of our app — `ox` never sees the click. The user-side workarounds: + +- **Hold Shift** while clicking / dragging. Bypasses tmux's mouse handler and falls through to the host terminal's native selection (which has accurate row mapping). +- **Toggle tmux mouse mode off** with `set -g mouse off` (or via the mouse-mode toggle binding) and rely entirely on the host terminal's selection. + ## Takeaway for oxide-code Two features are worth supporting: clicking the PR number to open it in the browser, and drag-selecting chat content to copy. The `EnableMouseCapture` + OSC 52 approach trades native drag-select for an app-side reimplementation that fights the terminal in every layer above (tmux behavior, OSC 52 acceptance, in-app highlight, payload caps). The author's terminals (Cursor's and VS Code's xterm.js, plus tmux without `set-clipboard on`) make that trade unfavorable. From d55794d7ca2d7117e7fba2b0917cac41dfba068c Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 16:48:42 +0800 Subject: [PATCH 22/35] fix(tui): restore cursor after out-of-band OSC 8 emission `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. --- crates/oxide-code/src/tui/app.rs | 12 ++++++++++-- crates/oxide-code/src/tui/components/status.rs | 4 ++-- docs/design/tui/mouse-interactions.md | 10 +++++----- docs/research/tui/mouse-interactions.md | 9 --------- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index c4dd2da3..8c4bb3fc 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -225,8 +225,8 @@ impl App { /// Routes a mouse event. The TUI does not capture mouse input, so the only events it sees are /// app-side affordances delivered through DECSET 1007 (alternate-scroll, which delivers wheel - /// as arrow-key sequences). Left-click on the cached jump-pill rect snaps to bottom; every - /// other event flows to chat for wheel scroll. Native drag-select-and-copy is preserved. + /// as arrow-key sequences). Left-click on the cached jump-pill rect snaps to bottom, and + /// every other event flows to chat for wheel scroll. Native drag-select-and-copy is preserved. fn handle_mouse_event(&mut self, event: &Event) { if let Event::Mouse(mouse) = event && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) @@ -705,7 +705,15 @@ impl App { if links.is_empty() { return Ok(()); } + // Save the cursor `terminal.draw()` parked (typically at the input field). Our writes + // move it to each link rect, so without restoring it the user sees a stray cursor next + // to `#NN` in the status bar. + let saved_cursor = terminal.get_cursor_position().ok(); write_status_hyperlinks(terminal.backend_mut(), &links)?; + if let Some(pos) = saved_cursor { + terminal.set_cursor_position(pos)?; + std::io::Write::flush(terminal.backend_mut())?; + } Ok(()) } diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 7c732b7c..634994cd 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -678,7 +678,7 @@ mod tests { #[test] fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { - // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes; the bar's + // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes. The bar's // job is to record the link rect + visible cells so the App can replay them. Storing the // envelope inside cell symbols would inflate `unicode-width` to ~30 (URL is plain ASCII) // and ratatui's diff would skip ~30 trailing cells, producing the `#86 → #pus` bug. @@ -725,7 +725,7 @@ mod tests { #[test] fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { - // Narrow renders drop the PR segment via the fit pass; the recorded hyperlinks list + // Narrow renders drop the PR segment via the fit pass, so the recorded hyperlinks list // must mirror the rendered line and not surface a link rect that points at empty cells. let mut bar = StatusBar::new( &Theme::default(), diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 627f643a..074d950a 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -31,14 +31,14 @@ The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph \x1b[;H\x1b]8;;\x07\x1b]8;;\x07\x1b[0m ``` -The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap (the original `#86 → #pus` bug). Replaying the envelope after the flush bypasses the diff entirely; the just-painted cells stay plain, and the next frame's diff sees them as plain symbols of width 1. +The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap (the original `#86 → #pus` bug). Replaying the envelope after the flush bypasses the diff entirely. The just-painted cells stay plain, so the next frame's diff sees them as plain symbols of width 1. Two non-obvious mechanics: -- **Out-of-band emission, not `Cell::set_symbol`.** Storing the envelope in the cell symbol breaks ratatui's diff math (`unicode-width` over-counts the URL bytes). After `terminal.draw()` returns, `App::emit_status_hyperlinks` walks the captured link list, positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. -- **BEL (`\x07`) terminator instead of ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. +- **Out-of-band emission via the crossterm backend.** Storing the envelope in the cell symbol breaks ratatui's diff math, since `unicode-width` over-counts the URL bytes. After `terminal.draw()` returns, `App::emit_status_hyperlinks` walks the captured link list, positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. Finally, the saved pre-emission cursor position (typically the input field) is restored so the user doesn't see a stray cursor parked next to `#NN`. +- **BEL (`\x07`) terminator over ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. -Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally; the visible `#NN` still reads correctly because BEL is non-printable. +Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally. The visible `#NN` still reads correctly because BEL is non-printable. URLs are sanitized — every control char is filtered out before the envelope is built — so a malformed value can't break out of the OSC 8 sequence. @@ -58,7 +58,7 @@ This means we don't need: ## Implementation files - `crates/oxide-code/src/tui/terminal.rs` — `enter_tui_mode` / `leave_tui_mode` write the alt-screen + alternate-scroll + Kitty keyboard sequences. -- `crates/oxide-code/src/tui/app.rs` — `handle_mouse_event` routes the jump-pill click and forwards everything else to chat. `emit_status_hyperlinks` drains the bar's pending link list after `terminal.draw()` flushes; `write_status_hyperlinks` emits the CUP + OSC 8 envelope + replayed cells + SGR reset to any `Write`. +- `crates/oxide-code/src/tui/app.rs` — `handle_mouse_event` routes the jump-pill click and forwards everything else to chat. `emit_status_hyperlinks` drains the bar's pending link list after `terminal.draw()` flushes, and `write_status_hyperlinks` emits the CUP + OSC 8 envelope + replayed cells + SGR reset to any `Write`. - `crates/oxide-code/src/tui/components/status.rs` — `StatusBar::render` paints the line with plain cell symbols, then snapshots each link rect's visible chars + styles into `pending_hyperlinks` for `App::emit_status_hyperlinks` to drain post-flush. - `crates/oxide-code/src/tui/components/status/line.rs` — `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line. - `crates/oxide-code/src/util/git.rs` — `current_pull_request` returns `Option` parsed from `gh pr view --json number,url`, so the status bar has the URL ready when the PR refresh fires. diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index 92f87789..eabd5c22 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -84,15 +84,6 @@ Some terminals skip `?1003` for performance. SGR (`?1006`) is the only encoding The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` for yank. With `set -g set-clipboard on`, OSC 52 from an inner app passes through to the outer terminal. Wheel-up enters copy-mode when the pane isn't already receiving mouse events. An app that captures mouse intercepts those gestures. The author also runs `ox` inside VS Code's and Cursor's integrated terminals, which are xterm.js-based; those have looser OSC parsing than xterm proper, so any per-cell escape sequence has to use the safest forms (BEL terminator, no relying on OSC 52 reaching the OS clipboard). -### tmux mouse mode and the off-by-one click - -When the user is inside `tmux` with `set -g mouse on`, tmux is the **first** layer to interpret mouse events. Click + drag automatically enters tmux's copy-mode, and tmux's own highlight layer (independent of the host terminal's selection) is what the user sees. tmux's hit-test against the pane is row-aware and is sometimes off-by-one in xterm.js-based hosts (Cursor, VS Code's integrated terminals): a click on the row the user perceives as "row N of chat" registers as "row N+1" inside copy-mode, so the highlight starts on the line below. - -This is independent of our app — `ox` never sees the click. The user-side workarounds: - -- **Hold Shift** while clicking / dragging. Bypasses tmux's mouse handler and falls through to the host terminal's native selection (which has accurate row mapping). -- **Toggle tmux mouse mode off** with `set -g mouse off` (or via the mouse-mode toggle binding) and rely entirely on the host terminal's selection. - ## Takeaway for oxide-code Two features are worth supporting: clicking the PR number to open it in the browser, and drag-selecting chat content to copy. The `EnableMouseCapture` + OSC 52 approach trades native drag-select for an app-side reimplementation that fights the terminal in every layer above (tmux behavior, OSC 52 acceptance, in-app highlight, payload caps). The author's terminals (Cursor's and VS Code's xterm.js, plus tmux without `set-clipboard on`) make that trade unfavorable. From ef9dd95f61e4b4965f8e4a043c7ba579430bc7ce Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 17:07:51 +0800 Subject: [PATCH 23/35] fix(tui): bracket OSC 8 emission with DECSC / DECRC instead of DSR `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. --- crates/oxide-code/src/tui/app.rs | 65 +++++++++++++++++++------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 8c4bb3fc..f5163a34 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; +use crossterm::cursor::{RestorePosition, SavePosition}; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEventKind}; use crossterm::queue; use crossterm::style::{ @@ -681,22 +682,20 @@ impl App { draw_sync(terminal, |frame| { chat_area = self.draw_frame(frame); })?; - self.emit_status_hyperlinks(terminal)?; if self.chat.update_layout(chat_area) { draw_sync(terminal, |frame| { self.draw_frame(frame); })?; - self.emit_status_hyperlinks(terminal)?; } + self.emit_status_hyperlinks(terminal)?; Ok(()) } - /// Re-emits any hyperlink ranges captured by the status bar wrapped in OSC 8 envelopes. - /// Runs out-of-band after `terminal.draw()` has flushed because storing the envelope inside - /// cell symbols would inflate `unicode-width` (URLs are plain ASCII), and ratatui's diff - /// reads that width to compute `to_skip` for trailing cells. With a 30-byte URL the diff - /// would skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into - /// the gap. Replaying the visible bytes after the flush bypasses the diff entirely. + /// Replays each captured link rect inside an OSC 8 envelope after `terminal.draw()` flushes. + /// Out-of-band emission avoids ratatui's `Buffer::diff` over-counting URL bytes as cell width + /// and skipping trailing cells (the `#86 → #pus` bug). Brackets the writes with DECSC / DECRC + /// so the cursor `terminal.draw()` parked stays put without a DSR roundtrip racing the event + /// reader. fn emit_status_hyperlinks( &mut self, terminal: &mut ratatui::Terminal>, @@ -705,15 +704,7 @@ impl App { if links.is_empty() { return Ok(()); } - // Save the cursor `terminal.draw()` parked (typically at the input field). Our writes - // move it to each link rect, so without restoring it the user sees a stray cursor next - // to `#NN` in the status bar. - let saved_cursor = terminal.get_cursor_position().ok(); write_status_hyperlinks(terminal.backend_mut(), &links)?; - if let Some(pos) = saved_cursor { - terminal.set_cursor_position(pos)?; - std::io::Write::flush(terminal.backend_mut())?; - } Ok(()) } @@ -840,18 +831,23 @@ impl App { } /// Writes each link's OSC 8 envelope (with replayed visible chars) to `out`. Pure I/O — accepts -/// any `Write` so tests can capture the bytes via a `Vec` and inspect them. Strips control -/// chars from each URL so a malformed value can't break out of the envelope, and uses BEL (`\x07`) -/// as the OSC terminator because xterm.js misparses self-contained per-cell ST closers. -pub(super) fn write_status_hyperlinks( +/// any `Write` so tests can capture and inspect the bytes. +pub(crate) fn write_status_hyperlinks( out: &mut W, links: &[super::components::status::StatusHyperlink], ) -> std::io::Result<()> { + let mut wrote_any = false; for link in links { let safe_url: String = link.url.chars().filter(|c| !c.is_control()).collect(); if safe_url.is_empty() { continue; } + if !wrote_any { + // DECSC / DECRC parks the cursor terminal-side, so the OSC 8 writes don't drag it onto + // the status bar. Avoids a DSR roundtrip that would race the TUI event reader. + queue!(out, SavePosition)?; + wrote_any = true; + } // CUP uses 1-based coordinates. let row = link.rect.y.saturating_add(1); let col = link.rect.x.saturating_add(1); @@ -864,6 +860,9 @@ pub(super) fn write_status_hyperlinks( // Reset SGR so subsequent terminal output isn't tinted by the last cell's style. write!(out, "\x1b[0m")?; } + if wrote_any { + queue!(out, RestorePosition)?; + } out.flush()?; Ok(()) } @@ -3491,9 +3490,10 @@ mod tests { #[test] fn write_status_hyperlinks_emits_cup_then_envelope_per_link() { - // Pins the on-the-wire byte sequence: CUP (1-based) → OSC 8 opener → replayed cells → - // OSC 8 closer (BEL terminator) → SGR reset. Drift here breaks the PR-click affordance - // in xterm.js-based terminals (Cursor / VS Code) where the parser is least forgiving. + // Pins the on-the-wire byte sequence: DECSC → CUP (1-based) → OSC 8 opener → replayed + // cells → OSC 8 closer (BEL terminator) → SGR reset → DECRC. Drift here breaks the + // PR-click affordance in xterm.js-based terminals (Cursor / VS Code) where the parser is + // least forgiving, or leaves the cursor parked next to `#NN` on the status bar. use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; let link = StatusHyperlink { @@ -3517,9 +3517,17 @@ mod tests { let mut buf: Vec = Vec::new(); write_status_hyperlinks(&mut buf, &[link]).unwrap(); let output = String::from_utf8(buf).unwrap(); + assert!( + output.starts_with("\x1b7"), + "DECSC parks the cursor before our writes: {output:?}", + ); + assert!( + output.ends_with("\x1b8"), + "DECRC restores the cursor after our writes: {output:?}", + ); assert!( output.contains("\x1b[1;3H"), - "CUP to row 1 col 3 (1-based): {output:?}" + "CUP to row 1 col 3 (1-based): {output:?}", ); assert!( output.contains("\x1b]8;;https://example.com/pull/86\x07"), @@ -3533,8 +3541,8 @@ mod tests { "visible bytes #, 8, 6 sit between opener and closer: {between:?}", ); assert!( - output.ends_with("\x1b]8;;\x07\x1b[0m"), - "closer + SGR reset: {output:?}" + output.contains("\x1b]8;;\x07\x1b[0m"), + "closer + SGR reset: {output:?}", ); } @@ -3573,7 +3581,10 @@ mod tests { }; let mut buf: Vec = Vec::new(); write_status_hyperlinks(&mut buf, &[link]).unwrap(); - assert!(buf.is_empty(), "no bytes when sanitized URL is empty"); + assert!( + buf.is_empty(), + "no bytes (no DECSC either) when sanitized URL is empty", + ); } // ── jump_overlay_label ── From 19bb1918436bf78008e2af2fd80c5f8be866faeb Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 17:27:49 +0800 Subject: [PATCH 24/35] refactor(util/git): extract probe helpers and cover failure paths `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%. --- crates/oxide-code/src/util/git.rs | 199 ++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 52 deletions(-) diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index fdadb425..f0bdea51 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -3,42 +3,25 @@ //! `debug` so they don't pollute normal use but are recoverable when the status bar misbehaves. use std::path::Path; -use std::process::Command; +use std::process::{Command, Output}; use tracing::debug; /// Probe the current branch via `git branch --show-current`. Detached HEAD comes back as empty /// stdout, which we collapse to `None`. pub(crate) fn current_branch(cwd: &Path) -> Option { - let Some(cwd_str) = cwd.to_str() else { - debug!(cwd = ?cwd, "git branch probe: cwd is not valid UTF-8"); - return None; - }; - let output = match Command::new("git") - .args([ - "-C", - cwd_str, - "--no-optional-locks", - "branch", - "--show-current", - ]) - .output() - { - Ok(output) => output, - Err(e) => { - debug!(cwd = cwd_str, error = %e, "git branch probe: spawn failed"); - return None; - } - }; - if !output.status.success() { - debug!( - cwd = cwd_str, - code = output.status.code().unwrap_or(-1), - stderr = stderr_summary(&output.stderr), - "git branch probe: non-zero exit", - ); - return None; - } + let cwd_str = cwd_to_str(cwd, "git branch")?; + let output = run_probe("git branch", || { + Command::new("git") + .args([ + "-C", + cwd_str, + "--no-optional-locks", + "branch", + "--show-current", + ]) + .output() + })?; parse_branch(&output.stdout) } @@ -66,41 +49,59 @@ pub(crate) struct PullRequest { /// Probe the open pull request for `cwd`'s current branch via `gh pr view --json number,url`. /// Returns `None` when `gh` is missing, the user is unauthenticated, or no PR is open. pub(crate) fn current_pull_request(cwd: &Path) -> Option { - let Some(cwd_str) = cwd.to_str() else { - debug!(cwd = ?cwd, "gh pr probe: cwd is not valid UTF-8"); + let cwd_str = cwd_to_str(cwd, "gh pr")?; + let output = run_probe("gh pr", || { + Command::new("gh") + .args(["pr", "view", "--json", "number,url"]) + .current_dir(cwd_str) + .output() + })?; + parse_pull_request(&output.stdout) +} + +fn parse_pull_request(stdout: &[u8]) -> Option { + let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; + let number = value.get("number")?.as_u64()?; + let url = value.get("url")?.as_str()?.to_owned(); + if url.is_empty() { return None; - }; - let output = match Command::new("gh") - .args(["pr", "view", "--json", "number,url"]) - .current_dir(cwd_str) - .output() - { + } + Some(PullRequest { number, url }) +} + +/// Coerces `cwd` to a `&str` so it can flow into `Command::args` / `current_dir`. Logs and +/// surfaces `None` when the path isn't valid UTF-8. +fn cwd_to_str<'a>(cwd: &'a Path, probe: &str) -> Option<&'a str> { + if let Some(s) = cwd.to_str() { + Some(s) + } else { + debug!(cwd = ?cwd, "{probe} probe: cwd is not valid UTF-8"); + None + } +} + +/// Runs a `Command::output()` closure, logging on spawn failure or non-zero exit. Returns the +/// successful output or `None`. +fn run_probe(probe: &str, spawn: F) -> Option +where + F: FnOnce() -> std::io::Result, +{ + let output = match spawn() { Ok(output) => output, Err(e) => { - debug!(cwd = cwd_str, error = %e, "gh pr probe: spawn failed"); + debug!(error = %e, "{probe} probe: spawn failed"); return None; } }; if !output.status.success() { debug!( - cwd = cwd_str, code = output.status.code().unwrap_or(-1), stderr = stderr_summary(&output.stderr), - "gh pr probe: non-zero exit", + "{probe} probe: non-zero exit", ); return None; } - parse_pull_request(&output.stdout) -} - -fn parse_pull_request(stdout: &[u8]) -> Option { - let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; - let number = value.get("number")?.as_u64()?; - let url = value.get("url")?.as_str()?.to_owned(); - if url.is_empty() { - return None; - } - Some(PullRequest { number, url }) + Some(output) } /// First non-blank stderr line, capped to keep log records terse. Surfaces the actionable signal @@ -120,6 +121,10 @@ fn stderr_summary(stderr: &[u8]) -> String { #[cfg(test)] mod tests { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::path::PathBuf; + use super::*; // ── current_branch ── @@ -159,6 +164,22 @@ mod tests { assert_eq!(current_branch(dir.path()), None); } + #[test] + fn current_branch_with_non_utf8_cwd_is_absent() { + // Linux paths are bytes; embedding a non-UTF-8 byte hits the cwd_to_str failure branch + // without ever spawning git. + let cwd = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff")); + assert_eq!(current_branch(&cwd), None); + } + + // ── current_branch_str ── + + #[test] + fn current_branch_str_delegates_to_current_branch() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(current_branch_str(dir.path().to_str().unwrap()), None); + } + // ── parse_branch ── #[test] @@ -178,6 +199,12 @@ mod tests { assert_eq!(current_pull_request(dir.path()), None); } + #[test] + fn current_pull_request_with_non_utf8_cwd_is_absent() { + let cwd = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff")); + assert_eq!(current_pull_request(&cwd), None); + } + // ── parse_pull_request ── #[test] @@ -195,6 +222,69 @@ mod tests { assert_eq!(parse_pull_request(br#"{"url":"https://x"}"#), None); assert_eq!(parse_pull_request(br#"{"number":86,"url":""}"#), None); assert_eq!(parse_pull_request(br#"{"number":-1,"url":"x"}"#), None); + // Non-string url field exercises the second `?` in `as_str()?.to_owned()`. + assert_eq!(parse_pull_request(br#"{"number":86,"url":42}"#), None); + } + + // ── cwd_to_str ── + + #[test] + fn cwd_to_str_returns_path_when_utf8() { + assert_eq!(cwd_to_str(Path::new("/tmp/a"), "p"), Some("/tmp/a")); + } + + #[test] + fn cwd_to_str_is_absent_when_not_utf8() { + let cwd = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff")); + assert_eq!(cwd_to_str(&cwd, "p"), None); + } + + // ── run_probe ── + + #[test] + fn run_probe_returns_output_on_success() { + let output = run_probe("test", || { + Ok(Output { + status: status_with_code(0), + stdout: b"ok".to_vec(), + stderr: Vec::new(), + }) + }) + .expect("success path keeps the output"); + assert_eq!(output.stdout, b"ok"); + } + + #[test] + fn run_probe_drops_output_on_non_zero_exit() { + let result = run_probe("test", || { + Ok(Output { + status: status_with_code(1), + stdout: b"unused".to_vec(), + stderr: b"fatal: not a git repository\n".to_vec(), + }) + }); + assert!(result.is_none()); + } + + #[test] + fn run_probe_drops_output_on_spawn_failure() { + let result = run_probe("test", || { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "binary missing", + )) + }); + assert!(result.is_none()); + } + + fn status_with_code(code: i32) -> std::process::ExitStatus { + // `Command::new("sh").arg("-c").arg(format!("exit {code}"))` is the portable way to mint + // an `ExitStatus` with a specific code from tests. + Command::new("sh") + .arg("-c") + .arg(format!("exit {code}")) + .status() + .unwrap() } // ── stderr_summary ── @@ -211,4 +301,9 @@ mod tests { assert_eq!(summary.len(), 203, "200 chars + '...': {summary}"); assert!(summary.ends_with("...")); } + + #[test] + fn stderr_summary_returns_empty_when_every_line_is_blank() { + assert_eq!(stderr_summary(b"\n \n\t\n"), ""); + } } From 0ac6d86fe4a1adc59237232ca67813edf05c55cc Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 17:28:00 +0800 Subject: [PATCH 25/35] refactor(tui/status): regroup tests, derive margin constant, tighten prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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`. --- .../oxide-code/src/tui/components/status.rs | 212 +++++++++++------- .../src/tui/components/status/line.rs | 22 +- 2 files changed, 140 insertions(+), 94 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 634994cd..813fcda6 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -28,10 +28,8 @@ const GIT_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(5); /// probe because `gh pr view` hits the network. const PR_REFRESH_INTERVAL: Duration = Duration::from_mins(1); -/// A hyperlink the status bar wants the App to wrap in OSC 8 after `terminal.draw()` flushes. -/// `rect` is in absolute screen coordinates (relative to the area passed to `render`). `cells` -/// holds a snapshot of the visible chars and their styles taken at render time so the post-draw -/// emission can replay them without having to re-read the buffer. +/// Hyperlink rect + replayable cell snapshot the App wraps in OSC 8 after `terminal.draw()` +/// flushes. #[derive(Debug, Clone)] pub(crate) struct StatusHyperlink { pub(crate) rect: Rect, @@ -255,14 +253,10 @@ impl StatusBar { .border_style(self.theme.border_unfocused()) .style(self.theme.surface()); let rendered = self.render_line(area.width); - // Inside the block the content row is `area.y` (no top border) starting at `area.x`. let content_y = area.y; let content_x = area.x; - // Capture link rects for the App to wrap in OSC 8 *after* `terminal.draw()` flushes. - // Storing the envelope inside cell symbols would inflate `unicode-width` (the URL is - // plain ASCII), and ratatui's diff uses that width to compute `to_skip` for trailing - // cells. With a 30-byte URL the diff would skip ~30 cells worth of trailing text and - // shift unrelated chars into the gap (e.g., `#86` rendering as `#pus`). + // Capture link rects for App::emit_status_hyperlinks to wrap post-flush. See + // docs/design/tui/mouse-interactions.md for why the envelope can't live in cell symbols. self.pending_hyperlinks.clear(); let mut hyperlink_rects = Vec::with_capacity(rendered.hyperlinks.len()); for link in &rendered.hyperlinks { @@ -275,8 +269,7 @@ impl StatusBar { hyperlink_rects.push((Rect::new(link_x, content_y, width, 1), link.url.clone())); } frame.render_widget(Paragraph::new(rendered.line).block(block), area); - // After the Paragraph paints into the buffer, snapshot the cells under each link rect so - // the App can replay them with an OSC 8 envelope wrapped around them post-flush. + // Snapshot link cells after the paint so emit_status_hyperlinks can replay them. let buf = frame.buffer_mut(); for (rect, url) in hyperlink_rects { let mut cells = Vec::with_capacity(usize::from(rect.width)); @@ -293,8 +286,8 @@ impl StatusBar { } } - /// Drain hyperlink rects captured during the most recent `render` so the App can re-emit them - /// out-of-band as OSC 8 sequences after `terminal.draw()` has flushed. + /// Drains the link rects captured by the most recent `render` so the App can re-emit them + /// inside OSC 8 envelopes after `terminal.draw()` flushes. pub(crate) fn take_pending_hyperlinks(&mut self) -> Vec { std::mem::take(&mut self.pending_hyperlinks) } @@ -561,6 +554,23 @@ mod tests { assert_eq!(bar.git_branch, None); } + #[test] + fn tick_marks_dirty_when_pull_request_changes() { + // Pins the pull-request → dirty arm of `tick`. A regression that drops the PR-refresh + // result on the floor would leave the status bar stale for one whole probe interval. + let dir = tempfile::tempdir().unwrap(); + let mut bar = test_bar(); + bar.git_cwd = Some(dir.path().to_path_buf()); + bar.track_pull_request = true; + bar.pull_request = Some(git::PullRequest { + number: 999, + url: "https://example.com/pull/999".to_owned(), + }); + bar.last_pr_probe = None; + assert!(bar.tick()); + assert_eq!(bar.pull_request, None); + } + // ── refresh_git_branch ── #[test] @@ -593,6 +603,24 @@ mod tests { ); } + #[test] + fn refresh_git_branch_returns_false_when_branch_unchanged() { + // Pre-seed last_branch_probe past the throttle window and pin git_branch to whatever + // probe will return (None for a non-repo cwd). A re-probe yields the same value, so the + // function must report no change. + let dir = tempfile::tempdir().unwrap(); + let mut bar = test_bar(); + bar.git_cwd = Some(dir.path().to_path_buf()); + bar.git_branch = None; + bar.last_branch_probe = Some( + Instant::now() + .checked_sub(GIT_BRANCH_REFRESH_INTERVAL * 2) + .unwrap(), + ); + assert!(!bar.refresh_git_branch(Instant::now())); + assert_eq!(bar.git_branch, None); + } + // ── refresh_pull_request ── #[test] @@ -674,76 +702,6 @@ mod tests { )); } - // ── take_pending_hyperlinks ── - - #[test] - fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { - // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes. The bar's - // job is to record the link rect + visible cells so the App can replay them. Storing the - // envelope inside cell symbols would inflate `unicode-width` to ~30 (URL is plain ASCII) - // and ratatui's diff would skip ~30 trailing cells, producing the `#86 → #pus` bug. - let mut bar = StatusBar::new( - &Theme::default(), - vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], - "Opus 4.7".into(), - None, - String::new(), - None, - None, - ); - bar.pull_request = Some(git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), - }); - let _backend = render_status(&mut bar, 40); - let links = bar.take_pending_hyperlinks(); - assert_eq!( - links.len(), - 1, - "PR segment reports exactly one link: {links:?}" - ); - let link = &links[0]; - assert_eq!(link.url, "https://github.com/o/r/pull/86"); - // Leading " " margin → `#86` lives at col 2, width 3. - assert_eq!(link.rect.x, 2); - assert_eq!(link.rect.width, 3); - let visible: String = link.cells.iter().map(|c| c.symbol.as_str()).collect(); - assert_eq!(visible, "#86"); - // Drained on take. - assert!( - bar.take_pending_hyperlinks().is_empty(), - "drain leaves nothing behind" - ); - } - - #[test] - fn render_records_no_hyperlinks_when_pull_request_absent() { - let mut bar = test_bar(); - let _backend = render_status(&mut bar, 40); - assert!(bar.take_pending_hyperlinks().is_empty()); - } - - #[test] - fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { - // Narrow renders drop the PR segment via the fit pass, so the recorded hyperlinks list - // must mirror the rendered line and not surface a link rect that points at empty cells. - let mut bar = StatusBar::new( - &Theme::default(), - vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], - "Opus 4.7".into(), - None, - String::new(), - None, - None, - ); - bar.pull_request = Some(git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), - }); - let _backend = render_status(&mut bar, 10); - assert!(bar.take_pending_hyperlinks().is_empty()); - } - // ── render ── fn render_status(bar: &mut StatusBar, width: u16) -> TestBackend { @@ -966,4 +924,90 @@ mod tests { "no tildified path should appear: {output:?}", ); } + + #[test] + fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { + // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes. The bar's + // job is to record the link rect + visible cells so the App can replay them. + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], + "Opus 4.7".into(), + None, + String::new(), + None, + None, + ); + bar.pull_request = Some(git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }); + let _backend = render_status(&mut bar, 40); + let links = bar.take_pending_hyperlinks(); + assert_eq!( + links.len(), + 1, + "PR segment reports exactly one link: {links:?}", + ); + let link = &links[0]; + assert_eq!(link.url, "https://github.com/o/r/pull/86"); + // Leading " " margin → `#86` lives at col 2, width 3. + assert_eq!(link.rect.x, 2); + assert_eq!(link.rect.width, 3); + let visible: String = link.cells.iter().map(|c| c.symbol.as_str()).collect(); + assert_eq!(visible, "#86"); + } + + #[test] + fn render_records_no_hyperlinks_when_pull_request_absent() { + let mut bar = test_bar(); + let _backend = render_status(&mut bar, 40); + assert!(bar.take_pending_hyperlinks().is_empty()); + } + + #[test] + fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { + // Narrow renders drop the PR segment via the fit pass, so the recorded hyperlinks list + // must mirror the rendered line and not surface a link rect that points at empty cells. + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], + "Opus 4.7".into(), + None, + String::new(), + None, + None, + ); + bar.pull_request = Some(git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }); + let _backend = render_status(&mut bar, 10); + assert!(bar.take_pending_hyperlinks().is_empty()); + } + + // ── take_pending_hyperlinks ── + + #[test] + fn take_pending_hyperlinks_drains_after_first_call() { + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], + "Opus 4.7".into(), + None, + String::new(), + None, + None, + ); + bar.pull_request = Some(git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }); + let _backend = render_status(&mut bar, 40); + assert_eq!(bar.take_pending_hyperlinks().len(), 1); + assert!( + bar.take_pending_hyperlinks().is_empty(), + "second take leaves nothing behind", + ); + } } diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 0f81738e..512738a9 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -13,6 +13,8 @@ const MAX_GIT_BRANCH_WIDTH: usize = 32; const MAX_TITLE_WIDTH: usize = 40; /// Leading margin (cells) inside the status bar that lines up content with the chat block. const STATUS_LINE_MARGIN: u16 = 2; +const STATUS_LINE_MARGIN_STR: &str = " "; +const _: () = assert!(STATUS_LINE_MARGIN_STR.len() == STATUS_LINE_MARGIN as usize); /// Ordered segment roster for one status-line render. #[derive(Debug, Clone)] @@ -57,7 +59,7 @@ impl StatusLine { fit_segments(&mut rendered, usize::from(width), sep_width); // Leading margin lines up content with the chat block underneath. - let mut spans = vec![Span::raw(" ")]; + let mut spans = vec![Span::raw(STATUS_LINE_MARGIN_STR)]; let mut hyperlinks = Vec::new(); let mut col: u16 = STATUS_LINE_MARGIN; let sep_w = u16::try_from(sep_width).unwrap_or(0); @@ -346,6 +348,13 @@ mod tests { && bytes[4].is_ascii_digit() } + fn pr_state() -> crate::util::git::PullRequest { + crate::util::git::PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + } + } + // ── StatusLine::render ── #[test] @@ -432,15 +441,8 @@ mod tests { assert_eq!(render_text(segments, 14), " main │ Ready"); } - fn pr_state() -> crate::util::git::PullRequest { - crate::util::git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), - } - } - #[test] - fn pull_request_segment_reports_hyperlink_range_for_post_render_marking() { + fn render_pull_request_reports_hyperlink_range() { let pr = pr_state(); let rendered = StatusLine::new(vec![StatusLineSegment::PullRequest]).render( &Theme::default(), @@ -464,7 +466,7 @@ mod tests { } #[test] - fn pull_request_segment_reports_no_hyperlink_when_absent() { + fn render_pull_request_reports_no_hyperlink_when_absent() { let rendered = StatusLine::new(vec![StatusLineSegment::PullRequest]).render( &Theme::default(), &StatusLineState { From d4d546f6046f610c99a643ca7d7a6179dfe39baf Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 17:28:08 +0800 Subject: [PATCH 26/35] refactor(tui/app): tighten prose and cover style replay branches - 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`. --- crates/oxide-code/src/tui/app.rs | 104 +++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index f5163a34..b42977d4 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -224,10 +224,8 @@ impl App { self.dirty = true; } - /// Routes a mouse event. The TUI does not capture mouse input, so the only events it sees are - /// app-side affordances delivered through DECSET 1007 (alternate-scroll, which delivers wheel - /// as arrow-key sequences). Left-click on the cached jump-pill rect snaps to bottom, and - /// every other event flows to chat for wheel scroll. Native drag-select-and-copy is preserved. + /// Routes a left-click on the cached jump-pill rect to chat-jump. Forwards everything else + /// to chat for wheel scroll. fn handle_mouse_event(&mut self, event: &Event) { if let Event::Mouse(mouse) = event && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) @@ -832,7 +830,7 @@ impl App { /// Writes each link's OSC 8 envelope (with replayed visible chars) to `out`. Pure I/O — accepts /// any `Write` so tests can capture and inspect the bytes. -pub(crate) fn write_status_hyperlinks( +fn write_status_hyperlinks( out: &mut W, links: &[super::components::status::StatusHyperlink], ) -> std::io::Result<()> { @@ -867,9 +865,7 @@ pub(crate) fn write_status_hyperlinks( Ok(()) } -/// Writes a single buffer cell — symbol + foreground / background / SGR attributes — directly to -/// `out`. Used by `App::emit_status_hyperlinks` to replay link cells inside an OSC 8 envelope -/// after `terminal.draw()` has flushed. +/// Writes one buffer cell (symbol + fg / bg / SGR) directly to `out`. fn write_styled_symbol( out: &mut W, cell: &super::components::status::HyperlinkCell, @@ -1514,9 +1510,8 @@ mod tests { #[test] fn left_click_outside_jump_overlay_does_not_jump_chat() { - // Without mouse capture the TUI never sees Down(Left) events from the terminal in real - // sessions, but DECSET 1007 lets simulated tests flow through `handle_mouse_event` so the - // pill hit-test stays exercised end-to-end. + // In real sessions the TUI never sees Down(Left) without mouse capture, but tests inject + // events directly to keep the pill hit-test exercised. let (mut app, _rx, _agent_tx) = test_app(None); app.chat.content_height_for_test().set(100); app.chat.set_viewport_for_test(20); @@ -3587,6 +3582,93 @@ mod tests { ); } + #[test] + fn write_status_hyperlinks_replays_styled_cell_attributes() { + // Pins every modifier branch in write_styled_symbol plus a few palette branches in + // ratatui_color_to_crossterm, since uncovered branches there silently lose styling on + // the post-flush replay. + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; + use ratatui::style::{Color, Modifier, Style}; + + let modifiers = Modifier::BOLD + | Modifier::DIM + | Modifier::ITALIC + | Modifier::UNDERLINED + | Modifier::REVERSED; + let link = StatusHyperlink { + rect: ratatui::layout::Rect::new(0, 0, 1, 1), + url: "https://x".to_owned(), + cells: vec![HyperlinkCell { + symbol: "X".to_owned(), + style: Style::default() + .fg(Color::Rgb(10, 20, 30)) + .bg(Color::Indexed(7)) + .add_modifier(modifiers), + }], + }; + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + let output = String::from_utf8(buf).unwrap(); + // SGR 38;2;r;g;b is the truecolor fg; 48;5;n is the indexed bg. + assert!( + output.contains("\x1b[38;2;10;20;30m") && output.contains("\x1b[48;5;7m"), + "fg + bg SGR escapes: {output:?}", + ); + for (sgr, label) in [ + ("\x1b[1m", "bold"), + ("\x1b[2m", "dim"), + ("\x1b[3m", "italic"), + ("\x1b[4m", "underlined"), + ("\x1b[7m", "reverse"), + ] { + assert!( + output.contains(sgr), + "missing {label} SGR escape: {output:?}", + ); + } + } + + #[test] + fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { + // Drives the early-return path in App::emit_status_hyperlinks. A regression that always + // touches the backend would wedge a stray cursor move into every frame. + use std::sync::{Arc, Mutex}; + + use ratatui::Terminal; + use ratatui::backend::CrosstermBackend; + + #[derive(Clone)] + struct SharedSink(Arc>>); + + impl std::io::Write for SharedSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let (mut app, _rx, _agent_tx) = test_app(None); + let bytes = Arc::new(Mutex::new(Vec::::new())); + let backend = CrosstermBackend::new(SharedSink(bytes.clone())); + let mut terminal = Terminal::with_options( + backend, + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 80, 24)), + }, + ) + .unwrap(); + // No render() means take_pending_hyperlinks returns empty; emit must short-circuit. + app.emit_status_hyperlinks(&mut terminal).unwrap(); + assert!( + bytes.lock().unwrap().is_empty(), + "no bytes written for a no-link frame", + ); + } + // ── jump_overlay_label ── #[test] From 2ad118b0bed9f14d6f5bbd5bd75d65236818819e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 17:28:22 +0800 Subject: [PATCH 27/35] docs(tui): refresh mouse-interactions design for DECSC/DECRC restore 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. --- .cspell/words.txt | 2 ++ docs/design/tui/mouse-interactions.md | 10 +++++----- docs/research/tui/mouse-interactions.md | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.cspell/words.txt b/.cspell/words.txt index 26412cd5..0a76de93 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -18,6 +18,8 @@ codex CREAT creds crossterm +DECRC +DECSC dedupe deque deserialize diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 074d950a..5a960bda 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -7,7 +7,7 @@ Design policy for mouse behavior in the TUI. Two user-visible features: 1. **Click `#NN` in the status bar to open the pull request in the browser**, even though no app code routes the click. -2. **Drag-select chat content with the mouse and paste it elsewhere**, in any terminal the user runs `ox` in (iTerm2, WezTerm, kitty, Alacritty, Terminal.app, GNOME Terminal, Konsole, Ghostty, Windows Terminal, VS Code's integrated terminal, Cursor's integrated terminal, ...). +2. **Drag-select chat content with the mouse and paste it elsewhere**, in any terminal the user runs `ox` in (iTerm2, WezTerm, kitty, Alacritty, Terminal.app, GNOME Terminal, Konsole, Ghostty, Windows Terminal, VS Code's integrated terminal, Cursor's integrated terminal). The cleanest way to deliver both is to let the terminal do the work. The TUI does not enable mouse capture, so the terminal's own selection layer is intact. The status-bar PR segment is wrapped in an OSC 8 hyperlink envelope that every modern terminal already knows how to make Ctrl-clickable. @@ -15,7 +15,7 @@ The cleanest way to deliver both is to let the terminal do the work. The TUI doe `enter_tui_mode` enables raw mode, the alternate screen, Kitty keyboard disambiguation, and DECSET 1007 (alternate-scroll). It does **not** enable `EnableMouseCapture`. Pairs unwind on `leave_tui_mode`. -`App::handle_mouse_event` only routes a left-click on the cached jump-to-bottom pill rect. Every other mouse event flows to `ChatView::handle_event` for wheel scroll. Wheel events arrive as keyboard arrow-key sequences via DECSET 1007 in real sessions, so the path that exercises `MouseEventKind::ScrollUp` / `ScrollDown` is mostly test-side. Both routes are kept for portability. +`App::handle_mouse_event` only routes a left-click on the cached jump-to-bottom pill rect. Every other mouse event flows to `ChatView::handle_event` for wheel scroll. Wheel events arrive as keyboard arrow-key sequences via DECSET 1007 in real sessions, so the wheel-event path mostly exercises in tests. Both routes are kept for portability. ## DECSET 1007 (alternate-scroll) @@ -35,12 +35,12 @@ The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` re Two non-obvious mechanics: -- **Out-of-band emission via the crossterm backend.** Storing the envelope in the cell symbol breaks ratatui's diff math, since `unicode-width` over-counts the URL bytes. After `terminal.draw()` returns, `App::emit_status_hyperlinks` walks the captured link list, positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. Finally, the saved pre-emission cursor position (typically the input field) is restored so the user doesn't see a stray cursor parked next to `#NN`. +- **Out-of-band emission via the crossterm backend.** Storing the envelope in the cell symbol breaks ratatui's diff math, since `unicode-width` over-counts the URL bytes. After `terminal.draw()` returns, `App::emit_status_hyperlinks` writes DECSC (`\x1b7`) to park the cursor terminal-side, then for each captured link rect it positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. After the loop a single DECRC (`\x1b8`) restores the cursor `terminal.draw()` parked, so the user doesn't see a stray cursor next to `#NN`. DECSC / DECRC was chosen over `terminal.get_cursor_position()` because the latter issues a DSR query and reads from stdin, which races the TUI event loop and silently fails when the response gets consumed by the event reader. - **BEL (`\x07`) terminator over ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally. The visible `#NN` still reads correctly because BEL is non-printable. -URLs are sanitized — every control char is filtered out before the envelope is built — so a malformed value can't break out of the OSC 8 sequence. +URLs are sanitized: every control char is filtered out before the envelope is built, so a malformed value can't break out of the OSC 8 sequence. ## Native drag-select-and-copy @@ -52,7 +52,7 @@ This means we don't need: - An app-side highlight overlay. - An OSC 52 encoder. - A `selection` theme slot. -- Per-terminal escape hatches (Option+drag, Shift+drag, ...) — the terminal's normal drag is the primary path, not an escape hatch. +- Per-terminal escape hatches (Option+drag, Shift+drag, etc.). The terminal's normal drag is the primary path. - `set -g set-clipboard on` in tmux (the user's tmux selection model is whatever the user already configured). ## Implementation files diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index eabd5c22..02c99d85 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -64,7 +64,7 @@ Modern support: iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recen Two ratatui-specific traps appear when embedding the envelope inside a frame buffer cell: 1. **`Buffer::set_string` strips control chars.** Embedding ESC in a `Span::raw` strips the leading and trailing escape and leaks the printable middle. `Cell::set_symbol` bypasses that filter and stores the symbol verbatim; crossterm's `Print(cell.symbol())` writes the bytes unchanged. -2. **`Buffer::diff` over-counts the cell's width.** The diff reads `cell.symbol().width()` to compute `to_skip` for trailing cells (multi-width handling). A symbol like `\x1b]8;;https://github.com/o/r/pull/86\x07#\x1b]8;;\x07` is mostly plain ASCII (the URL), so `unicode-width` reports ~30. The diff then skips ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap. Codex's `mark_url_hyperlink` writes the envelope into cell symbols too, but no Codex test exercises `Terminal::flush`, so the same bug lurks there as well. +2. **`Buffer::diff` over-counts the cell's width.** The diff reads `cell.symbol().width()` to compute `to_skip` for trailing cells (multi-width handling). A symbol like `\x1b]8;;https://github.com/o/r/pull/86\x07#\x1b]8;;\x07` is mostly plain ASCII (the URL), so `unicode-width` reports ~30. The diff then skips ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap. Codex's `mark_url_hyperlink` writes the envelope into cell symbols too, but no Codex test exercises `Terminal::flush`, so the same bug lurks there. The clean workaround: emit the OSC 8 envelope **out-of-band**, after `terminal.draw()` has flushed. Capture the link rect + visible cells at render time, then write CUP + `\x1b]8;;\x07` + replayed styled cells + `\x1b]8;;\x07` + SGR reset directly to the crossterm backend. Buffer cells stay plain (width 1) and the diff math behaves. From 8ea8aab6b6fb7b8e7b248ea5cfa23bf776f80e04 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 18:03:02 +0800 Subject: [PATCH 28/35] fix(tui): wrap OSC 8 in tmux DCS pass-through so ctrl+click survives 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`. --- .cspell/words.txt | 1 + crates/oxide-code/src/tui/app.rs | 165 +++++++++++++++++++++----- docs/design/tui/mouse-interactions.md | 3 +- 3 files changed, 141 insertions(+), 28 deletions(-) diff --git a/.cspell/words.txt b/.cspell/words.txt index 0a76de93..e744dff2 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -69,6 +69,7 @@ pgid pkgs pname println +Ptmux pulldown RAII ratatui diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index b42977d4..710ab52e 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -829,11 +829,35 @@ impl App { } /// Writes each link's OSC 8 envelope (with replayed visible chars) to `out`. Pure I/O — accepts -/// any `Write` so tests can capture and inspect the bytes. +/// any `Write` so tests can capture and inspect the bytes. Inside tmux the envelope is wrapped +/// in DCS pass-through, since tmux only passes a small whitelist of OSCs through to the outer +/// terminal and OSC 8 isn't on it. fn write_status_hyperlinks( out: &mut W, links: &[super::components::status::StatusHyperlink], ) -> std::io::Result<()> { + let envelope = build_status_hyperlink_envelope(links)?; + if envelope.is_empty() { + return Ok(()); + } + if std::env::var_os("TMUX").is_some() { + out.write_all(&tmux_passthrough(&envelope))?; + } else { + out.write_all(&envelope)?; + } + out.flush()?; + Ok(()) +} + +/// Materializes the DECSC + per-link CUP / OSC 8 / replayed cells / SGR reset + DECRC byte +/// stream. Returns an empty buffer when no link has a non-empty sanitized URL, so callers can +/// short-circuit before touching the terminal. +fn build_status_hyperlink_envelope( + links: &[super::components::status::StatusHyperlink], +) -> std::io::Result> { + use std::io::Write as _; + + let mut buf: Vec = Vec::new(); let mut wrote_any = false; for link in links { let safe_url: String = link.url.chars().filter(|c| !c.is_control()).collect(); @@ -843,26 +867,41 @@ fn write_status_hyperlinks( if !wrote_any { // DECSC / DECRC parks the cursor terminal-side, so the OSC 8 writes don't drag it onto // the status bar. Avoids a DSR roundtrip that would race the TUI event reader. - queue!(out, SavePosition)?; + queue!(&mut buf, SavePosition)?; wrote_any = true; } // CUP uses 1-based coordinates. let row = link.rect.y.saturating_add(1); let col = link.rect.x.saturating_add(1); - write!(out, "\x1b[{row};{col}H")?; - write!(out, "\x1b]8;;{safe_url}\x07")?; + write!(buf, "\x1b[{row};{col}H")?; + write!(buf, "\x1b]8;;{safe_url}\x07")?; for cell in &link.cells { - write_styled_symbol(out, cell)?; + write_styled_symbol(&mut buf, cell)?; } - write!(out, "\x1b]8;;\x07")?; + write!(buf, "\x1b]8;;\x07")?; // Reset SGR so subsequent terminal output isn't tinted by the last cell's style. - write!(out, "\x1b[0m")?; + write!(buf, "\x1b[0m")?; } if wrote_any { - queue!(out, RestorePosition)?; + queue!(&mut buf, RestorePosition)?; } - out.flush()?; - Ok(()) + Ok(buf) +} + +/// Wraps an escape sequence in tmux's DCS pass-through (`\x1bPtmux;...\x1b\\`) so a tmux session +/// forwards bytes for OSC numbers it doesn't otherwise pass through (OSC 8 is the relevant case). +/// Every `\x1b` inside the payload must be doubled per the tmux spec. +fn tmux_passthrough(escape: &[u8]) -> Vec { + let mut out = Vec::with_capacity(escape.len() + 8); + out.extend_from_slice(b"\x1bPtmux;"); + for &b in escape { + if b == 0x1b { + out.push(0x1b); + } + out.push(b); + } + out.extend_from_slice(b"\x1b\\"); + out } /// Writes one buffer cell (symbol + fg / bg / SGR) directly to `out`. @@ -3481,10 +3520,10 @@ mod tests { } } - // ── write_status_hyperlinks ── + // ── build_status_hyperlink_envelope ── #[test] - fn write_status_hyperlinks_emits_cup_then_envelope_per_link() { + fn build_status_hyperlink_envelope_emits_cup_then_osc8_per_link() { // Pins the on-the-wire byte sequence: DECSC → CUP (1-based) → OSC 8 opener → replayed // cells → OSC 8 closer (BEL terminator) → SGR reset → DECRC. Drift here breaks the // PR-click affordance in xterm.js-based terminals (Cursor / VS Code) where the parser is @@ -3509,9 +3548,8 @@ mod tests { }, ], }; - let mut buf: Vec = Vec::new(); - write_status_hyperlinks(&mut buf, &[link]).unwrap(); - let output = String::from_utf8(buf).unwrap(); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); assert!( output.starts_with("\x1b7"), "DECSC parks the cursor before our writes: {output:?}", @@ -3542,7 +3580,7 @@ mod tests { } #[test] - fn write_status_hyperlinks_strips_control_chars_from_url() { + fn build_status_hyperlink_envelope_strips_control_chars_from_url() { use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; let link = StatusHyperlink { @@ -3553,9 +3591,8 @@ mod tests { style: ratatui::style::Style::default(), }], }; - let mut buf: Vec = Vec::new(); - write_status_hyperlinks(&mut buf, &[link]).unwrap(); - let output = String::from_utf8(buf).unwrap(); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); assert!( output.contains("\x1b]8;;https://x.com/ok\x07"), "ESC, BEL, NUL stripped from URL: {output:?}", @@ -3563,7 +3600,7 @@ mod tests { } #[test] - fn write_status_hyperlinks_skips_link_with_empty_safe_url() { + fn build_status_hyperlink_envelope_is_empty_when_safe_url_is_empty() { use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; let link = StatusHyperlink { @@ -3574,16 +3611,15 @@ mod tests { style: ratatui::style::Style::default(), }], }; - let mut buf: Vec = Vec::new(); - write_status_hyperlinks(&mut buf, &[link]).unwrap(); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); assert!( - buf.is_empty(), + bytes.is_empty(), "no bytes (no DECSC either) when sanitized URL is empty", ); } #[test] - fn write_status_hyperlinks_replays_styled_cell_attributes() { + fn build_status_hyperlink_envelope_replays_styled_cell_attributes() { // Pins every modifier branch in write_styled_symbol plus a few palette branches in // ratatui_color_to_crossterm, since uncovered branches there silently lose styling on // the post-flush replay. @@ -3606,9 +3642,8 @@ mod tests { .add_modifier(modifiers), }], }; - let mut buf: Vec = Vec::new(); - write_status_hyperlinks(&mut buf, &[link]).unwrap(); - let output = String::from_utf8(buf).unwrap(); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); // SGR 38;2;r;g;b is the truecolor fg; 48;5;n is the indexed bg. assert!( output.contains("\x1b[38;2;10;20;30m") && output.contains("\x1b[48;5;7m"), @@ -3628,6 +3663,82 @@ mod tests { } } + // ── write_status_hyperlinks ── + + #[test] + fn write_status_hyperlinks_passes_envelope_through_unchanged_outside_tmux() { + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; + + let link = StatusHyperlink { + rect: ratatui::layout::Rect::new(0, 0, 1, 1), + url: "https://x".to_owned(), + cells: vec![HyperlinkCell { + symbol: "x".to_owned(), + style: ratatui::style::Style::default(), + }], + }; + let mut buf: Vec = Vec::new(); + temp_env::with_var_unset("TMUX", || { + write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); + }); + let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); + assert_eq!( + buf, envelope, + "outside tmux the wire bytes match the raw envelope", + ); + } + + #[test] + fn write_status_hyperlinks_wraps_envelope_in_dcs_passthrough_inside_tmux() { + // tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer + // terminal; OSC 8 needs the DCS pass-through wrapper or it gets silently dropped. + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; + + let link = StatusHyperlink { + rect: ratatui::layout::Rect::new(0, 0, 1, 1), + url: "https://x".to_owned(), + cells: vec![HyperlinkCell { + symbol: "x".to_owned(), + style: ratatui::style::Style::default(), + }], + }; + let mut buf: Vec = Vec::new(); + temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + }); + assert!( + buf.starts_with(b"\x1bPtmux;"), + "DCS opener brackets the envelope: {buf:?}", + ); + assert!( + buf.ends_with(b"\x1b\\"), + "ST closer terminates the DCS: {buf:?}", + ); + // Inner OSC 8 ESCs must be doubled so tmux strips its own DCS wrapper without consuming + // the inner sequence. + assert!( + buf.windows(8).any(|w| w == b"\x1b\x1b]8;;ht"), + "inner ESC before OSC 8 opener is doubled: {buf:?}", + ); + } + + #[test] + fn write_status_hyperlinks_is_a_noop_when_no_links_pending() { + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[]).unwrap(); + assert!(buf.is_empty(), "no bytes for an empty link list"); + } + + // ── tmux_passthrough ── + + #[test] + fn tmux_passthrough_doubles_inner_escape_and_wraps_in_dcs() { + let inner = b"\x1b]8;;https://x\x07X\x1b]8;;\x07"; + let wrapped = tmux_passthrough(inner); + let expected = b"\x1bPtmux;\x1b\x1b]8;;https://x\x07X\x1b\x1b]8;;\x07\x1b\\"; + assert_eq!(&wrapped, expected); + } + #[test] fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { // Drives the early-return path in App::emit_status_hyperlinks. A regression that always diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 5a960bda..7932b9e4 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -33,10 +33,11 @@ The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap (the original `#86 → #pus` bug). Replaying the envelope after the flush bypasses the diff entirely. The just-painted cells stay plain, so the next frame's diff sees them as plain symbols of width 1. -Two non-obvious mechanics: +Three non-obvious mechanics: - **Out-of-band emission via the crossterm backend.** Storing the envelope in the cell symbol breaks ratatui's diff math, since `unicode-width` over-counts the URL bytes. After `terminal.draw()` returns, `App::emit_status_hyperlinks` writes DECSC (`\x1b7`) to park the cursor terminal-side, then for each captured link rect it positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. After the loop a single DECRC (`\x1b8`) restores the cursor `terminal.draw()` parked, so the user doesn't see a stray cursor next to `#NN`. DECSC / DECRC was chosen over `terminal.get_cursor_position()` because the latter issues a DSR query and reads from stdin, which races the TUI event loop and silently fails when the response gets consumed by the event reader. - **BEL (`\x07`) terminator over ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. +- **DCS pass-through inside tmux.** tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer terminal; OSC 8 isn't on it and gets silently dropped. When `$TMUX` is set, `write_status_hyperlinks` wraps the entire envelope (DECSC + per-link CUP / OSC 8 / replayed cells + DECRC) in `\x1bPtmux;...\x1b\\` with every inner ESC doubled. tmux strips the DCS and forwards the inner bytes verbatim to the outer terminal. Without this wrap, ctrl+click on `#NN` does nothing inside tmux even though OSC 8 works fine in the same terminal outside tmux. Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally. The visible `#NN` still reads correctly because BEL is non-printable. From 7a8ba86877a16b413ec3718312853cd818b7cb24 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Mon, 18 May 2026 18:09:34 +0800 Subject: [PATCH 29/35] refactor(tui): lift test-mod imports and dedupe hyperlink fixtures - 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. --- crates/oxide-code/src/tui/app.rs | 106 ++++++------------ .../oxide-code/src/tui/components/status.rs | 32 ++---- .../src/tui/components/status/line.rs | 8 +- 3 files changed, 51 insertions(+), 95 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 710ab52e..a6b061e3 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -1051,12 +1051,13 @@ fn append_sentence(mut message: String, sentence: &str) -> String { #[cfg(test)] mod tests { use std::io::{self, Write}; - use std::sync::Mutex; + use std::sync::{Arc, Mutex}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::backend::TestBackend; use ratatui::layout::Rect; use ratatui::prelude::CrosstermBackend; + use ratatui::style::{Color, Modifier, Style}; use ratatui::{Terminal, TerminalOptions, Viewport}; use tokio::sync::mpsc; @@ -1064,6 +1065,7 @@ mod tests { use crate::agent::event::UsageSnapshot; use crate::config::test_thresholds; use crate::tool::ToolRegistry; + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; use crate::tui::modal::testing::ScriptedModal; /// Idle `App` plus the `user_tx` consumer and an `agent_tx` kept alive so `agent_rx` stays @@ -3492,8 +3494,6 @@ mod tests { // Buffer-wide invariant: pre-stain every cell, render, and assert no sentinel survives. // The frame-area surface fill is the only widget that guarantees this for cells no // other widget covers. - use ratatui::style::Color; - let (mut app, _rx, _agent_tx) = test_app(None); let sentinel = Color::Rgb(254, 0, 254); let surface_bg = app.theme.surface().bg.expect("surface slot defines bg"); @@ -3522,32 +3522,35 @@ mod tests { // ── build_status_hyperlink_envelope ── + fn plain_hyperlink( + rect: ratatui::layout::Rect, + url: &str, + symbols: &[&str], + ) -> StatusHyperlink { + StatusHyperlink { + rect, + url: url.to_owned(), + cells: symbols + .iter() + .map(|s| HyperlinkCell { + symbol: (*s).to_owned(), + style: Style::default(), + }) + .collect(), + } + } + #[test] fn build_status_hyperlink_envelope_emits_cup_then_osc8_per_link() { // Pins the on-the-wire byte sequence: DECSC → CUP (1-based) → OSC 8 opener → replayed // cells → OSC 8 closer (BEL terminator) → SGR reset → DECRC. Drift here breaks the // PR-click affordance in xterm.js-based terminals (Cursor / VS Code) where the parser is // least forgiving, or leaves the cursor parked next to `#NN` on the status bar. - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; - - let link = StatusHyperlink { - rect: ratatui::layout::Rect::new(2, 0, 3, 1), - url: "https://example.com/pull/86".to_owned(), - cells: vec![ - HyperlinkCell { - symbol: "#".to_owned(), - style: ratatui::style::Style::default(), - }, - HyperlinkCell { - symbol: "8".to_owned(), - style: ratatui::style::Style::default(), - }, - HyperlinkCell { - symbol: "6".to_owned(), - style: ratatui::style::Style::default(), - }, - ], - }; + let link = plain_hyperlink( + Rect::new(2, 0, 3, 1), + "https://example.com/pull/86", + &["#", "8", "6"], + ); let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); let output = String::from_utf8(bytes).unwrap(); assert!( @@ -3581,16 +3584,11 @@ mod tests { #[test] fn build_status_hyperlink_envelope_strips_control_chars_from_url() { - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; - - let link = StatusHyperlink { - rect: ratatui::layout::Rect::new(0, 0, 1, 1), - url: "https://x.com/\x1b\x07\x00ok".to_owned(), - cells: vec![HyperlinkCell { - symbol: "x".to_owned(), - style: ratatui::style::Style::default(), - }], - }; + let link = plain_hyperlink( + Rect::new(0, 0, 1, 1), + "https://x.com/\x1b\x07\x00ok", + &["x"], + ); let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); let output = String::from_utf8(bytes).unwrap(); assert!( @@ -3601,16 +3599,7 @@ mod tests { #[test] fn build_status_hyperlink_envelope_is_empty_when_safe_url_is_empty() { - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; - - let link = StatusHyperlink { - rect: ratatui::layout::Rect::new(0, 0, 1, 1), - url: "\x1b\x07".to_owned(), - cells: vec![HyperlinkCell { - symbol: "x".to_owned(), - style: ratatui::style::Style::default(), - }], - }; + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "\x1b\x07", &["x"]); let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); assert!( bytes.is_empty(), @@ -3623,16 +3612,13 @@ mod tests { // Pins every modifier branch in write_styled_symbol plus a few palette branches in // ratatui_color_to_crossterm, since uncovered branches there silently lose styling on // the post-flush replay. - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; - use ratatui::style::{Color, Modifier, Style}; - let modifiers = Modifier::BOLD | Modifier::DIM | Modifier::ITALIC | Modifier::UNDERLINED | Modifier::REVERSED; let link = StatusHyperlink { - rect: ratatui::layout::Rect::new(0, 0, 1, 1), + rect: Rect::new(0, 0, 1, 1), url: "https://x".to_owned(), cells: vec![HyperlinkCell { symbol: "X".to_owned(), @@ -3667,16 +3653,7 @@ mod tests { #[test] fn write_status_hyperlinks_passes_envelope_through_unchanged_outside_tmux() { - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; - - let link = StatusHyperlink { - rect: ratatui::layout::Rect::new(0, 0, 1, 1), - url: "https://x".to_owned(), - cells: vec![HyperlinkCell { - symbol: "x".to_owned(), - style: ratatui::style::Style::default(), - }], - }; + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); let mut buf: Vec = Vec::new(); temp_env::with_var_unset("TMUX", || { write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); @@ -3692,16 +3669,7 @@ mod tests { fn write_status_hyperlinks_wraps_envelope_in_dcs_passthrough_inside_tmux() { // tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer // terminal; OSC 8 needs the DCS pass-through wrapper or it gets silently dropped. - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; - - let link = StatusHyperlink { - rect: ratatui::layout::Rect::new(0, 0, 1, 1), - url: "https://x".to_owned(), - cells: vec![HyperlinkCell { - symbol: "x".to_owned(), - style: ratatui::style::Style::default(), - }], - }; + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); let mut buf: Vec = Vec::new(); temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { write_status_hyperlinks(&mut buf, &[link]).unwrap(); @@ -3743,10 +3711,6 @@ mod tests { fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { // Drives the early-return path in App::emit_status_hyperlinks. A regression that always // touches the backend would wedge a stray cursor move into every frame. - use std::sync::{Arc, Mutex}; - - use ratatui::Terminal; - use ratatui::backend::CrosstermBackend; #[derive(Clone)] struct SharedSink(Arc>>); diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 813fcda6..00bd4743 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -365,6 +365,13 @@ mod tests { ) } + fn pr_state(number: u64) -> git::PullRequest { + git::PullRequest { + number, + url: format!("https://github.com/o/r/pull/{number}"), + } + } + // ── set_title ── #[test] @@ -562,10 +569,7 @@ mod tests { let mut bar = test_bar(); bar.git_cwd = Some(dir.path().to_path_buf()); bar.track_pull_request = true; - bar.pull_request = Some(git::PullRequest { - number: 999, - url: "https://example.com/pull/999".to_owned(), - }); + bar.pull_request = Some(pr_state(999)); bar.last_pr_probe = None; assert!(bar.tick()); assert_eq!(bar.pull_request, None); @@ -649,10 +653,7 @@ mod tests { let mut bar = test_bar(); bar.track_pull_request = true; bar.git_cwd = Some(dir.path().to_path_buf()); - bar.pull_request = Some(git::PullRequest { - number: 1, - url: "https://example.com/pull/1".to_owned(), - }); + bar.pull_request = Some(pr_state(1)); assert!(bar.refresh_pull_request(Instant::now())); assert_eq!(bar.pull_request, None); } @@ -938,10 +939,7 @@ mod tests { None, None, ); - bar.pull_request = Some(git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), - }); + bar.pull_request = Some(pr_state(86)); let _backend = render_status(&mut bar, 40); let links = bar.take_pending_hyperlinks(); assert_eq!( @@ -978,10 +976,7 @@ mod tests { None, None, ); - bar.pull_request = Some(git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), - }); + bar.pull_request = Some(pr_state(86)); let _backend = render_status(&mut bar, 10); assert!(bar.take_pending_hyperlinks().is_empty()); } @@ -999,10 +994,7 @@ mod tests { None, None, ); - bar.pull_request = Some(git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), - }); + bar.pull_request = Some(pr_state(86)); let _backend = render_status(&mut bar, 40); assert_eq!(bar.take_pending_hyperlinks().len(), 1); assert!( diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 512738a9..2b041bb9 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -348,10 +348,10 @@ mod tests { && bytes[4].is_ascii_digit() } - fn pr_state() -> crate::util::git::PullRequest { + fn pr_state(number: u64) -> crate::util::git::PullRequest { crate::util::git::PullRequest { - number: 86, - url: "https://github.com/o/r/pull/86".to_owned(), + number, + url: format!("https://github.com/o/r/pull/{number}"), } } @@ -443,7 +443,7 @@ mod tests { #[test] fn render_pull_request_reports_hyperlink_range() { - let pr = pr_state(); + let pr = pr_state(86); let rendered = StatusLine::new(vec![StatusLineSegment::PullRequest]).render( &Theme::default(), &StatusLineState { From 8de4975e3d579ff18c941b6fa313766c66c80225 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Tue, 19 May 2026 13:46:39 +0800 Subject: [PATCH 30/35] fix(tui): harden OSC 8 replay against modifier leak and stale tmux env 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. --- crates/oxide-code/src/tui/app.rs | 117 ++++++++++++++++++++++++++++-- crates/oxide-code/src/util/git.rs | 18 +++-- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index a6b061e3..18723808 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -840,7 +840,7 @@ fn write_status_hyperlinks( if envelope.is_empty() { return Ok(()); } - if std::env::var_os("TMUX").is_some() { + if running_inside_tmux() { out.write_all(&tmux_passthrough(&envelope))?; } else { out.write_all(&envelope)?; @@ -888,6 +888,12 @@ fn build_status_hyperlink_envelope( Ok(buf) } +/// True when the process is running inside tmux. Empty `$TMUX` is treated as absent so a stale +/// rc-file export doesn't route DCS pass-through bytes to a raw terminal. +fn running_inside_tmux() -> bool { + std::env::var("TMUX").is_ok_and(|v| !v.is_empty()) +} + /// Wraps an escape sequence in tmux's DCS pass-through (`\x1bPtmux;...\x1b\\`) so a tmux session /// forwards bytes for OSC numbers it doesn't otherwise pass through (OSC 8 is the relevant case). /// Every `\x1b` inside the payload must be doubled per the tmux spec. @@ -904,20 +910,19 @@ fn tmux_passthrough(escape: &[u8]) -> Vec { out } -/// Writes one buffer cell (symbol + fg / bg / SGR) directly to `out`. +/// Writes one buffer cell (symbol + fg / bg / SGR) directly to `out`. Each cell starts with an +/// SGR reset so a prior cell's modifier bits don't carry over into a sibling with a different +/// style. fn write_styled_symbol( out: &mut W, cell: &super::components::status::HyperlinkCell, ) -> std::io::Result<()> { + queue!(out, SetAttribute(CtAttribute::Reset))?; if let Some(fg) = cell.style.fg { queue!(out, SetForegroundColor(ratatui_color_to_crossterm(fg)))?; - } else { - queue!(out, SetForegroundColor(CtColor::Reset))?; } if let Some(bg) = cell.style.bg { queue!(out, SetBackgroundColor(ratatui_color_to_crossterm(bg)))?; - } else { - queue!(out, SetBackgroundColor(CtColor::Reset))?; } let modifier = cell.style.add_modifier; if modifier.contains(ratatui::style::Modifier::BOLD) { @@ -2448,6 +2453,27 @@ mod tests { assert_eq!(s, "Switched to Claude Haiku 4.5 (claude-haiku-4-5)."); } + #[test] + fn format_config_change_swap_to_no_tier_model_surfaces_requested_effort() { + // Model swap onto a no-tier model with an explicit effort ask. The user needs to know + // the requested tier didn't apply, otherwise a silent drop reads as a config change + // landing. + let s = format_config_change( + "claude-haiku-4-5", + true, + None, + None, + Some(crate::config::Effort::High), + base_compaction(), + base_compaction(), + ); + assert_eq!( + s, + "Switched to Claude Haiku 4.5 (claude-haiku-4-5). \ + Effort unchanged — model has no effort tier (asked for high).", + ); + } + #[test] fn format_config_change_swap_clears_effort_when_new_model_drops_it() { // User had a tier; new model has none. Surface the change so @@ -3649,6 +3675,34 @@ mod tests { } } + #[test] + fn build_status_hyperlink_envelope_resets_sgr_between_cells_with_different_modifiers() { + // Without an SGR reset per cell, modifier bits from a prior cell leak into the next + // (crossterm's `SetAttribute(Bold)` only sets, it doesn't clear). Pin the reset so a + // mixed-style link can't render the second cell with bold-from-the-first. + let link = StatusHyperlink { + rect: Rect::new(0, 0, 2, 1), + url: "https://x".to_owned(), + cells: vec![ + HyperlinkCell { + symbol: "A".to_owned(), + style: Style::default().add_modifier(Modifier::BOLD), + }, + HyperlinkCell { + symbol: "B".to_owned(), + style: Style::default().add_modifier(Modifier::ITALIC), + }, + ], + }; + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); + let after_first_cell = output.split('A').nth(1).unwrap_or_default(); + assert!( + after_first_cell.starts_with("\x1b[0m"), + "second cell starts with SGR reset to drop the prior cell's modifiers: {output:?}", + ); + } + // ── write_status_hyperlinks ── #[test] @@ -3690,6 +3744,19 @@ mod tests { ); } + #[test] + fn write_status_hyperlinks_treats_empty_tmux_env_as_outside_tmux() { + // Stale rc-file `export TMUX=` would otherwise route DCS pass-through bytes to a raw + // terminal that prints `Ptmux;...\\` literally on the status bar. + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var("TMUX", Some(""), || { + write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); + }); + let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); + assert_eq!(buf, envelope, "empty $TMUX is treated as absent"); + } + #[test] fn write_status_hyperlinks_is_a_noop_when_no_links_pending() { let mut buf: Vec = Vec::new(); @@ -3744,6 +3811,44 @@ mod tests { ); } + // ── ratatui_color_to_crossterm ── + + #[test] + fn ratatui_color_to_crossterm_round_trips_every_palette_variant() { + // Every color a theme can emit must replay correctly over OSC 8. A wrong branch silently + // loses styling on the post-flush hyperlink replay because `write_styled_symbol` queues + // whichever crossterm value this function returns. + use ratatui::style::Color as RC; + let cases = [ + (RC::Reset, CtColor::Reset), + (RC::Black, CtColor::Black), + (RC::Red, CtColor::DarkRed), + (RC::Green, CtColor::DarkGreen), + (RC::Yellow, CtColor::DarkYellow), + (RC::Blue, CtColor::DarkBlue), + (RC::Magenta, CtColor::DarkMagenta), + (RC::Cyan, CtColor::DarkCyan), + (RC::Gray, CtColor::Grey), + (RC::DarkGray, CtColor::DarkGrey), + (RC::LightRed, CtColor::Red), + (RC::LightGreen, CtColor::Green), + (RC::LightYellow, CtColor::Yellow), + (RC::LightBlue, CtColor::Blue), + (RC::LightMagenta, CtColor::Magenta), + (RC::LightCyan, CtColor::Cyan), + (RC::White, CtColor::White), + (RC::Indexed(33), CtColor::AnsiValue(33)), + (RC::Rgb(1, 2, 3), CtColor::Rgb { r: 1, g: 2, b: 3 }), + ]; + for (input, expected) in cases { + assert_eq!( + ratatui_color_to_crossterm(input), + expected, + "color {input:?}", + ); + } + } + // ── jump_overlay_label ── #[test] diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index f0bdea51..55b8f2be 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -11,7 +11,7 @@ use tracing::debug; /// stdout, which we collapse to `None`. pub(crate) fn current_branch(cwd: &Path) -> Option { let cwd_str = cwd_to_str(cwd, "git branch")?; - let output = run_probe("git branch", || { + let output = run_probe("git branch", cwd_str, || { Command::new("git") .args([ "-C", @@ -50,7 +50,7 @@ pub(crate) struct PullRequest { /// Returns `None` when `gh` is missing, the user is unauthenticated, or no PR is open. pub(crate) fn current_pull_request(cwd: &Path) -> Option { let cwd_str = cwd_to_str(cwd, "gh pr")?; - let output = run_probe("gh pr", || { + let output = run_probe("gh pr", cwd_str, || { Command::new("gh") .args(["pr", "view", "--json", "number,url"]) .current_dir(cwd_str) @@ -81,15 +81,16 @@ fn cwd_to_str<'a>(cwd: &'a Path, probe: &str) -> Option<&'a str> { } /// Runs a `Command::output()` closure, logging on spawn failure or non-zero exit. Returns the -/// successful output or `None`. -fn run_probe(probe: &str, spawn: F) -> Option +/// successful output or `None`. `cwd` rides along on the log records so a user can pinpoint which +/// worktree the probe failed in. +fn run_probe(probe: &str, cwd: &str, spawn: F) -> Option where F: FnOnce() -> std::io::Result, { let output = match spawn() { Ok(output) => output, Err(e) => { - debug!(error = %e, "{probe} probe: spawn failed"); + debug!(error = %e, cwd = %cwd, "{probe} probe: spawn failed"); return None; } }; @@ -97,6 +98,7 @@ where debug!( code = output.status.code().unwrap_or(-1), stderr = stderr_summary(&output.stderr), + cwd = %cwd, "{probe} probe: non-zero exit", ); return None; @@ -243,7 +245,7 @@ mod tests { #[test] fn run_probe_returns_output_on_success() { - let output = run_probe("test", || { + let output = run_probe("test", "/tmp/cwd", || { Ok(Output { status: status_with_code(0), stdout: b"ok".to_vec(), @@ -256,7 +258,7 @@ mod tests { #[test] fn run_probe_drops_output_on_non_zero_exit() { - let result = run_probe("test", || { + let result = run_probe("test", "/tmp/cwd", || { Ok(Output { status: status_with_code(1), stdout: b"unused".to_vec(), @@ -268,7 +270,7 @@ mod tests { #[test] fn run_probe_drops_output_on_spawn_failure() { - let result = run_probe("test", || { + let result = run_probe("test", "/tmp/cwd", || { Err(std::io::Error::new( std::io::ErrorKind::NotFound, "binary missing", From bb2796ad9ceb863067a2ece89afff7356bc7f6f2 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Tue, 19 May 2026 13:47:35 +0800 Subject: [PATCH 31/35] refactor(tui): apply convention and prose review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:". --- crates/oxide-code/src/tui/app.rs | 260 ++++++++---------- .../oxide-code/src/tui/components/status.rs | 68 ++--- .../src/tui/components/status/line.rs | 14 +- crates/oxide-code/src/util/git.rs | 6 +- docs/design/tui/mouse-interactions.md | 6 +- 5 files changed, 160 insertions(+), 194 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 18723808..1dc8e01e 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr; use super::components::chat::ChatView; use super::components::input::InputArea; -use super::components::status::{Status, StatusBar}; +use super::components::status::{HyperlinkCell, Status, StatusBar, StatusHyperlink}; use super::components::welcome::{self, WelcomeSnapshot}; use super::glyphs::{NEWLINE_GLYPH, USER_PROMPT_PREFIX, USER_PROMPT_PREFIX_WIDTH}; use super::modal::{ModalAction, ModalStack}; @@ -689,11 +689,7 @@ impl App { Ok(()) } - /// Replays each captured link rect inside an OSC 8 envelope after `terminal.draw()` flushes. - /// Out-of-band emission avoids ratatui's `Buffer::diff` over-counting URL bytes as cell width - /// and skipping trailing cells (the `#86 → #pus` bug). Brackets the writes with DECSC / DECRC - /// so the cursor `terminal.draw()` parked stays put without a DSR roundtrip racing the event - /// reader. + /// Drains the bar's pending hyperlinks and writes their OSC 8 envelopes to the backend. fn emit_status_hyperlinks( &mut self, terminal: &mut ratatui::Terminal>, @@ -828,13 +824,11 @@ impl App { } } -/// Writes each link's OSC 8 envelope (with replayed visible chars) to `out`. Pure I/O — accepts -/// any `Write` so tests can capture and inspect the bytes. Inside tmux the envelope is wrapped -/// in DCS pass-through, since tmux only passes a small whitelist of OSCs through to the outer -/// terminal and OSC 8 isn't on it. +/// Writes each link's OSC 8 envelope to `out`. Inside tmux the envelope rides through DCS +/// pass-through, since tmux drops OSC 8 from its forward whitelist. fn write_status_hyperlinks( out: &mut W, - links: &[super::components::status::StatusHyperlink], + links: &[StatusHyperlink], ) -> std::io::Result<()> { let envelope = build_status_hyperlink_envelope(links)?; if envelope.is_empty() { @@ -849,12 +843,8 @@ fn write_status_hyperlinks( Ok(()) } -/// Materializes the DECSC + per-link CUP / OSC 8 / replayed cells / SGR reset + DECRC byte -/// stream. Returns an empty buffer when no link has a non-empty sanitized URL, so callers can -/// short-circuit before touching the terminal. -fn build_status_hyperlink_envelope( - links: &[super::components::status::StatusHyperlink], -) -> std::io::Result> { +/// Builds the OSC 8 byte stream for the link batch. Empty when no link has a usable URL. +fn build_status_hyperlink_envelope(links: &[StatusHyperlink]) -> std::io::Result> { use std::io::Write as _; let mut buf: Vec = Vec::new(); @@ -865,8 +855,6 @@ fn build_status_hyperlink_envelope( continue; } if !wrote_any { - // DECSC / DECRC parks the cursor terminal-side, so the OSC 8 writes don't drag it onto - // the status bar. Avoids a DSR roundtrip that would race the TUI event reader. queue!(&mut buf, SavePosition)?; wrote_any = true; } @@ -894,9 +882,7 @@ fn running_inside_tmux() -> bool { std::env::var("TMUX").is_ok_and(|v| !v.is_empty()) } -/// Wraps an escape sequence in tmux's DCS pass-through (`\x1bPtmux;...\x1b\\`) so a tmux session -/// forwards bytes for OSC numbers it doesn't otherwise pass through (OSC 8 is the relevant case). -/// Every `\x1b` inside the payload must be doubled per the tmux spec. +/// Wraps an escape sequence in tmux's DCS pass-through so OSC 8 reaches the outer terminal. fn tmux_passthrough(escape: &[u8]) -> Vec { let mut out = Vec::with_capacity(escape.len() + 8); out.extend_from_slice(b"\x1bPtmux;"); @@ -915,7 +901,7 @@ fn tmux_passthrough(escape: &[u8]) -> Vec { /// style. fn write_styled_symbol( out: &mut W, - cell: &super::components::status::HyperlinkCell, + cell: &HyperlinkCell, ) -> std::io::Result<()> { queue!(out, SetAttribute(CtAttribute::Reset))?; if let Some(fg) = cell.style.fg { @@ -1280,6 +1266,20 @@ mod tests { body } + fn plain_hyperlink(rect: Rect, url: &str, symbols: &[&str]) -> StatusHyperlink { + StatusHyperlink { + rect, + url: url.to_owned(), + cells: symbols + .iter() + .map(|s| HyperlinkCell { + symbol: (*s).to_owned(), + style: Style::default(), + }) + .collect(), + } + } + // ── App::new ── #[test] @@ -3546,26 +3546,108 @@ mod tests { } } - // ── build_status_hyperlink_envelope ── + // ── emit_status_hyperlinks ── - fn plain_hyperlink( - rect: ratatui::layout::Rect, - url: &str, - symbols: &[&str], - ) -> StatusHyperlink { - StatusHyperlink { - rect, - url: url.to_owned(), - cells: symbols - .iter() - .map(|s| HyperlinkCell { - symbol: (*s).to_owned(), - style: Style::default(), - }) - .collect(), + #[test] + fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { + // Drives the early-return path in App::emit_status_hyperlinks. A regression that always + // touches the backend would wedge a stray cursor move into every frame. + + #[derive(Clone)] + struct SharedSink(Arc>>); + + impl std::io::Write for SharedSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } } + + let (mut app, _rx, _agent_tx) = test_app(None); + let bytes = Arc::new(Mutex::new(Vec::::new())); + let backend = CrosstermBackend::new(SharedSink(bytes.clone())); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)), + }, + ) + .unwrap(); + // No render() means take_pending_hyperlinks returns empty; emit must short-circuit. + app.emit_status_hyperlinks(&mut terminal).unwrap(); + assert!( + bytes.lock().unwrap().is_empty(), + "no bytes written for a no-link frame", + ); + } + + // ── write_status_hyperlinks ── + + #[test] + fn write_status_hyperlinks_passes_envelope_through_unchanged_outside_tmux() { + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var_unset("TMUX", || { + write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); + }); + let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); + assert_eq!( + buf, envelope, + "outside tmux the wire bytes match the raw envelope", + ); + } + + #[test] + fn write_status_hyperlinks_wraps_envelope_in_dcs_passthrough_inside_tmux() { + // tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer + // terminal; OSC 8 needs the DCS pass-through wrapper or it gets silently dropped. + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + }); + assert!( + buf.starts_with(b"\x1bPtmux;"), + "DCS opener brackets the envelope: {buf:?}", + ); + assert!( + buf.ends_with(b"\x1b\\"), + "ST closer terminates the DCS: {buf:?}", + ); + // Inner OSC 8 ESCs must be doubled so tmux strips its own DCS wrapper without consuming + // the inner sequence. + assert!( + buf.windows(8).any(|w| w == b"\x1b\x1b]8;;ht"), + "inner ESC before OSC 8 opener is doubled: {buf:?}", + ); } + #[test] + fn write_status_hyperlinks_treats_empty_tmux_env_as_outside_tmux() { + // Stale rc-file `export TMUX=` would otherwise route DCS pass-through bytes to a raw + // terminal that prints `Ptmux;...\\` literally on the status bar. + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var("TMUX", Some(""), || { + write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); + }); + let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); + assert_eq!(buf, envelope, "empty $TMUX is treated as absent"); + } + + #[test] + fn write_status_hyperlinks_is_a_noop_when_no_links_pending() { + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[]).unwrap(); + assert!(buf.is_empty(), "no bytes for an empty link list"); + } + + // ── build_status_hyperlink_envelope ── + #[test] fn build_status_hyperlink_envelope_emits_cup_then_osc8_per_link() { // Pins the on-the-wire byte sequence: DECSC → CUP (1-based) → OSC 8 opener → replayed @@ -3703,67 +3785,6 @@ mod tests { ); } - // ── write_status_hyperlinks ── - - #[test] - fn write_status_hyperlinks_passes_envelope_through_unchanged_outside_tmux() { - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); - let mut buf: Vec = Vec::new(); - temp_env::with_var_unset("TMUX", || { - write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); - }); - let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); - assert_eq!( - buf, envelope, - "outside tmux the wire bytes match the raw envelope", - ); - } - - #[test] - fn write_status_hyperlinks_wraps_envelope_in_dcs_passthrough_inside_tmux() { - // tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer - // terminal; OSC 8 needs the DCS pass-through wrapper or it gets silently dropped. - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); - let mut buf: Vec = Vec::new(); - temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { - write_status_hyperlinks(&mut buf, &[link]).unwrap(); - }); - assert!( - buf.starts_with(b"\x1bPtmux;"), - "DCS opener brackets the envelope: {buf:?}", - ); - assert!( - buf.ends_with(b"\x1b\\"), - "ST closer terminates the DCS: {buf:?}", - ); - // Inner OSC 8 ESCs must be doubled so tmux strips its own DCS wrapper without consuming - // the inner sequence. - assert!( - buf.windows(8).any(|w| w == b"\x1b\x1b]8;;ht"), - "inner ESC before OSC 8 opener is doubled: {buf:?}", - ); - } - - #[test] - fn write_status_hyperlinks_treats_empty_tmux_env_as_outside_tmux() { - // Stale rc-file `export TMUX=` would otherwise route DCS pass-through bytes to a raw - // terminal that prints `Ptmux;...\\` literally on the status bar. - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); - let mut buf: Vec = Vec::new(); - temp_env::with_var("TMUX", Some(""), || { - write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); - }); - let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); - assert_eq!(buf, envelope, "empty $TMUX is treated as absent"); - } - - #[test] - fn write_status_hyperlinks_is_a_noop_when_no_links_pending() { - let mut buf: Vec = Vec::new(); - write_status_hyperlinks(&mut buf, &[]).unwrap(); - assert!(buf.is_empty(), "no bytes for an empty link list"); - } - // ── tmux_passthrough ── #[test] @@ -3774,43 +3795,6 @@ mod tests { assert_eq!(&wrapped, expected); } - #[test] - fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { - // Drives the early-return path in App::emit_status_hyperlinks. A regression that always - // touches the backend would wedge a stray cursor move into every frame. - - #[derive(Clone)] - struct SharedSink(Arc>>); - - impl std::io::Write for SharedSink { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - let (mut app, _rx, _agent_tx) = test_app(None); - let bytes = Arc::new(Mutex::new(Vec::::new())); - let backend = CrosstermBackend::new(SharedSink(bytes.clone())); - let mut terminal = Terminal::with_options( - backend, - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 80, 24)), - }, - ) - .unwrap(); - // No render() means take_pending_hyperlinks returns empty; emit must short-circuit. - app.emit_status_hyperlinks(&mut terminal).unwrap(); - assert!( - bytes.lock().unwrap().is_empty(), - "no bytes written for a no-link frame", - ); - } - // ── ratatui_color_to_crossterm ── #[test] @@ -3844,7 +3828,7 @@ mod tests { assert_eq!( ratatui_color_to_crossterm(input), expected, - "color {input:?}", + "color {input:?}" ); } } diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 00bd4743..e759275f 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use ratatui::Frame; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Paragraph}; @@ -28,8 +29,7 @@ const GIT_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(5); /// probe because `gh pr view` hits the network. const PR_REFRESH_INTERVAL: Duration = Duration::from_mins(1); -/// Hyperlink rect + replayable cell snapshot the App wraps in OSC 8 after `terminal.draw()` -/// flushes. +/// A status-bar segment that should be re-emitted as an OSC 8 hyperlink. #[derive(Debug, Clone)] pub(crate) struct StatusHyperlink { pub(crate) rect: Rect, @@ -40,7 +40,7 @@ pub(crate) struct StatusHyperlink { #[derive(Debug, Clone)] pub(crate) struct HyperlinkCell { pub(crate) symbol: String, - pub(crate) style: ratatui::style::Style, + pub(crate) style: Style, } /// Status bar at the top of the TUI. @@ -57,16 +57,14 @@ pub(crate) struct StatusBar { git_cwd: Option, git_branch: Option, pull_request: Option, - /// `true` while the `pull-request` segment is configured. Skips the `gh` probe entirely when - /// the user hasn't opted in. + /// Skips the gh probe when the pull-request segment isn't configured. track_pull_request: bool, last_branch_probe: Option, last_pr_probe: Option, status: Status, spinner_frame: usize, tick_counter: usize, - /// Absolute screen rects of hyperlink segments from the most recent `render` call. Drained by - /// the App after `terminal.draw()` flushes, then re-emitted as out-of-band OSC 8 envelopes. + /// Hyperlink segments captured during the most recent render, awaiting drain by the App. pending_hyperlinks: Vec, } @@ -255,8 +253,6 @@ impl StatusBar { let rendered = self.render_line(area.width); let content_y = area.y; let content_x = area.x; - // Capture link rects for App::emit_status_hyperlinks to wrap post-flush. See - // docs/design/tui/mouse-interactions.md for why the envelope can't live in cell symbols. self.pending_hyperlinks.clear(); let mut hyperlink_rects = Vec::with_capacity(rendered.hyperlinks.len()); for link in &rendered.hyperlinks { @@ -286,8 +282,7 @@ impl StatusBar { } } - /// Drains the link rects captured by the most recent `render` so the App can re-emit them - /// inside OSC 8 envelopes after `terminal.draw()` flushes. + /// Drains and returns the hyperlinks captured during the most recent render. pub(crate) fn take_pending_hyperlinks(&mut self) -> Vec { std::mem::take(&mut self.pending_hyperlinks) } @@ -365,6 +360,19 @@ mod tests { ) } + /// Bar configured with a `pull-request` segment so render captures a hyperlink rect. + fn pr_bar() -> StatusBar { + StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], + "Opus 4.7".into(), + None, + String::new(), + None, + None, + ) + } + fn pr_state(number: u64) -> git::PullRequest { git::PullRequest { number, @@ -930,17 +938,9 @@ mod tests { fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes. The bar's // job is to record the link rect + visible cells so the App can replay them. - let mut bar = StatusBar::new( - &Theme::default(), - vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], - "Opus 4.7".into(), - None, - String::new(), - None, - None, - ); + let mut bar = pr_bar(); bar.pull_request = Some(pr_state(86)); - let _backend = render_status(&mut bar, 40); + render_status(&mut bar, 40); let links = bar.take_pending_hyperlinks(); assert_eq!( links.len(), @@ -959,7 +959,7 @@ mod tests { #[test] fn render_records_no_hyperlinks_when_pull_request_absent() { let mut bar = test_bar(); - let _backend = render_status(&mut bar, 40); + render_status(&mut bar, 40); assert!(bar.take_pending_hyperlinks().is_empty()); } @@ -967,17 +967,9 @@ mod tests { fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { // Narrow renders drop the PR segment via the fit pass, so the recorded hyperlinks list // must mirror the rendered line and not surface a link rect that points at empty cells. - let mut bar = StatusBar::new( - &Theme::default(), - vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], - "Opus 4.7".into(), - None, - String::new(), - None, - None, - ); + let mut bar = pr_bar(); bar.pull_request = Some(pr_state(86)); - let _backend = render_status(&mut bar, 10); + render_status(&mut bar, 10); assert!(bar.take_pending_hyperlinks().is_empty()); } @@ -985,17 +977,9 @@ mod tests { #[test] fn take_pending_hyperlinks_drains_after_first_call() { - let mut bar = StatusBar::new( - &Theme::default(), - vec![StatusLineSegment::PullRequest, StatusLineSegment::RunState], - "Opus 4.7".into(), - None, - String::new(), - None, - None, - ); + let mut bar = pr_bar(); bar.pull_request = Some(pr_state(86)); - let _backend = render_status(&mut bar, 40); + render_status(&mut bar, 40); assert_eq!(bar.take_pending_hyperlinks().len(), 1); assert!( bar.take_pending_hyperlinks().is_empty(), diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 2b041bb9..07091213 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -5,6 +5,7 @@ use unicode_width::UnicodeWidthStr; use crate::agent::event::UsageSnapshot; use crate::config::{Effort, StatusLineSegment}; use crate::tui::theme::Theme; +use crate::util::git::PullRequest; use crate::util::text::truncate_to_width; use crate::util::time::local_offset; @@ -22,15 +23,14 @@ pub(super) struct StatusLine { segments: Vec, } -/// A rendered status line plus the buffer ranges that should be wrapped in OSC 8 hyperlinks. +/// A rendered status line plus the column ranges of hyperlinkable segments. #[derive(Debug)] pub(super) struct RenderedStatusLine { pub(super) line: Line<'static>, pub(super) hyperlinks: Vec, } -/// Where a hyperlinked segment landed inside the rendered line. `col` is the cell column from -/// the start of the line, before any block / area offset is applied. +/// Cell range of a hyperlinked segment, measured from the start of the line. #[derive(Debug, Clone)] pub(super) struct RenderedHyperlink { pub(super) col: u16, @@ -160,7 +160,7 @@ pub(super) struct StatusLineState<'a> { /// Already tilde-expanded, so the renderer must not substitute `~` again. pub(super) cwd: &'a str, pub(super) git_branch: Option<&'a str>, - pub(super) pull_request: Option<&'a crate::util::git::PullRequest>, + pub(super) pull_request: Option<&'a PullRequest>, /// Pre-rendered run-state segment from the parent component. pub(super) status_span: Span<'static>, } @@ -308,7 +308,7 @@ mod tests { use super::*; fn render_text(segments: Vec, width: u16) -> String { - let pr = crate::util::git::PullRequest { + let pr = PullRequest { number: 86, url: "https://github.com/o/r/pull/86".to_owned(), }; @@ -348,8 +348,8 @@ mod tests { && bytes[4].is_ascii_digit() } - fn pr_state(number: u64) -> crate::util::git::PullRequest { - crate::util::git::PullRequest { + fn pr_state(number: u64) -> PullRequest { + PullRequest { number, url: format!("https://github.com/o/r/pull/{number}"), } diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index 55b8f2be..5a963f33 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -1,6 +1,6 @@ //! Git probes used by the session header and the status bar. Best-effort: every probe collapses //! to `None` on missing git, non-repo cwd, detached HEAD, or non-UTF-8 output. Failures log at -//! `debug` so they don't pollute normal use but are recoverable when the status bar misbehaves. +//! `debug` so they're recoverable when the status bar misbehaves. use std::path::Path; use std::process::{Command, Output}; @@ -106,9 +106,7 @@ where Some(output) } -/// First non-blank stderr line, capped to keep log records terse. Surfaces the actionable signal -/// (`auth required`, `no pull requests found`, `not a git repository`) without dumping a wall of -/// hint text. +/// First non-blank stderr line, capped at `MAX_LEN` for terse log records. fn stderr_summary(stderr: &[u8]) -> String { const MAX_LEN: usize = 200; let text = String::from_utf8_lossy(stderr); diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index 7932b9e4..f3d2e528 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -31,13 +31,13 @@ The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph \x1b[;H\x1b]8;;\x07\x1b]8;;\x07\x1b[0m ``` -The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap (the original `#86 → #pus` bug). Replaying the envelope after the flush bypasses the diff entirely. The just-painted cells stay plain, so the next frame's diff sees them as plain symbols of width 1. +The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap. Replaying the envelope after the flush bypasses the diff entirely. The just-painted cells stay plain, so the next frame's diff sees them as plain symbols of width 1. -Three non-obvious mechanics: +Three mechanics worth surfacing: - **Out-of-band emission via the crossterm backend.** Storing the envelope in the cell symbol breaks ratatui's diff math, since `unicode-width` over-counts the URL bytes. After `terminal.draw()` returns, `App::emit_status_hyperlinks` writes DECSC (`\x1b7`) to park the cursor terminal-side, then for each captured link rect it positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. After the loop a single DECRC (`\x1b8`) restores the cursor `terminal.draw()` parked, so the user doesn't see a stray cursor next to `#NN`. DECSC / DECRC was chosen over `terminal.get_cursor_position()` because the latter issues a DSR query and reads from stdin, which races the TUI event loop and silently fails when the response gets consumed by the event reader. - **BEL (`\x07`) terminator over ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. -- **DCS pass-through inside tmux.** tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer terminal; OSC 8 isn't on it and gets silently dropped. When `$TMUX` is set, `write_status_hyperlinks` wraps the entire envelope (DECSC + per-link CUP / OSC 8 / replayed cells + DECRC) in `\x1bPtmux;...\x1b\\` with every inner ESC doubled. tmux strips the DCS and forwards the inner bytes verbatim to the outer terminal. Without this wrap, ctrl+click on `#NN` does nothing inside tmux even though OSC 8 works fine in the same terminal outside tmux. +- **DCS pass-through inside tmux.** tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer terminal; OSC 8 isn't on it and gets silently dropped. When the process runs inside tmux (`$TMUX` set and non-empty, so a stale rc-file export doesn't trigger the wrap), `write_status_hyperlinks` wraps the entire envelope (DECSC + per-link CUP / OSC 8 / replayed cells + DECRC) in `\x1bPtmux;...\x1b\\` with every inner ESC doubled. tmux strips the DCS and forwards the inner bytes verbatim to the outer terminal. Without this wrap, ctrl+click on `#NN` does nothing inside tmux even though OSC 8 works fine in the same terminal outside tmux. tmux 3.3+ requires `set -g allow-passthrough on`; without it the wrap is silently dropped at the tmux layer. Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally. The visible `#NN` still reads correctly because BEL is non-printable. From 6fa13b1f7aa8f087834f04bc9bdeea8c5f74e2e3 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Tue, 19 May 2026 14:44:25 +0800 Subject: [PATCH 32/35] feat(tui): surface drag-copy and PR-click in the welcome tip pool 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. --- ...nimum_full_layout_still_includes_starters.snap | 15 +++++++-------- ...nt_collapsed_drops_box_but_keeps_starters.snap | 15 +++++++-------- ...ders_box_environment_starters_and_trailer.snap | 15 +++++++-------- crates/oxide-code/src/tui/components/welcome.rs | 8 ++++++++ 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_60_col_minimum_full_layout_still_includes_starters.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_60_col_minimum_full_layout_still_includes_starters.snap index cd8e07a4..0635473f 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_60_col_minimum_full_layout_still_includes_starters.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_60_col_minimum_full_layout_still_includes_starters.snap @@ -1,19 +1,18 @@ --- source: crates/oxide-code/src/tui/components/welcome.rs -assertion_line: 438 expression: "render(60, 14, &snap)" --- " " " ━━━━ oxide-code v0.1.0 ━━━━ " " " -" Claude Opus 4.7 · xhigh effort · OAuth " -" ~/github/oxide-code " +" Claude Opus 4.7 · xhigh effort · OAuth " +" ~/github/oxide-code " " " -" Try one of: " +" Try one of: " " " -" /help list commands " -" /diff show git changes " -" /model switch model " +" /clear reset conversation " +" /help list commands " +" /theme switch theme " " " -" Tip — ox --continue resumes your last session " +" Tip — drag chat text to select and copy " " " diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_collapsed_drops_box_but_keeps_starters.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_collapsed_drops_box_but_keeps_starters.snap index 7f68001e..541c1e4f 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_collapsed_drops_box_but_keeps_starters.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_collapsed_drops_box_but_keeps_starters.snap @@ -1,19 +1,18 @@ --- source: crates/oxide-code/src/tui/components/welcome.rs -assertion_line: 444 expression: "render(50, 14, &snap)" --- " " " oxide-code v0.1.0 " " " -" Claude Opus 4.7 · xhigh effort " -" ~/github/oxide-code " +" Claude Opus 4.7 · xhigh effort " +" ~/github/oxide-code " " " -" Try one of: " +" Try one of: " " " -" /help list commands " -" /diff show git changes " -" /model switch model " +" /clear reset conversation " +" /help list commands " +" /theme switch theme " " " -" Tip — ox --continue resumes your last session " +" Tip — drag chat text to select and copy " " " diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_full_width_renders_box_environment_starters_and_trailer.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_full_width_renders_box_environment_starters_and_trailer.snap index 7155f64c..0c65a5e8 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_full_width_renders_box_environment_starters_and_trailer.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__welcome__tests__paint_full_width_renders_box_environment_starters_and_trailer.snap @@ -1,19 +1,18 @@ --- source: crates/oxide-code/src/tui/components/welcome.rs -assertion_line: 432 expression: "render(80, 14, &snap)" --- " " " ━━━━ oxide-code v0.1.0 ━━━━ " " " -" Claude Opus 4.7 · xhigh effort · OAuth " -" ~/github/oxide-code " +" Claude Opus 4.7 · xhigh effort · OAuth " +" ~/github/oxide-code " " " -" Try one of: " +" Try one of: " " " -" /help list commands " -" /diff show git changes " -" /model switch model " +" /clear reset conversation " +" /help list commands " +" /theme switch theme " " " -" Tip — ox --continue resumes your last session " +" Tip — drag chat text to select and copy " " " diff --git a/crates/oxide-code/src/tui/components/welcome.rs b/crates/oxide-code/src/tui/components/welcome.rs index 0f8cc4d0..e1f2d91c 100644 --- a/crates/oxide-code/src/tui/components/welcome.rs +++ b/crates/oxide-code/src/tui/components/welcome.rs @@ -18,23 +18,31 @@ type Starter = (&'static str, &'static str); const STARTER_POOL: &[Starter] = &[ ("/clear", "reset conversation"), + ("/compact", "summarize and trim context"), + ("/config", "inspect resolved config"), ("/diff", "show git changes"), ("/effort", "tune Speed ↔ Intelligence"), ("/help", "list commands"), ("/init", "author or update AGENTS.md"), ("/model", "switch model"), + ("/rename", "set session title"), ("/resume", "switch to another session"), ("/status", "session at a glance"), ("/theme", "switch theme"), ]; const TIP_POOL: &[&str] = &[ + "Ctrl/Cmd-click #NN in the status bar to open the PR", + "drag chat text to select and copy", "ox --continue resumes your last session", "ox --list shows recent sessions", + "ox -p \"\" runs a one-shot prompt headless", "press / to browse all commands", "press Ctrl+C twice to exit", "press Ctrl+D in /resume to delete a session", + "press Ctrl+End to jump chat back to bottom", "press Enter to send, Shift+Enter for newline", + "press Esc to cancel an in-flight response", ]; const STARTER_PICK: usize = 3; From 248c88ceebd259c38ddc4d90b49a08e5896f1b7e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Tue, 19 May 2026 15:38:31 +0800 Subject: [PATCH 33/35] test(util): isolate tildify HOME fixtures 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. --- crates/oxide-code/src/util/path.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/oxide-code/src/util/path.rs b/crates/oxide-code/src/util/path.rs index c670d6da..b91345d2 100644 --- a/crates/oxide-code/src/util/path.rs +++ b/crates/oxide-code/src/util/path.rs @@ -115,25 +115,29 @@ mod tests { #[test] fn tildify_rewrites_home_prefix_to_tilde() { - let Some(home) = dirs::home_dir() else { - return; // unusual CI envs without HOME - }; - let path = home.join("work/project"); - assert_eq!(tildify(&path), "~/work/project"); + temp_env::with_var("HOME", Some("/tmp/oxide-home"), || { + assert_eq!( + tildify(&PathBuf::from("/tmp/oxide-home/work/project")), + "~/work/project", + ); + }); } #[test] fn tildify_preserves_paths_outside_home() { - let path = PathBuf::from("/tmp/not-home/session"); - assert_eq!(tildify(&path), "/tmp/not-home/session"); + temp_env::with_var("HOME", Some("/tmp/oxide-home"), || { + assert_eq!( + tildify(&PathBuf::from("/tmp/not-home/session")), + "/tmp/not-home/session", + ); + }); } #[test] fn tildify_leaves_home_itself_as_tilde() { - let Some(home) = dirs::home_dir() else { - return; - }; - assert_eq!(tildify(&home), "~/"); + temp_env::with_var("HOME", Some("/tmp/oxide-home"), || { + assert_eq!(tildify(&PathBuf::from("/tmp/oxide-home")), "~/"); + }); } // ── expand_user ── From 39ec1ecb0a3118861722c3c72c9754675e486694 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Tue, 19 May 2026 15:39:00 +0800 Subject: [PATCH 34/35] refactor(tui): align mouse PR code with terminal behavior 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. --- crates/oxide-code/src/tui/app.rs | 640 ++---------------- crates/oxide-code/src/tui/components/chat.rs | 29 - .../oxide-code/src/tui/components/status.rs | 24 +- .../src/tui/components/status/line.rs | 8 +- crates/oxide-code/src/tui/terminal.rs | 370 ++++++++++ crates/oxide-code/src/util/git.rs | 15 +- 6 files changed, 462 insertions(+), 624 deletions(-) diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 1dc8e01e..6ff1ce9f 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -9,15 +9,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; -use crossterm::cursor::{RestorePosition, SavePosition}; -use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, MouseButton, MouseEventKind}; -use crossterm::queue; -use crossterm::style::{ - Attribute as CtAttribute, Color as CtColor, Print, SetAttribute, SetBackgroundColor, - SetForegroundColor, -}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent}; use futures::{Stream, StreamExt}; -use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use tokio::sync::mpsc; @@ -25,12 +19,12 @@ use unicode_width::UnicodeWidthStr; use super::components::chat::ChatView; use super::components::input::InputArea; -use super::components::status::{HyperlinkCell, Status, StatusBar, StatusHyperlink}; +use super::components::status::{Status, StatusBar}; use super::components::welcome::{self, WelcomeSnapshot}; use super::glyphs::{NEWLINE_GLYPH, USER_PROMPT_PREFIX, USER_PROMPT_PREFIX_WIDTH}; use super::modal::{ModalAction, ModalStack}; use super::pending_calls::{PendingCall, PendingCalls, result_header}; -use super::terminal::{Tui, draw_sync}; +use super::terminal::{Tui, draw_sync, write_status_hyperlinks}; use super::theme::Theme; use crate::agent::event::{AgentEvent, UserAction}; use crate::config::{CompactionConfig, Effort, display_auto_compaction}; @@ -53,8 +47,6 @@ pub(crate) struct App { theme: Theme, status_bar: StatusBar, chat: ChatView, - /// Rect of the jump-to-bottom pill on the most recent frame, or `None` while it's hidden. - jump_overlay_rect: Option, input: InputArea, session_info: LiveSessionInfo, agent_rx: mpsc::Receiver, @@ -111,7 +103,6 @@ impl App { theme: theme.clone(), status_bar, chat, - jump_overlay_rect: None, input: InputArea::new(theme), session_info, agent_rx, @@ -216,7 +207,7 @@ impl App { } } Event::Mouse(_) => { - self.handle_mouse_event(event); + self.chat.handle_event(event); } Event::Resize(..) => {} _ => return, @@ -224,20 +215,6 @@ impl App { self.dirty = true; } - /// Routes a left-click on the cached jump-pill rect to chat-jump. Forwards everything else - /// to chat for wheel scroll. - fn handle_mouse_event(&mut self, event: &Event) { - if let Event::Mouse(mouse) = event - && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && let Some(rect) = self.jump_overlay_rect - && rect.contains(Position::new(mouse.column, mouse.row)) - { - self.chat.jump_to_bottom(); - return; - } - self.chat.handle_event(event); - } - fn apply_modal_action(&mut self, action: ModalAction) { match action { // Cancel: revert any in-flight theme preview to the snapshot taken on open. @@ -689,7 +666,7 @@ impl App { Ok(()) } - /// Drains the bar's pending hyperlinks and writes their OSC 8 envelopes to the backend. + /// Replays captured status links as OSC 8 envelopes after the frame flush. fn emit_status_hyperlinks( &mut self, terminal: &mut ratatui::Terminal>, @@ -783,9 +760,8 @@ impl App { ); } - fn render_jump_overlay(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) { + fn render_jump_overlay(&self, frame: &mut ratatui::Frame<'_>, area: Rect) { if !self.chat.is_scrolled_up() || area.width < 25 || area.height == 0 { - self.jump_overlay_rect = None; return; } @@ -800,7 +776,6 @@ impl App { // opaque surface bg so the chat content underneath stays readable. let pill_width = u16::try_from(label.width().saturating_add(2)).unwrap_or(u16::MAX); if pill_width > area.width { - self.jump_overlay_rect = None; return; } let pill = Rect { @@ -809,7 +784,6 @@ impl App { width: pill_width, height: 1, }; - self.jump_overlay_rect = Some(pill); let block = Block::default().style(self.theme.surface()); let inner = block.inner(pill); frame.render_widget(block, pill); @@ -824,137 +798,6 @@ impl App { } } -/// Writes each link's OSC 8 envelope to `out`. Inside tmux the envelope rides through DCS -/// pass-through, since tmux drops OSC 8 from its forward whitelist. -fn write_status_hyperlinks( - out: &mut W, - links: &[StatusHyperlink], -) -> std::io::Result<()> { - let envelope = build_status_hyperlink_envelope(links)?; - if envelope.is_empty() { - return Ok(()); - } - if running_inside_tmux() { - out.write_all(&tmux_passthrough(&envelope))?; - } else { - out.write_all(&envelope)?; - } - out.flush()?; - Ok(()) -} - -/// Builds the OSC 8 byte stream for the link batch. Empty when no link has a usable URL. -fn build_status_hyperlink_envelope(links: &[StatusHyperlink]) -> std::io::Result> { - use std::io::Write as _; - - let mut buf: Vec = Vec::new(); - let mut wrote_any = false; - for link in links { - let safe_url: String = link.url.chars().filter(|c| !c.is_control()).collect(); - if safe_url.is_empty() { - continue; - } - if !wrote_any { - queue!(&mut buf, SavePosition)?; - wrote_any = true; - } - // CUP uses 1-based coordinates. - let row = link.rect.y.saturating_add(1); - let col = link.rect.x.saturating_add(1); - write!(buf, "\x1b[{row};{col}H")?; - write!(buf, "\x1b]8;;{safe_url}\x07")?; - for cell in &link.cells { - write_styled_symbol(&mut buf, cell)?; - } - write!(buf, "\x1b]8;;\x07")?; - // Reset SGR so subsequent terminal output isn't tinted by the last cell's style. - write!(buf, "\x1b[0m")?; - } - if wrote_any { - queue!(&mut buf, RestorePosition)?; - } - Ok(buf) -} - -/// True when the process is running inside tmux. Empty `$TMUX` is treated as absent so a stale -/// rc-file export doesn't route DCS pass-through bytes to a raw terminal. -fn running_inside_tmux() -> bool { - std::env::var("TMUX").is_ok_and(|v| !v.is_empty()) -} - -/// Wraps an escape sequence in tmux's DCS pass-through so OSC 8 reaches the outer terminal. -fn tmux_passthrough(escape: &[u8]) -> Vec { - let mut out = Vec::with_capacity(escape.len() + 8); - out.extend_from_slice(b"\x1bPtmux;"); - for &b in escape { - if b == 0x1b { - out.push(0x1b); - } - out.push(b); - } - out.extend_from_slice(b"\x1b\\"); - out -} - -/// Writes one buffer cell (symbol + fg / bg / SGR) directly to `out`. Each cell starts with an -/// SGR reset so a prior cell's modifier bits don't carry over into a sibling with a different -/// style. -fn write_styled_symbol( - out: &mut W, - cell: &HyperlinkCell, -) -> std::io::Result<()> { - queue!(out, SetAttribute(CtAttribute::Reset))?; - if let Some(fg) = cell.style.fg { - queue!(out, SetForegroundColor(ratatui_color_to_crossterm(fg)))?; - } - if let Some(bg) = cell.style.bg { - queue!(out, SetBackgroundColor(ratatui_color_to_crossterm(bg)))?; - } - let modifier = cell.style.add_modifier; - if modifier.contains(ratatui::style::Modifier::BOLD) { - queue!(out, SetAttribute(CtAttribute::Bold))?; - } - if modifier.contains(ratatui::style::Modifier::DIM) { - queue!(out, SetAttribute(CtAttribute::Dim))?; - } - if modifier.contains(ratatui::style::Modifier::ITALIC) { - queue!(out, SetAttribute(CtAttribute::Italic))?; - } - if modifier.contains(ratatui::style::Modifier::UNDERLINED) { - queue!(out, SetAttribute(CtAttribute::Underlined))?; - } - if modifier.contains(ratatui::style::Modifier::REVERSED) { - queue!(out, SetAttribute(CtAttribute::Reverse))?; - } - queue!(out, Print(&cell.symbol))?; - Ok(()) -} - -fn ratatui_color_to_crossterm(c: ratatui::style::Color) -> CtColor { - use ratatui::style::Color as RC; - match c { - RC::Reset => CtColor::Reset, - RC::Black => CtColor::Black, - RC::Red => CtColor::DarkRed, - RC::Green => CtColor::DarkGreen, - RC::Yellow => CtColor::DarkYellow, - RC::Blue => CtColor::DarkBlue, - RC::Magenta => CtColor::DarkMagenta, - RC::Cyan => CtColor::DarkCyan, - RC::Gray => CtColor::Grey, - RC::DarkGray => CtColor::DarkGrey, - RC::LightRed => CtColor::Red, - RC::LightGreen => CtColor::Green, - RC::LightYellow => CtColor::Yellow, - RC::LightBlue => CtColor::Blue, - RC::LightMagenta => CtColor::Magenta, - RC::LightCyan => CtColor::Cyan, - RC::White => CtColor::White, - RC::Indexed(i) => CtColor::AnsiValue(i), - RC::Rgb(r, g, b) => CtColor::Rgb { r, g, b }, - } -} - fn jump_overlay_label(new_count: u32, width: usize) -> String { if width < 40 { return "↓ (ctrl+End)".to_owned(); @@ -999,7 +842,7 @@ fn format_config_change( (Some(req), _, Some(eff)) if req == eff => format!("{head} · effort {eff}."), (Some(req), _, Some(eff)) => format!("{head} · effort {eff} (clamped from {req})."), (Some(req), _, None) => { - format!("{head}. Effort unchanged — model has no effort tier (asked for {req}).") + format!("{head}. Effort unchanged: model has no effort tier (asked for {req}).") } (None, None, None) => format!("{head}."), (None, Some(_), None) => format!("{head}. Effort cleared (model has no effort tier)."), @@ -1014,7 +857,7 @@ fn format_config_change( (Some(req), Some(eff)) if req == eff => format!("Effort set to {eff}."), (Some(req), Some(eff)) => format!("Effort set to {eff} (clamped from {req})."), (Some(req), None) => { - format!("Effort unchanged — model has no effort tier (asked for {req}).") + format!("Effort unchanged: model has no effort tier (asked for {req}).") } // Slash dispatch keeps this unreachable, but a clear fallback beats a panic. (None, _) => "Config unchanged.".to_owned(), @@ -1048,7 +891,7 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::layout::Rect; use ratatui::prelude::CrosstermBackend; - use ratatui::style::{Color, Modifier, Style}; + use ratatui::style::Color; use ratatui::{Terminal, TerminalOptions, Viewport}; use tokio::sync::mpsc; @@ -1056,7 +899,6 @@ mod tests { use crate::agent::event::UsageSnapshot; use crate::config::test_thresholds; use crate::tool::ToolRegistry; - use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; use crate::tui::modal::testing::ScriptedModal; /// Idle `App` plus the `user_tx` consumer and an `agent_tx` kept alive so `agent_rx` stays @@ -1266,20 +1108,6 @@ mod tests { body } - fn plain_hyperlink(rect: Rect, url: &str, symbols: &[&str]) -> StatusHyperlink { - StatusHyperlink { - rect, - url: url.to_owned(), - cells: symbols - .iter() - .map(|s| HyperlinkCell { - symbol: (*s).to_owned(), - style: Style::default(), - }) - .collect(), - } - } - // ── App::new ── #[test] @@ -1452,15 +1280,20 @@ mod tests { } #[test] - fn handle_crossterm_mouse_is_forwarded_to_chat() { - // Wheel events reach `ChatView::handle_event` for scroll, so the dirty flag flips. + fn handle_crossterm_event_mouse_is_forwarded_to_chat() { let (mut app, _rx, _agent_tx) = test_app(None); + app.chat.push_system_message(long_chat_block()); + _ = render_app(&mut app, 60, 10); + app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollDown, + kind: MouseEventKind::ScrollUp, column: 0, row: 0, modifiers: KeyModifiers::NONE, })); + let text = rendered_text(&mut app, 60, 10); + + assert!(text.contains("Jump to bottom"), "{text}"); assert!(app.dirty); } @@ -1531,70 +1364,6 @@ mod tests { ); } - // ── handle_mouse_event ── - - #[test] - fn left_click_on_jump_overlay_jumps_chat_to_bottom() { - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat.content_height_for_test().set(100); - app.chat.set_viewport_for_test(20); - app.chat.set_scroll_offset_for_test(10); - app.chat.set_auto_scroll_for_test(false); - app.jump_overlay_rect = Some(Rect::new(60, 23, 18, 1)); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 70, - row: 23, - modifiers: KeyModifiers::NONE, - })); - - assert_eq!(app.chat.scroll_offset_for_test(), 80); - assert!(app.chat.auto_scroll_for_test()); - assert!(app.dirty); - } - - #[test] - fn left_click_outside_jump_overlay_does_not_jump_chat() { - // In real sessions the TUI never sees Down(Left) without mouse capture, but tests inject - // events directly to keep the pill hit-test exercised. - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat.content_height_for_test().set(100); - app.chat.set_viewport_for_test(20); - app.chat.set_scroll_offset_for_test(10); - app.chat.set_auto_scroll_for_test(false); - app.jump_overlay_rect = Some(Rect::new(60, 23, 18, 1)); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 5, - row: 5, - modifiers: KeyModifiers::NONE, - })); - - assert_eq!(app.chat.scroll_offset_for_test(), 10); - assert!(!app.chat.auto_scroll_for_test()); - } - - #[test] - fn wheel_scroll_event_routes_to_chat_view() { - // DECSET 1007 lets the terminal translate physical wheel into arrow-key sequences in real - // sessions, but tests inject ScrollUp / ScrollDown directly to exercise the routing. - let (mut app, _rx, _agent_tx) = test_app(None); - app.chat.content_height_for_test().set(100); - app.chat.set_viewport_for_test(20); - app.chat.set_scroll_offset_for_test(10); - - app.handle_crossterm_event(&Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 5, - row: 5, - modifiers: KeyModifiers::NONE, - })); - - assert_eq!(app.chat.scroll_offset_for_test(), 9); - } - // ── modal gate ── #[tokio::test] @@ -2470,7 +2239,7 @@ mod tests { assert_eq!( s, "Switched to Claude Haiku 4.5 (claude-haiku-4-5). \ - Effort unchanged — model has no effort tier (asked for high).", + Effort unchanged: model has no effort tier (asked for high).", ); } @@ -2612,7 +2381,7 @@ mod tests { ); assert_eq!( s, - "Effort unchanged — model has no effort tier (asked for high)." + "Effort unchanged: model has no effort tier (asked for high)." ); } @@ -3372,8 +3141,7 @@ mod tests { fn render_repaints_when_chat_content_grows_past_viewport() { use std::fmt::Write as _; - // Content pushed in the same handler tick must land in the viewport on the first frame — - // a post-paint re-clamp would arrive too late. + // Content pushed in the same handler tick must land before a post-paint re-clamp. let (mut app, _rx, _agent_tx) = test_app(None); let mut body = String::new(); for i in 0..40 { @@ -3387,6 +3155,41 @@ mod tests { ); } + // ── emit_status_hyperlinks ── + + #[test] + fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { + #[derive(Clone)] + struct SharedSink(Arc>>); + + impl std::io::Write for SharedSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let (mut app, _rx, _agent_tx) = test_app(None); + let bytes = Arc::new(Mutex::new(Vec::::new())); + let backend = CrosstermBackend::new(SharedSink(bytes.clone())); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)), + }, + ) + .unwrap(); + app.emit_status_hyperlinks(&mut terminal).unwrap(); + assert!( + bytes.lock().unwrap().is_empty(), + "no bytes written for a no-link frame", + ); + } + // ── draw_frame ── #[test] @@ -3517,9 +3320,6 @@ mod tests { #[test] fn draw_frame_surface_fill_overwrites_unpainted_cells_with_surface_bg() { - // Buffer-wide invariant: pre-stain every cell, render, and assert no sentinel survives. - // The frame-area surface fill is the only widget that guarantees this for cells no - // other widget covers. let (mut app, _rx, _agent_tx) = test_app(None); let sentinel = Color::Rgb(254, 0, 254); let surface_bg = app.theme.surface().bg.expect("surface slot defines bg"); @@ -3540,317 +3340,12 @@ mod tests { let cell = buffer.cell((x, y)).expect("cell in bounds"); assert_eq!( cell.bg, surface_bg, - "cell ({x},{y}) kept the sentinel — surface fill regressed", + "cell ({x},{y}) kept the sentinel, surface fill regressed", ); } } } - // ── emit_status_hyperlinks ── - - #[test] - fn emit_status_hyperlinks_is_a_noop_when_no_links_pending() { - // Drives the early-return path in App::emit_status_hyperlinks. A regression that always - // touches the backend would wedge a stray cursor move into every frame. - - #[derive(Clone)] - struct SharedSink(Arc>>); - - impl std::io::Write for SharedSink { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - let (mut app, _rx, _agent_tx) = test_app(None); - let bytes = Arc::new(Mutex::new(Vec::::new())); - let backend = CrosstermBackend::new(SharedSink(bytes.clone())); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)), - }, - ) - .unwrap(); - // No render() means take_pending_hyperlinks returns empty; emit must short-circuit. - app.emit_status_hyperlinks(&mut terminal).unwrap(); - assert!( - bytes.lock().unwrap().is_empty(), - "no bytes written for a no-link frame", - ); - } - - // ── write_status_hyperlinks ── - - #[test] - fn write_status_hyperlinks_passes_envelope_through_unchanged_outside_tmux() { - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); - let mut buf: Vec = Vec::new(); - temp_env::with_var_unset("TMUX", || { - write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); - }); - let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); - assert_eq!( - buf, envelope, - "outside tmux the wire bytes match the raw envelope", - ); - } - - #[test] - fn write_status_hyperlinks_wraps_envelope_in_dcs_passthrough_inside_tmux() { - // tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer - // terminal; OSC 8 needs the DCS pass-through wrapper or it gets silently dropped. - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); - let mut buf: Vec = Vec::new(); - temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { - write_status_hyperlinks(&mut buf, &[link]).unwrap(); - }); - assert!( - buf.starts_with(b"\x1bPtmux;"), - "DCS opener brackets the envelope: {buf:?}", - ); - assert!( - buf.ends_with(b"\x1b\\"), - "ST closer terminates the DCS: {buf:?}", - ); - // Inner OSC 8 ESCs must be doubled so tmux strips its own DCS wrapper without consuming - // the inner sequence. - assert!( - buf.windows(8).any(|w| w == b"\x1b\x1b]8;;ht"), - "inner ESC before OSC 8 opener is doubled: {buf:?}", - ); - } - - #[test] - fn write_status_hyperlinks_treats_empty_tmux_env_as_outside_tmux() { - // Stale rc-file `export TMUX=` would otherwise route DCS pass-through bytes to a raw - // terminal that prints `Ptmux;...\\` literally on the status bar. - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); - let mut buf: Vec = Vec::new(); - temp_env::with_var("TMUX", Some(""), || { - write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); - }); - let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); - assert_eq!(buf, envelope, "empty $TMUX is treated as absent"); - } - - #[test] - fn write_status_hyperlinks_is_a_noop_when_no_links_pending() { - let mut buf: Vec = Vec::new(); - write_status_hyperlinks(&mut buf, &[]).unwrap(); - assert!(buf.is_empty(), "no bytes for an empty link list"); - } - - // ── build_status_hyperlink_envelope ── - - #[test] - fn build_status_hyperlink_envelope_emits_cup_then_osc8_per_link() { - // Pins the on-the-wire byte sequence: DECSC → CUP (1-based) → OSC 8 opener → replayed - // cells → OSC 8 closer (BEL terminator) → SGR reset → DECRC. Drift here breaks the - // PR-click affordance in xterm.js-based terminals (Cursor / VS Code) where the parser is - // least forgiving, or leaves the cursor parked next to `#NN` on the status bar. - let link = plain_hyperlink( - Rect::new(2, 0, 3, 1), - "https://example.com/pull/86", - &["#", "8", "6"], - ); - let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); - let output = String::from_utf8(bytes).unwrap(); - assert!( - output.starts_with("\x1b7"), - "DECSC parks the cursor before our writes: {output:?}", - ); - assert!( - output.ends_with("\x1b8"), - "DECRC restores the cursor after our writes: {output:?}", - ); - assert!( - output.contains("\x1b[1;3H"), - "CUP to row 1 col 3 (1-based): {output:?}", - ); - assert!( - output.contains("\x1b]8;;https://example.com/pull/86\x07"), - "OSC 8 opener with URL: {output:?}", - ); - let osc8_open_at = output.find("\x1b]8;;https").unwrap(); - let osc8_close_at = output.rfind("\x1b]8;;\x07").unwrap(); - let between = &output[osc8_open_at..osc8_close_at]; - assert!( - between.contains('#') && between.contains('8') && between.contains('6'), - "visible bytes #, 8, 6 sit between opener and closer: {between:?}", - ); - assert!( - output.contains("\x1b]8;;\x07\x1b[0m"), - "closer + SGR reset: {output:?}", - ); - } - - #[test] - fn build_status_hyperlink_envelope_strips_control_chars_from_url() { - let link = plain_hyperlink( - Rect::new(0, 0, 1, 1), - "https://x.com/\x1b\x07\x00ok", - &["x"], - ); - let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); - let output = String::from_utf8(bytes).unwrap(); - assert!( - output.contains("\x1b]8;;https://x.com/ok\x07"), - "ESC, BEL, NUL stripped from URL: {output:?}", - ); - } - - #[test] - fn build_status_hyperlink_envelope_is_empty_when_safe_url_is_empty() { - let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "\x1b\x07", &["x"]); - let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); - assert!( - bytes.is_empty(), - "no bytes (no DECSC either) when sanitized URL is empty", - ); - } - - #[test] - fn build_status_hyperlink_envelope_replays_styled_cell_attributes() { - // Pins every modifier branch in write_styled_symbol plus a few palette branches in - // ratatui_color_to_crossterm, since uncovered branches there silently lose styling on - // the post-flush replay. - let modifiers = Modifier::BOLD - | Modifier::DIM - | Modifier::ITALIC - | Modifier::UNDERLINED - | Modifier::REVERSED; - let link = StatusHyperlink { - rect: Rect::new(0, 0, 1, 1), - url: "https://x".to_owned(), - cells: vec![HyperlinkCell { - symbol: "X".to_owned(), - style: Style::default() - .fg(Color::Rgb(10, 20, 30)) - .bg(Color::Indexed(7)) - .add_modifier(modifiers), - }], - }; - let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); - let output = String::from_utf8(bytes).unwrap(); - // SGR 38;2;r;g;b is the truecolor fg; 48;5;n is the indexed bg. - assert!( - output.contains("\x1b[38;2;10;20;30m") && output.contains("\x1b[48;5;7m"), - "fg + bg SGR escapes: {output:?}", - ); - for (sgr, label) in [ - ("\x1b[1m", "bold"), - ("\x1b[2m", "dim"), - ("\x1b[3m", "italic"), - ("\x1b[4m", "underlined"), - ("\x1b[7m", "reverse"), - ] { - assert!( - output.contains(sgr), - "missing {label} SGR escape: {output:?}", - ); - } - } - - #[test] - fn build_status_hyperlink_envelope_resets_sgr_between_cells_with_different_modifiers() { - // Without an SGR reset per cell, modifier bits from a prior cell leak into the next - // (crossterm's `SetAttribute(Bold)` only sets, it doesn't clear). Pin the reset so a - // mixed-style link can't render the second cell with bold-from-the-first. - let link = StatusHyperlink { - rect: Rect::new(0, 0, 2, 1), - url: "https://x".to_owned(), - cells: vec![ - HyperlinkCell { - symbol: "A".to_owned(), - style: Style::default().add_modifier(Modifier::BOLD), - }, - HyperlinkCell { - symbol: "B".to_owned(), - style: Style::default().add_modifier(Modifier::ITALIC), - }, - ], - }; - let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); - let output = String::from_utf8(bytes).unwrap(); - let after_first_cell = output.split('A').nth(1).unwrap_or_default(); - assert!( - after_first_cell.starts_with("\x1b[0m"), - "second cell starts with SGR reset to drop the prior cell's modifiers: {output:?}", - ); - } - - // ── tmux_passthrough ── - - #[test] - fn tmux_passthrough_doubles_inner_escape_and_wraps_in_dcs() { - let inner = b"\x1b]8;;https://x\x07X\x1b]8;;\x07"; - let wrapped = tmux_passthrough(inner); - let expected = b"\x1bPtmux;\x1b\x1b]8;;https://x\x07X\x1b\x1b]8;;\x07\x1b\\"; - assert_eq!(&wrapped, expected); - } - - // ── ratatui_color_to_crossterm ── - - #[test] - fn ratatui_color_to_crossterm_round_trips_every_palette_variant() { - // Every color a theme can emit must replay correctly over OSC 8. A wrong branch silently - // loses styling on the post-flush hyperlink replay because `write_styled_symbol` queues - // whichever crossterm value this function returns. - use ratatui::style::Color as RC; - let cases = [ - (RC::Reset, CtColor::Reset), - (RC::Black, CtColor::Black), - (RC::Red, CtColor::DarkRed), - (RC::Green, CtColor::DarkGreen), - (RC::Yellow, CtColor::DarkYellow), - (RC::Blue, CtColor::DarkBlue), - (RC::Magenta, CtColor::DarkMagenta), - (RC::Cyan, CtColor::DarkCyan), - (RC::Gray, CtColor::Grey), - (RC::DarkGray, CtColor::DarkGrey), - (RC::LightRed, CtColor::Red), - (RC::LightGreen, CtColor::Green), - (RC::LightYellow, CtColor::Yellow), - (RC::LightBlue, CtColor::Blue), - (RC::LightMagenta, CtColor::Magenta), - (RC::LightCyan, CtColor::Cyan), - (RC::White, CtColor::White), - (RC::Indexed(33), CtColor::AnsiValue(33)), - (RC::Rgb(1, 2, 3), CtColor::Rgb { r: 1, g: 2, b: 3 }), - ]; - for (input, expected) in cases { - assert_eq!( - ratatui_color_to_crossterm(input), - expected, - "color {input:?}" - ); - } - } - - // ── jump_overlay_label ── - - #[test] - fn jump_overlay_label_idle_reads_jump_to_bottom() { - assert_eq!(jump_overlay_label(0, 60), "Jump to bottom (ctrl+End) ↓"); - } - - #[test] - fn jump_overlay_label_pluralizes_new_message_count() { - assert_eq!(jump_overlay_label(1, 60), "1 new message (ctrl+End) ↓"); - assert_eq!(jump_overlay_label(3, 60), "3 new messages (ctrl+End) ↓"); - } - - #[test] - fn jump_overlay_label_uses_short_form_below_full_width() { - assert_eq!(jump_overlay_label(3, 30), "↓ (ctrl+End)"); - } - // ── preview_height ── #[test] @@ -3875,9 +3370,6 @@ mod tests { #[test] fn render_preview_overflow_appends_more_count_row() { - // A queue larger than `PREVIEW_VISIBLE` collapses the tail - // into a single "+N more" hint so the panel never grows past - // the cap; the user keeps the most recent items in view. let (mut app, _rx, _agent_tx) = test_app(None); app.input.set_enabled(false); let extra = 3; @@ -3891,4 +3383,22 @@ mod tests { "overflow hint must show exact extra count: {text}", ); } + + // ── jump_overlay_label ── + + #[test] + fn jump_overlay_label_idle_reads_jump_to_bottom() { + assert_eq!(jump_overlay_label(0, 60), "Jump to bottom (ctrl+End) ↓"); + } + + #[test] + fn jump_overlay_label_pluralizes_new_message_count() { + assert_eq!(jump_overlay_label(1, 60), "1 new message (ctrl+End) ↓"); + assert_eq!(jump_overlay_label(3, 60), "3 new messages (ctrl+End) ↓"); + } + + #[test] + fn jump_overlay_label_uses_short_form_below_full_width() { + assert_eq!(jump_overlay_label(3, 30), "↓ (ctrl+End)"); + } } diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index f6700955..8aab2d29 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -510,35 +510,6 @@ impl ChatView { } } -// ── Test accessors ── - -#[cfg(test)] -impl ChatView { - pub(crate) fn scroll_offset_for_test(&self) -> u16 { - self.scroll_offset - } - - pub(crate) fn auto_scroll_for_test(&self) -> bool { - self.auto_scroll - } - - pub(crate) fn set_scroll_offset_for_test(&mut self, offset: u16) { - self.scroll_offset = offset; - } - - pub(crate) fn set_viewport_for_test(&mut self, height: u16) { - self.viewport_height = height; - } - - pub(crate) fn set_auto_scroll_for_test(&mut self, on: bool) { - self.auto_scroll = on; - } - - pub(crate) fn content_height_for_test(&self) -> &Cell { - &self.content_height - } -} - fn post_compact_display_messages(messages: &[Message]) -> Vec { let mut messages = messages.to_vec(); if let Some(first) = messages.first_mut() diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index e759275f..710ee7cb 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -11,14 +11,13 @@ use ratatui::style::Style; use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Paragraph}; +use self::line::{RenderedStatusLine, StatusLine, StatusLineState}; use crate::agent::event::UsageSnapshot; use crate::config::{Effort, StatusLineSegment}; use crate::tui::glyphs::SPINNER_FRAMES; use crate::tui::theme::Theme; use crate::util::git; -use self::line::{RenderedStatusLine, StatusLine, StatusLineState}; - const TICKS_PER_FRAME: usize = 5; /// How often the status bar re-probes git for the current branch. Branch changes outside the @@ -57,14 +56,14 @@ pub(crate) struct StatusBar { git_cwd: Option, git_branch: Option, pull_request: Option, - /// Skips the gh probe when the pull-request segment isn't configured. + /// Whether the configured line needs the `gh pr` probe. track_pull_request: bool, last_branch_probe: Option, last_pr_probe: Option, status: Status, spinner_frame: usize, tick_counter: usize, - /// Hyperlink segments captured during the most recent render, awaiting drain by the App. + /// Hyperlink cells captured during the most recent render. pending_hyperlinks: Vec, } @@ -360,7 +359,6 @@ mod tests { ) } - /// Bar configured with a `pull-request` segment so render captures a hyperlink rect. fn pr_bar() -> StatusBar { StatusBar::new( &Theme::default(), @@ -557,9 +555,6 @@ mod tests { #[test] fn tick_marks_dirty_when_git_branch_changes() { - // With no animated status and no minute change, the only path flipping dirty is the git - // probe surfacing a new branch. A future `refresh_git_branch` reordering could quietly - // drop the dirty bit and leave the rendered branch label stale until the next user input. let dir = tempfile::tempdir().unwrap(); let mut bar = test_bar(); bar.git_cwd = Some(dir.path().to_path_buf()); @@ -571,8 +566,6 @@ mod tests { #[test] fn tick_marks_dirty_when_pull_request_changes() { - // Pins the pull-request → dirty arm of `tick`. A regression that drops the PR-refresh - // result on the floor would leave the status bar stale for one whole probe interval. let dir = tempfile::tempdir().unwrap(); let mut bar = test_bar(); bar.git_cwd = Some(dir.path().to_path_buf()); @@ -654,9 +647,6 @@ mod tests { #[test] fn refresh_pull_request_marks_dirty_when_value_changes() { - // Mirrors `tick_marks_dirty_when_git_branch_changes` for the PR slot. The fail mode is - // the same: a refactor that only flips dirty when the probe yields `Some(_)` would leave - // the rendered `#NN` segment stale after the user closes the PR. let dir = tempfile::tempdir().unwrap(); let mut bar = test_bar(); bar.track_pull_request = true; @@ -668,9 +658,6 @@ mod tests { #[test] fn refresh_pull_request_arms_throttle_when_probe_returns_none() { - // Same throttle invariant as the git branch probe. A non-repo cwd (or a cwd where `gh` - // can't find a PR) returns None, but the stamp must still advance so we don't re-shell - // every tick. let dir = tempfile::tempdir().unwrap(); let mut bar = test_bar(); bar.track_pull_request = true; @@ -936,8 +923,6 @@ mod tests { #[test] fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { - // The OSC 8 envelope is emitted by the App after `terminal.draw()` flushes. The bar's - // job is to record the link rect + visible cells so the App can replay them. let mut bar = pr_bar(); bar.pull_request = Some(pr_state(86)); render_status(&mut bar, 40); @@ -949,7 +934,6 @@ mod tests { ); let link = &links[0]; assert_eq!(link.url, "https://github.com/o/r/pull/86"); - // Leading " " margin → `#86` lives at col 2, width 3. assert_eq!(link.rect.x, 2); assert_eq!(link.rect.width, 3); let visible: String = link.cells.iter().map(|c| c.symbol.as_str()).collect(); @@ -965,8 +949,6 @@ mod tests { #[test] fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { - // Narrow renders drop the PR segment via the fit pass, so the recorded hyperlinks list - // must mirror the rendered line and not surface a link rect that points at empty cells. let mut bar = pr_bar(); bar.pull_request = Some(pr_state(86)); render_status(&mut bar, 10); diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 07091213..28d900bd 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -177,7 +177,7 @@ enum SegmentStyle { struct RenderedSegment { segment: StatusLineSegment, span: Span<'static>, - /// URL to wrap the visible span in an OSC 8 hyperlink. Empty when the segment is plain text. + /// OSC 8 target for the visible span. hyperlink: Option, } @@ -230,8 +230,7 @@ fn lowest_priority_index(segments: &[RenderedSegment]) -> Option { .map(|(index, _)| index) } -/// Per-segment "drop me first when narrow" rank. Lower numbers drop earlier, so run state and -/// model sit at the top because the bar is useless without them. +/// Per-segment narrow-width rank. Lower numbers drop earlier. fn segment_utility(segment: StatusLineSegment) -> u8 { match segment { StatusLineSegment::ThreadTitle => 0, @@ -435,8 +434,6 @@ mod tests { full.contains("#86") && full.contains("main") && full.ends_with("Ready"), "wide width keeps every segment: {full}", ); - // Width 22 drops time (utility 1) before PR (2) and branch (3). Width 14 narrows further - // until only branch and run state remain. assert_eq!(render_text(segments.clone(), 22), " main │ #86 │ Ready"); assert_eq!(render_text(segments, 14), " main │ Ready"); } @@ -458,7 +455,6 @@ mod tests { }, 80, ); - // After the leading " " margin the `#86` segment lives at col 2, width 3. assert_eq!(rendered.hyperlinks.len(), 1); assert_eq!(rendered.hyperlinks[0].col, 2); assert_eq!(rendered.hyperlinks[0].width, 3); diff --git a/crates/oxide-code/src/tui/terminal.rs b/crates/oxide-code/src/tui/terminal.rs index efcf60dc..9ad25104 100644 --- a/crates/oxide-code/src/tui/terminal.rs +++ b/crates/oxide-code/src/tui/terminal.rs @@ -5,9 +5,14 @@ use std::io::{self, Stdout, Write}; use anyhow::Result; use crossterm::Command; +use crossterm::cursor::{RestorePosition, SavePosition}; use crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; +use crossterm::style::{ + Attribute as CtAttribute, Color as CtColor, Print, SetAttribute, SetBackgroundColor, + SetForegroundColor, +}; use crossterm::terminal::{ self, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; @@ -15,6 +20,8 @@ use crossterm::{execute, queue}; use ratatui::Terminal; use ratatui::prelude::CrosstermBackend; +use super::components::status::{HyperlinkCell, StatusHyperlink}; + pub(crate) type Tui = Terminal>; /// Enters raw mode + alt screen + alternate-scroll + Kitty keyboard. Caller must invoke [`restore`] @@ -122,6 +129,128 @@ pub(crate) fn draw_sync( Ok(()) } +/// Writes status-link OSC 8 envelopes, using tmux DCS pass-through when needed. +pub(crate) fn write_status_hyperlinks( + out: &mut W, + links: &[StatusHyperlink], +) -> io::Result<()> { + let envelope = build_status_hyperlink_envelope(links)?; + if envelope.is_empty() { + return Ok(()); + } + if running_inside_tmux() { + out.write_all(&tmux_passthrough(&envelope))?; + } else { + out.write_all(&envelope)?; + } + out.flush()?; + Ok(()) +} + +/// Builds the OSC 8 byte stream for the link batch. Empty when no link has a usable URL. +fn build_status_hyperlink_envelope(links: &[StatusHyperlink]) -> io::Result> { + use std::io::Write as _; + + let mut buf: Vec = Vec::new(); + let mut wrote_any = false; + for link in links { + let safe_url: String = link.url.chars().filter(|c| !c.is_control()).collect(); + if safe_url.is_empty() { + continue; + } + if !wrote_any { + queue!(&mut buf, SavePosition)?; + wrote_any = true; + } + let row = link.rect.y.saturating_add(1); + let col = link.rect.x.saturating_add(1); + write!(buf, "\x1b[{row};{col}H")?; + write!(buf, "\x1b]8;;{safe_url}\x07")?; + for cell in &link.cells { + write_styled_symbol(&mut buf, cell)?; + } + write!(buf, "\x1b]8;;\x07")?; + write!(buf, "\x1b[0m")?; + } + if wrote_any { + queue!(&mut buf, RestorePosition)?; + } + Ok(buf) +} + +/// Empty `$TMUX` is treated as absent to avoid writing DCS bytes to a raw terminal. +fn running_inside_tmux() -> bool { + std::env::var("TMUX").is_ok_and(|v| !v.is_empty()) +} + +/// Wraps an escape sequence in tmux's DCS pass-through so OSC 8 reaches the outer terminal. +fn tmux_passthrough(escape: &[u8]) -> Vec { + let mut out = Vec::with_capacity(escape.len() + 8); + out.extend_from_slice(b"\x1bPtmux;"); + for &b in escape { + if b == 0x1b { + out.push(0x1b); + } + out.push(b); + } + out.extend_from_slice(b"\x1b\\"); + out +} + +/// Replays one captured cell, resetting SGR first so modifiers cannot leak across cells. +fn write_styled_symbol(out: &mut W, cell: &HyperlinkCell) -> io::Result<()> { + queue!(out, SetAttribute(CtAttribute::Reset))?; + if let Some(fg) = cell.style.fg { + queue!(out, SetForegroundColor(ratatui_color_to_crossterm(fg)))?; + } + if let Some(bg) = cell.style.bg { + queue!(out, SetBackgroundColor(ratatui_color_to_crossterm(bg)))?; + } + let modifier = cell.style.add_modifier; + if modifier.contains(ratatui::style::Modifier::BOLD) { + queue!(out, SetAttribute(CtAttribute::Bold))?; + } + if modifier.contains(ratatui::style::Modifier::DIM) { + queue!(out, SetAttribute(CtAttribute::Dim))?; + } + if modifier.contains(ratatui::style::Modifier::ITALIC) { + queue!(out, SetAttribute(CtAttribute::Italic))?; + } + if modifier.contains(ratatui::style::Modifier::UNDERLINED) { + queue!(out, SetAttribute(CtAttribute::Underlined))?; + } + if modifier.contains(ratatui::style::Modifier::REVERSED) { + queue!(out, SetAttribute(CtAttribute::Reverse))?; + } + queue!(out, Print(&cell.symbol))?; + Ok(()) +} + +fn ratatui_color_to_crossterm(c: ratatui::style::Color) -> CtColor { + use ratatui::style::Color as RC; + match c { + RC::Reset => CtColor::Reset, + RC::Black => CtColor::Black, + RC::Red => CtColor::DarkRed, + RC::Green => CtColor::DarkGreen, + RC::Yellow => CtColor::DarkYellow, + RC::Blue => CtColor::DarkBlue, + RC::Magenta => CtColor::DarkMagenta, + RC::Cyan => CtColor::DarkCyan, + RC::Gray => CtColor::Grey, + RC::DarkGray => CtColor::DarkGrey, + RC::LightRed => CtColor::Red, + RC::LightGreen => CtColor::Green, + RC::LightYellow => CtColor::Yellow, + RC::LightBlue => CtColor::Blue, + RC::LightMagenta => CtColor::Magenta, + RC::LightCyan => CtColor::Cyan, + RC::White => CtColor::White, + RC::Indexed(i) => CtColor::AnsiValue(i), + RC::Rgb(r, g, b) => CtColor::Rgb { r, g, b }, + } +} + /// Installs a panic hook that restores the terminal before delegating to the previous hook. /// /// Without this, a panic inside the TUI loop would leave the terminal in raw mode + alternate @@ -139,9 +268,11 @@ pub(crate) fn install_panic_hook() { mod tests { use std::sync::{Arc, Mutex}; + use ratatui::style::{Modifier, Style}; use ratatui::{TerminalOptions, Viewport, layout::Rect}; use super::*; + use crate::tui::components::status::{HyperlinkCell, StatusHyperlink}; // ── enter_tui_mode ── @@ -229,6 +360,20 @@ mod tests { haystack.windows(needle.len()).position(|w| w == needle) } + fn plain_hyperlink(rect: Rect, url: &str, symbols: &[&str]) -> StatusHyperlink { + StatusHyperlink { + rect, + url: url.to_owned(), + cells: symbols + .iter() + .map(|s| HyperlinkCell { + symbol: (*s).to_owned(), + style: Style::default(), + }) + .collect(), + } + } + #[test] fn draw_sync_brackets_the_render_with_sync_update_bytes() { let buf = Arc::new(Mutex::new(Vec::new())); @@ -249,4 +394,229 @@ mod tests { let end = index_of(&bytes, END_SYNC).expect("EndSynchronizedUpdate emitted"); assert!(begin < end, "sync update must bracket the render"); } + + // ── write_status_hyperlinks ── + + #[test] + fn write_status_hyperlinks_passes_envelope_through_unchanged_outside_tmux() { + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var_unset("TMUX", || { + write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); + }); + let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); + assert_eq!( + buf, envelope, + "outside tmux the wire bytes match the raw envelope", + ); + } + + #[test] + fn write_status_hyperlinks_wraps_envelope_in_dcs_passthrough_inside_tmux() { + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var("TMUX", Some("/tmp/tmux-1000/default,1234,0"), || { + write_status_hyperlinks(&mut buf, &[link]).unwrap(); + }); + assert!( + buf.starts_with(b"\x1bPtmux;"), + "DCS opener brackets the envelope: {buf:?}", + ); + assert!( + buf.ends_with(b"\x1b\\"), + "ST closer terminates the DCS: {buf:?}", + ); + assert!( + buf.windows(8).any(|w| w == b"\x1b\x1b]8;;ht"), + "inner ESC before OSC 8 opener is doubled: {buf:?}", + ); + } + + #[test] + fn write_status_hyperlinks_treats_empty_tmux_env_as_outside_tmux() { + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "https://x", &["x"]); + let mut buf: Vec = Vec::new(); + temp_env::with_var("TMUX", Some(""), || { + write_status_hyperlinks(&mut buf, std::slice::from_ref(&link)).unwrap(); + }); + let envelope = build_status_hyperlink_envelope(std::slice::from_ref(&link)).unwrap(); + assert_eq!(buf, envelope, "empty $TMUX is treated as absent"); + } + + #[test] + fn write_status_hyperlinks_is_a_noop_when_no_links_pending() { + let mut buf: Vec = Vec::new(); + write_status_hyperlinks(&mut buf, &[]).unwrap(); + assert!(buf.is_empty(), "no bytes for an empty link list"); + } + + // ── build_status_hyperlink_envelope ── + + #[test] + fn build_status_hyperlink_envelope_emits_cup_then_osc8_per_link() { + let link = plain_hyperlink( + Rect::new(2, 0, 3, 1), + "https://example.com/pull/86", + &["#", "8", "6"], + ); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); + assert!( + output.starts_with("\x1b7"), + "DECSC parks the cursor before our writes: {output:?}", + ); + assert!( + output.ends_with("\x1b8"), + "DECRC restores the cursor after our writes: {output:?}", + ); + assert!( + output.contains("\x1b[1;3H"), + "CUP to row 1 col 3 (1-based): {output:?}", + ); + assert!( + output.contains("\x1b]8;;https://example.com/pull/86\x07"), + "OSC 8 opener with URL: {output:?}", + ); + let osc8_open_at = output.find("\x1b]8;;https").unwrap(); + let osc8_close_at = output.rfind("\x1b]8;;\x07").unwrap(); + let between = &output[osc8_open_at..osc8_close_at]; + assert!( + between.contains('#') && between.contains('8') && between.contains('6'), + "visible bytes #, 8, 6 sit between opener and closer: {between:?}", + ); + assert!( + output.contains("\x1b]8;;\x07\x1b[0m"), + "closer + SGR reset: {output:?}", + ); + } + + #[test] + fn build_status_hyperlink_envelope_strips_control_chars_from_url() { + let link = plain_hyperlink( + Rect::new(0, 0, 1, 1), + "https://x.com/\x1b\x07\x00ok", + &["x"], + ); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); + assert!( + output.contains("\x1b]8;;https://x.com/ok\x07"), + "ESC, BEL, NUL stripped from URL: {output:?}", + ); + } + + #[test] + fn build_status_hyperlink_envelope_replays_styled_cell_modifiers() { + let modifiers = Modifier::BOLD + | Modifier::DIM + | Modifier::ITALIC + | Modifier::UNDERLINED + | Modifier::REVERSED; + let link = StatusHyperlink { + rect: Rect::new(0, 0, 1, 1), + url: "https://x".to_owned(), + cells: vec![HyperlinkCell { + symbol: "X".to_owned(), + style: Style::default().add_modifier(modifiers), + }], + }; + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); + for (sgr, label) in [ + ("\x1b[1m", "bold"), + ("\x1b[2m", "dim"), + ("\x1b[3m", "italic"), + ("\x1b[4m", "underlined"), + ("\x1b[7m", "reverse"), + ] { + assert!( + output.contains(sgr), + "missing {label} SGR escape: {output:?}", + ); + } + assert!( + output.contains('X'), + "visible cell symbol replayed: {output:?}" + ); + } + + #[test] + fn build_status_hyperlink_envelope_resets_sgr_between_cells_with_different_modifiers() { + let link = StatusHyperlink { + rect: Rect::new(0, 0, 2, 1), + url: "https://x".to_owned(), + cells: vec![ + HyperlinkCell { + symbol: "A".to_owned(), + style: Style::default().add_modifier(Modifier::BOLD), + }, + HyperlinkCell { + symbol: "B".to_owned(), + style: Style::default().add_modifier(Modifier::ITALIC), + }, + ], + }; + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + let output = String::from_utf8(bytes).unwrap(); + let after_first_cell = output.split('A').nth(1).unwrap_or_default(); + assert!( + after_first_cell.starts_with("\x1b[0m"), + "second cell starts with SGR reset to drop the prior cell's modifiers: {output:?}", + ); + } + + #[test] + fn build_status_hyperlink_envelope_is_empty_when_safe_url_is_empty() { + let link = plain_hyperlink(Rect::new(0, 0, 1, 1), "\x1b\x07", &["x"]); + let bytes = build_status_hyperlink_envelope(&[link]).unwrap(); + assert!( + bytes.is_empty(), + "no bytes (no DECSC either) when sanitized URL is empty", + ); + } + + // ── tmux_passthrough ── + + #[test] + fn tmux_passthrough_doubles_inner_escape_and_wraps_in_dcs() { + let inner = b"\x1b]8;;https://x\x07X\x1b]8;;\x07"; + let wrapped = tmux_passthrough(inner); + let expected = b"\x1bPtmux;\x1b\x1b]8;;https://x\x07X\x1b\x1b]8;;\x07\x1b\\"; + assert_eq!(&wrapped, expected); + } + + // ── ratatui_color_to_crossterm ── + + #[test] + fn ratatui_color_to_crossterm_round_trips_every_palette_variant() { + use ratatui::style::Color as RC; + let cases = [ + (RC::Reset, CtColor::Reset), + (RC::Black, CtColor::Black), + (RC::Red, CtColor::DarkRed), + (RC::Green, CtColor::DarkGreen), + (RC::Yellow, CtColor::DarkYellow), + (RC::Blue, CtColor::DarkBlue), + (RC::Magenta, CtColor::DarkMagenta), + (RC::Cyan, CtColor::DarkCyan), + (RC::Gray, CtColor::Grey), + (RC::DarkGray, CtColor::DarkGrey), + (RC::LightRed, CtColor::Red), + (RC::LightGreen, CtColor::Green), + (RC::LightYellow, CtColor::Yellow), + (RC::LightBlue, CtColor::Blue), + (RC::LightMagenta, CtColor::Magenta), + (RC::LightCyan, CtColor::Cyan), + (RC::White, CtColor::White), + (RC::Indexed(33), CtColor::AnsiValue(33)), + (RC::Rgb(1, 2, 3), CtColor::Rgb { r: 1, g: 2, b: 3 }), + ]; + for (input, expected) in cases { + assert_eq!( + ratatui_color_to_crossterm(input), + expected, + "color {input:?}" + ); + } + } } diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index 5a963f33..8c907685 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -121,9 +121,12 @@ fn stderr_summary(stderr: &[u8]) -> String { #[cfg(test)] mod tests { + use std::path::PathBuf; + + #[cfg(unix)] use std::ffi::OsStr; + #[cfg(unix)] use std::os::unix::ffi::OsStrExt; - use std::path::PathBuf; use super::*; @@ -165,6 +168,7 @@ mod tests { } #[test] + #[cfg(unix)] fn current_branch_with_non_utf8_cwd_is_absent() { // Linux paths are bytes; embedding a non-UTF-8 byte hits the cwd_to_str failure branch // without ever spawning git. @@ -175,7 +179,7 @@ mod tests { // ── current_branch_str ── #[test] - fn current_branch_str_delegates_to_current_branch() { + fn current_branch_str_outside_a_repo_is_absent() { let dir = tempfile::tempdir().unwrap(); assert_eq!(current_branch_str(dir.path().to_str().unwrap()), None); } @@ -200,6 +204,7 @@ mod tests { } #[test] + #[cfg(unix)] fn current_pull_request_with_non_utf8_cwd_is_absent() { let cwd = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff")); assert_eq!(current_pull_request(&cwd), None); @@ -216,13 +221,16 @@ mod tests { url: "https://github.com/o/r/pull/86".to_owned(), }), ); + } + + #[test] + fn parse_pull_request_drops_invalid_payloads() { assert_eq!(parse_pull_request(b""), None); assert_eq!(parse_pull_request(b"not json"), None); assert_eq!(parse_pull_request(br#"{"number":86}"#), None); assert_eq!(parse_pull_request(br#"{"url":"https://x"}"#), None); assert_eq!(parse_pull_request(br#"{"number":86,"url":""}"#), None); assert_eq!(parse_pull_request(br#"{"number":-1,"url":"x"}"#), None); - // Non-string url field exercises the second `?` in `as_str()?.to_owned()`. assert_eq!(parse_pull_request(br#"{"number":86,"url":42}"#), None); } @@ -234,6 +242,7 @@ mod tests { } #[test] + #[cfg(unix)] fn cwd_to_str_is_absent_when_not_utf8() { let cwd = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff")); assert_eq!(cwd_to_str(&cwd, "p"), None); From 35d15c610fddbf612a6d1c44e2a198059f5738b9 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Tue, 19 May 2026 15:40:38 +0800 Subject: [PATCH 35/35] docs(tui): tighten mouse interaction prose 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. --- docs/design/tui/mouse-interactions.md | 33 ++++++++----------------- docs/guide/configuration.md | 6 ++--- docs/research/tui/mouse-interactions.md | 20 +++++++-------- docs/roadmap.md | 6 ++--- 4 files changed, 26 insertions(+), 39 deletions(-) diff --git a/docs/design/tui/mouse-interactions.md b/docs/design/tui/mouse-interactions.md index f3d2e528..4153f4a1 100644 --- a/docs/design/tui/mouse-interactions.md +++ b/docs/design/tui/mouse-interactions.md @@ -15,7 +15,7 @@ The cleanest way to deliver both is to let the terminal do the work. The TUI doe `enter_tui_mode` enables raw mode, the alternate screen, Kitty keyboard disambiguation, and DECSET 1007 (alternate-scroll). It does **not** enable `EnableMouseCapture`. Pairs unwind on `leave_tui_mode`. -`App::handle_mouse_event` only routes a left-click on the cached jump-to-bottom pill rect. Every other mouse event flows to `ChatView::handle_event` for wheel scroll. Wheel events arrive as keyboard arrow-key sequences via DECSET 1007 in real sessions, so the wheel-event path mostly exercises in tests. Both routes are kept for portability. +`App::handle_crossterm_event` still forwards any mouse event it receives to `ChatView::handle_event`, but real sessions rely on DECSET 1007 for wheel scroll because the TUI does not claim mouse capture. ## DECSET 1007 (alternate-scroll) @@ -25,21 +25,21 @@ Modern emulators (iTerm2, WezTerm, kitty, Alacritty, foot, Ghostty, Windows Term ## OSC 8 hyperlink on the PR status segment -The `pull-request` status segment renders `#NN` as plain spans. After `Paragraph::render` paints the line into the frame buffer, `StatusBar::render` records each `RenderedHyperlink` range — its absolute screen rect, URL, and a snapshot of the visible chars + styles in the link cells — into a `pending_hyperlinks` queue. `App::render` drains that queue after `terminal.draw()` flushes and replays each link as an OSC 8 envelope written directly to the crossterm backend. +The `pull-request` status segment renders `#NN` as plain spans. After the frame buffer is painted, `StatusBar::render` records each hyperlink rect, URL, chars, and style. `App::render` drains that queue after `terminal.draw()` flushes and replays OSC 8 directly to the crossterm backend. ```text \x1b[;H\x1b]8;;\x07\x1b]8;;\x07\x1b[0m ``` -The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies (multi-width handling), and stores that in `to_skip`. A URL like `https://github.com/o/r/pull/86` is plain ASCII, so `unicode-width` reports ~30 for the envelope. The diff would then skip ~30 trailing cells, dropping the rest of `#86` and shifting later text into the gap. Replaying the envelope after the flush bypasses the diff entirely. The just-painted cells stay plain, so the next frame's diff sees them as plain symbols of width 1. +The envelope must live **outside** the cell symbols. ratatui's `Buffer::diff` reads each cell symbol's `unicode-width` to decide how many trailing cells the cell occupies. A URL like `https://github.com/o/r/pull/86` makes that width look like roughly 30 cells, which drops the rest of `#86` and shifts later text. Post-flush replay keeps buffer cells plain, so the next diff still sees one-cell symbols. Three mechanics worth surfacing: -- **Out-of-band emission via the crossterm backend.** Storing the envelope in the cell symbol breaks ratatui's diff math, since `unicode-width` over-counts the URL bytes. After `terminal.draw()` returns, `App::emit_status_hyperlinks` writes DECSC (`\x1b7`) to park the cursor terminal-side, then for each captured link rect it positions the cursor with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays each cell's style + symbol via crossterm's `SetForegroundColor` / `SetBackgroundColor` / `SetAttribute` / `Print`, then writes the OSC 8 closer and an SGR reset. After the loop a single DECRC (`\x1b8`) restores the cursor `terminal.draw()` parked, so the user doesn't see a stray cursor next to `#NN`. DECSC / DECRC was chosen over `terminal.get_cursor_position()` because the latter issues a DSR query and reads from stdin, which races the TUI event loop and silently fails when the response gets consumed by the event reader. +- **Out-of-band emission via the crossterm backend.** `App::emit_status_hyperlinks` writes DECSC (`\x1b7`), positions each link with CUP (`\x1b[;H`, 1-based), writes the OSC 8 opener, replays captured cells, closes OSC 8, resets SGR, then restores with DECRC (`\x1b8`). DECSC / DECRC avoids the stdin race from `terminal.get_cursor_position()`'s DSR query. - **BEL (`\x07`) terminator over ST (`\x1b\\`).** Some xterm.js-based terminals (VS Code's and Cursor's integrated terminals) misparse self-contained per-cell ST closers, leaking visible bytes into the next cells of the line. BEL is one byte and every modern emulator parses it identically. -- **DCS pass-through inside tmux.** tmux only forwards a small whitelist of OSC numbers (52, 4, 7, 9, 12) to the outer terminal; OSC 8 isn't on it and gets silently dropped. When the process runs inside tmux (`$TMUX` set and non-empty, so a stale rc-file export doesn't trigger the wrap), `write_status_hyperlinks` wraps the entire envelope (DECSC + per-link CUP / OSC 8 / replayed cells + DECRC) in `\x1bPtmux;...\x1b\\` with every inner ESC doubled. tmux strips the DCS and forwards the inner bytes verbatim to the outer terminal. Without this wrap, ctrl+click on `#NN` does nothing inside tmux even though OSC 8 works fine in the same terminal outside tmux. tmux 3.3+ requires `set -g allow-passthrough on`; without it the wrap is silently dropped at the tmux layer. +- **DCS pass-through inside tmux.** tmux does not forward OSC 8 by default. When `$TMUX` is set and non-empty, `write_status_hyperlinks` wraps the envelope in `\x1bPtmux;...\x1b\\` with every inner ESC doubled. tmux 3.3+ also requires `set -g allow-passthrough on`. -Modern terminals (iTerm2, WezTerm, kitty, Alacritty, foot, Konsole, Ghostty, recent Windows Terminal, GNOME Terminal, VS Code's terminal, Cursor's terminal) make the segment Ctrl-clickable (Cmd-click on macOS in some terminals) and open the URL via the user's browser. Older terminals print the raw bytes literally. The visible `#NN` still reads correctly because BEL is non-printable. +Terminals with OSC 8 support make the segment Ctrl-clickable (Cmd-click on macOS in some terminals). Terminals that ignore OSC control strings leave the visible `#NN` intact. Terminals that print raw OSC bytes may show escape text, which is the legacy fallback. URLs are sanitized: every control char is filtered out before the envelope is built, so a malformed value can't break out of the OSC 8 sequence. @@ -56,19 +56,11 @@ This means we don't need: - Per-terminal escape hatches (Option+drag, Shift+drag, etc.). The terminal's normal drag is the primary path. - `set -g set-clipboard on` in tmux (the user's tmux selection model is whatever the user already configured). -## Implementation files - -- `crates/oxide-code/src/tui/terminal.rs` — `enter_tui_mode` / `leave_tui_mode` write the alt-screen + alternate-scroll + Kitty keyboard sequences. -- `crates/oxide-code/src/tui/app.rs` — `handle_mouse_event` routes the jump-pill click and forwards everything else to chat. `emit_status_hyperlinks` drains the bar's pending link list after `terminal.draw()` flushes, and `write_status_hyperlinks` emits the CUP + OSC 8 envelope + replayed cells + SGR reset to any `Write`. -- `crates/oxide-code/src/tui/components/status.rs` — `StatusBar::render` paints the line with plain cell symbols, then snapshots each link rect's visible chars + styles into `pending_hyperlinks` for `App::emit_status_hyperlinks` to drain post-flush. -- `crates/oxide-code/src/tui/components/status/line.rs` — `StatusLine::render` returns the cell-column ranges of every hyperlinked segment alongside the line. -- `crates/oxide-code/src/util/git.rs` — `current_pull_request` returns `Option` parsed from `gh pr view --json number,url`, so the status bar has the URL ready when the PR refresh fires. - ## Out of scope -- Click-to-expand on tool-result blocks (would require capturing mouse). +- Click-to-expand on tool-result blocks. - OSC 8 hyperlinks inside markdown body text (would require threading URLs through the markdown renderer). -- App-driven copy-on-select with OSC 52 / arboard fallback (rejected: native terminal selection covers it). +- App-driven copy-on-select with OSC 52 / arboard fallback. Native terminal selection covers the current need. ## Verification @@ -76,15 +68,10 @@ Manual verification across terminals: 1. Start `ox` and generate enough chat content to scroll. 2. Page up. Confirm the jump-to-bottom pill appears. -3. Click the pill. Confirm chat snaps to bottom and re-arms auto-scroll. +3. Press Ctrl+End. Confirm chat snaps to bottom and re-arms auto-scroll. 4. Drag-select a chat region. Confirm the highlight uses the terminal's native selection style. Mouse up. Paste somewhere external. Confirm bytes round-trip. 5. With a `pull-request` status segment configured, Ctrl-click (Cmd-click on iTerm2 / Terminal.app) on `#NN`. Confirm the browser opens to the PR URL. 6. Wheel scroll. Confirm chat scrolls (DECSET 1007 in a supporting terminal). 7. Quit. Confirm alt-screen restored. -Automated tests: - -- `tui::terminal::tests::enter_tui_mode_writes_setup_sequences`, `enter_tui_mode_does_not_enable_mouse_capture`, `leave_tui_mode_writes_restore_sequences` — pin the DECSET 1007 enable / disable and the absence of `EnableMouseCapture`. -- `tui::app::tests::left_click_on_jump_overlay_jumps_chat_to_bottom`, `left_click_outside_jump_overlay_does_not_jump_chat`, `wheel_scroll_event_routes_to_chat_view` — pin the mouse-routing surface. -- `tui::app::tests::write_status_hyperlinks_emits_cup_then_envelope_per_link`, `write_status_hyperlinks_strips_control_chars_from_url`, `write_status_hyperlinks_skips_link_with_empty_safe_url` — pin the on-the-wire byte sequence (CUP + OSC 8 opener + replayed cells + closer + SGR reset). -- `tui::components::status::tests::render_records_pull_request_hyperlink_rect_for_post_flush_emission`, `render_records_no_hyperlinks_when_pull_request_absent`, `render_drops_hyperlink_when_segment_clipped_to_zero_width`, `pull_request_segment_reports_hyperlink_range_for_post_render_marking`, `pull_request_segment_reports_no_hyperlink_when_absent` — pin what the bar surfaces to the App. +Automated coverage pins DECSET 1007 enter / leave bytes, no `EnableMouseCapture`, OSC 8 replay bytes, URL sanitization, tmux wrapping, and status-bar hyperlink capture. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2fd208fa..47e96bc0 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -13,7 +13,7 @@ All fields are optional. Only specify the values you want to override. ```toml # ~/.config/ox/config.toml (user-wide) -# or ox.toml in your project root (per-project, except api_key/base_url) +# or ox.toml in your project root (per-project, except api_key / base_url) [client] model = "claude-sonnet-4-6" @@ -137,13 +137,13 @@ On Opus 4.7, `show_thinking = true` opts the request into `thinking.display = "s `show_welcome = false` blanks the chat region until you submit a prompt, which is useful when piping or screen-recording. -`status_line` accepts these segment names: `current-dir`, `git-branch`, `pull-request`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. The list must contain at least one segment. Segments with no data, such as a missing git branch, an unopened pull request, or usage before the first completed turn, are omitted. The `pull-request` segment shells out to `gh pr view` so it requires the GitHub CLI on PATH. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. +`status_line` accepts these segment names: `current-dir`, `git-branch`, `pull-request`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. The list must contain at least one segment. Segments with no data, such as a missing git branch, an unopened pull request, or usage before the first completed turn, are omitted. The `pull-request` segment shells out to `gh pr view` so it requires the GitHub CLI on PATH. In supporting terminals, Ctrl-click or Cmd-click the segment to open the PR URL. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. | Segment | Renders | Refresh | | ------------------- | ---------------------------------------------- | --------------- | | `current-dir` | Tildified working directory | At startup | | `git-branch` | Current branch (omitted on detached HEAD) | Every 5 s | -| `pull-request` | Open PR for the branch as `#86` | Every 60 s | +| `pull-request` | Open PR as clickable `#86` when OSC 8 works | Every 60 s | | `model` | Compact model label (e.g., `Opus 4.7`) | On `/model` | | `model-with-effort` | Model label plus the effort tier in parens | On `/model` | | `context-used` | `Ctx: 50% (100k/200k)` after the first turn | Per turn | diff --git a/docs/research/tui/mouse-interactions.md b/docs/research/tui/mouse-interactions.md index 02c99d85..8cc9528e 100644 --- a/docs/research/tui/mouse-interactions.md +++ b/docs/research/tui/mouse-interactions.md @@ -6,7 +6,7 @@ Research on mouse handling in terminal AI CLIs: capture defaults, click affordan The most polished mouse layer of the three peers. Claude Code's `src/utils/fullscreen.ts` reads `CLAUDE_CODE_DISABLE_MOUSE` to gate the entire mouse pipeline, with a separate `CLAUDE_CODE_DISABLE_MOUSE_CLICKS` env var that lets wheel work while blocking click events. -The mode bundle enabled by `src/ink/termio/dec.ts` is `?1000h`, `?1002h`, `?1003h`, `?1006h` — same as crossterm's `EnableMouseCapture`. Disabled via the matching `...l` set on suspend / exit. +The mode bundle enabled by `src/ink/termio/dec.ts` is `?1000h`, `?1002h`, `?1003h`, `?1006h`, the same shape as crossterm's `EnableMouseCapture`. Disabled via the matching `...l` set on suspend or exit. Hit-testing lives in `src/ink/hit-test.ts`. Each render builds a Yoga DOM with rect-per-node, then `dispatchClick` bubbles from the deepest hit up the parent chain until `stopImmediatePropagation()`. Clickable elements include the jump-to-bottom pill (`FullscreenLayout.tsx:491`), expand / collapse on message rows (`VirtualMessageList.tsx:225`), background-task agent pills (`BackgroundTaskStatus.tsx:155`), and OSC 8 hyperlinks via ``. @@ -72,11 +72,11 @@ The clean workaround: emit the OSC 8 envelope **out-of-band**, after `terminal.d `crossterm::EnableMouseCapture` writes five DECSETs: -- `?1000h` — X10/normal tracking (button press / release). -- `?1002h` — button-event tracking (adds drag while button held). -- `?1003h` — any-event tracking (adds motion without button). -- `?1006h` — SGR encoding (`\x1b[ 223). -- `?1015h` — URXVT encoding (legacy fallback). +- `?1000h`: X10 / normal tracking (button press / release). +- `?1002h`: button-event tracking (adds drag while button held). +- `?1003h`: any-event tracking (adds motion without button). +- `?1006h`: SGR encoding (`\x1b[ 223). +- `?1015h`: URXVT encoding (legacy fallback). Some terminals skip `?1003` for performance. SGR (`?1006`) is the only encoding modern crossterm reads, but the others are needed for older terminals that don't speak SGR. There's no portable terminal primitive that delivers wheel only without click / drag, so claiming wheel implies claiming the rest. @@ -88,11 +88,11 @@ The author's tmux config enables tmux mouse mode, vi copy-mode bindings, and `y` Two features are worth supporting: clicking the PR number to open it in the browser, and drag-selecting chat content to copy. The `EnableMouseCapture` + OSC 52 approach trades native drag-select for an app-side reimplementation that fights the terminal in every layer above (tmux behavior, OSC 52 acceptance, in-app highlight, payload caps). The author's terminals (Cursor's and VS Code's xterm.js, plus tmux without `set-clipboard on`) make that trade unfavorable. -The shipped design defers selection entirely to the terminal: +For oxide-code, selection should stay terminal-owned: - **No mouse capture.** `enter_tui_mode` skips `EnableMouseCapture` so the terminal sees every drag itself. The terminal's native selection layer renders the highlight, decides what gets copied, and writes to the OS clipboard the way the user already expects. - **DECSET 1007 (alternate-scroll).** Wheel events arrive as arrow-key sequences in the alt-screen, so chat still scrolls without claiming the mouse. iTerm2, WezTerm, kitty, Alacritty, foot, Ghostty, Windows Terminal, recent GNOME Terminal, Konsole, and xterm.js-based terminals (VS Code, Cursor) implement 1007. Older terminals fall back to keyboard scroll. -- **OSC 8 on the PR segment, emitted out-of-band after `terminal.draw()` flushes.** Capturing the link rect + visible cells at render time and writing the envelope directly to the crossterm backend avoids both ratatui traps: `Buffer::set_string` no longer filters our control chars (we never go through it), and `Buffer::diff`'s `to_skip` math no longer reads a 30-byte URL out of a single cell symbol and drops the trailing characters. BEL (`\x07`) terminator, not ST (`\x1b\\`), because xterm.js misparses the self-contained per-cell ST closers and leaks visible bytes into adjacent cells. -- **No app-side selection.** The `Selection` state machine, OSC 52 encoder, tmux DCS pass-through, `selection` theme slot, and `arboard` fallback all become unnecessary. +- **OSC 8 on the PR segment, emitted out-of-band after `terminal.draw()` flushes.** Capturing the link rect + visible cells at render time and writing the envelope directly to the crossterm backend avoids both ratatui traps: `Buffer::set_string` no longer filters our control chars, and `Buffer::diff` no longer sees a 30-byte URL as one cell symbol. BEL (`\x07`) avoids xterm.js ST parsing leaks in self-contained per-cell closers. +- **No app-side selection.** The `Selection` state machine, OSC 52 encoder, `selection` theme slot, and `arboard` fallback all become unnecessary. -The remaining trade-off is the same one Codex makes: app-side click affordances beyond the jump-pill are out of reach, because there are no mouse events to route. That's the right trade until concrete demand for click-to-expand or similar arrives. +The remaining trade-off is the same one Codex makes: app-side click affordances are out of reach because there are no mouse events to route. That's the right trade until concrete demand for click-to-expand or similar arrives. diff --git a/docs/roadmap.md b/docs/roadmap.md index 220c6908..f061ed4a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,7 +58,7 @@ The direction is simple: ### Slash Commands -- Built-in commands cover session control, config/status, model and theme changes, diffs, compaction, and help. See the [user guide](guide/slash-commands.md). +- Built-in commands cover session control, config / status, model and theme changes, diffs, compaction, and help. See the [user guide](guide/slash-commands.md). - Autocomplete, typed shortcuts, and modal pickers keep common actions quick. - Destructive session actions require confirmation. @@ -84,7 +84,7 @@ The direction is simple: Remaining surface beyond Working Today: -- Login/logout, custom commands, and a guided `/init` flow. +- Login / logout, custom commands, and a guided `/init` flow. Persistence stance: session commands should feel reversible. Cross-session writes will require an explicit user action. @@ -117,7 +117,7 @@ Persistence stance: session commands should feel reversible. Cross-session write ### Status Line Extensions -- Additional segments for queue state, session identity, theme, account-limit usage, pull requests, and task progress. +- Additional segments for queue state, session identity, theme, account-limit usage, richer PR metadata / actions, and task progress. ## Not the Goal Right Now