diff --git a/.cspell/words.txt b/.cspell/words.txt index 1e28132f..e744dff2 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -6,6 +6,7 @@ anthropic anthropics anyhow APFS +arboard atuin cachain catppuccin @@ -17,6 +18,8 @@ codex CREAT creds crossterm +DECRC +DECSC dedupe deque deserialize @@ -32,6 +35,7 @@ ESRCH feff frappe getpwuid +Ghostty gitui hakula impls @@ -40,9 +44,11 @@ insta isatty killpg Kobalte +Konsole latte macchiato misparse +misparses MMIX mplan mpsc @@ -63,9 +69,11 @@ pgid pkgs pname println +Ptmux pulldown RAII ratatui +rects replacen reqwest reserialized @@ -84,6 +92,7 @@ sysname SYSPROMPT tengu Tera +termio thiserror throbber tildified @@ -99,10 +108,12 @@ unresumable unrotated untap urandom +URXVT usize venv vtable writeln xhigh +Xresources xxhash yazi diff --git a/CLAUDE.md b/CLAUDE.md index bf5ecd4c..04478dff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ ox # Start an interactive session │ │ └── searchable_list.rs # Generic SearchableList: substring filter + scrollable viewport for searchable pickers │ ├── pending_calls.rs # Tool-call correlation state for streaming and transcript resume │ ├── 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/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index b31627af..6ff1ce9f 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -24,7 +24,7 @@ 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}; @@ -206,7 +206,7 @@ impl App { self.chat.handle_event(event); } } - Event::Mouse(..) => { + Event::Mouse(_) => { self.chat.handle_event(event); } Event::Resize(..) => {} @@ -662,6 +662,20 @@ impl App { self.draw_frame(frame); })?; } + self.emit_status_hyperlinks(terminal)?; + Ok(()) + } + + /// Replays captured status links as OSC 8 envelopes after the frame flush. + 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(()) } @@ -828,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)."), @@ -843,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(), @@ -871,12 +885,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; use ratatui::{Terminal, TerminalOptions, Viewport}; use tokio::sync::mpsc; @@ -1265,15 +1280,20 @@ mod tests { } #[test] - fn handle_crossterm_mouse_is_forwarded_to_chat() { - // Mouse events reach `ChatView::handle_event` for scroll; the dirty flag must flip. + 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); } @@ -2202,6 +2222,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 @@ -2340,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)." ); } @@ -3100,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 { @@ -3115,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] @@ -3245,11 +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. - 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"); @@ -3270,30 +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", ); } } } - // ── 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] @@ -3318,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; @@ -3334,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 bf2bd1db..8aab2d29 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -372,10 +372,7 @@ impl ChatView { code: KeyCode::End, modifiers: KeyModifiers::CONTROL, .. - }) => { - self.scroll_to_bottom(); - self.auto_scroll = true; - } + }) => self.jump_to_bottom(), _ => {} } } @@ -389,6 +386,12 @@ impl ChatView { .scroll((self.scroll_offset, 0)); frame.render_widget(paragraph, area); } + + /// 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; + } } // ── Private Helpers ── 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/status.rs b/crates/oxide-code/src/tui/components/status.rs index f57bafb4..710ee7cb 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -7,17 +7,17 @@ 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}; +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::{StatusLine, StatusLineState}; - const TICKS_PER_FRAME: usize = 5; /// How often the status bar re-probes git for the current branch. Branch changes outside the @@ -28,6 +28,20 @@ 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 status-bar segment that should be re-emitted as an OSC 8 hyperlink. +#[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: Style, +} + /// Status bar at the top of the TUI. pub(crate) struct StatusBar { theme: Theme, @@ -41,15 +55,16 @@ pub(crate) struct StatusBar { /// `None` collapses every git probe to a no-op. 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. + pull_request: Option, + /// 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 cells captured during the most recent render. + pending_hyperlinks: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -94,6 +109,7 @@ impl StatusBar { status: Status::Idle, spinner_frame: 0, tick_counter: 0, + pending_hyperlinks: Vec::new(), } } @@ -228,15 +244,46 @@ 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()) .style(self.theme.surface()); - frame.render_widget( - Paragraph::new(self.render_line(area.width)).block(block), - area, - ); + let rendered = self.render_line(area.width); + let content_y = area.y; + let content_x = area.x; + 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); + let width = link.width.min(max_width); + if width == 0 { + continue; + } + hyperlink_rects.push((Rect::new(link_x, content_y, width, 1), link.url.clone())); + } + frame.render_widget(Paragraph::new(rendered.line).block(block), area); + // 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)); + 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 }); + } + } + + /// 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) } } @@ -263,7 +310,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 { @@ -273,7 +320,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, @@ -312,6 +359,25 @@ mod tests { ) } + 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, + url: format!("https://github.com/o/r/pull/{number}"), + } + } + // ── set_title ── #[test] @@ -489,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()); @@ -501,6 +564,18 @@ mod tests { assert_eq!(bar.git_branch, None); } + #[test] + fn tick_marks_dirty_when_pull_request_changes() { + 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(pr_state(999)); + bar.last_pr_probe = None; + assert!(bar.tick()); + assert_eq!(bar.pull_request, None); + } + // ── refresh_git_branch ── #[test] @@ -533,6 +608,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] @@ -552,11 +645,19 @@ mod tests { assert!(bar.last_pr_probe.is_none(), "must skip without cwd"); } + #[test] + fn refresh_pull_request_marks_dirty_when_value_changes() { + 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(pr_state(1)); + 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` - // 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; @@ -819,4 +920,52 @@ mod tests { "no tildified path should appear: {output:?}", ); } + + #[test] + fn render_records_pull_request_hyperlink_rect_for_post_flush_emission() { + let mut bar = pr_bar(); + bar.pull_request = Some(pr_state(86)); + 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"); + 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(); + render_status(&mut bar, 40); + assert!(bar.take_pending_hyperlinks().is_empty()); + } + + #[test] + fn render_drops_hyperlink_when_segment_clipped_to_zero_width() { + let mut bar = pr_bar(); + bar.pull_request = Some(pr_state(86)); + 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 = pr_bar(); + bar.pull_request = Some(pr_state(86)); + 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 dd82c75f..28d900bd 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -5,12 +5,17 @@ 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; 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; +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)] @@ -18,6 +23,21 @@ pub(super) struct StatusLine { segments: Vec, } +/// 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, +} + +/// Cell range of a hyperlinked segment, measured from the start of the line. +#[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,16 +58,33 @@ impl StatusLine { .collect::>(); fit_segments(&mut rendered, usize::from(width), sep_width); - let mut spans = vec![Span::raw(" ")]; - let mut first = true; - for segment in rendered { - if !first { + // Leading margin lines up content with the chat block underneath. + 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); + for (index, segment) in rendered.iter().enumerate() { + if index > 0 { spans.push(sep.clone()); + col = col.saturating_add(sep_w); + } + 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(), + }); } - spans.push(segment.span); - 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( @@ -55,6 +92,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), @@ -66,9 +104,10 @@ impl StatusLine { Self::segment_style(theme, SegmentStyle::Accent), ) }), - StatusLineSegment::PullRequest => state.pull_request.map(|number| { + StatusLineSegment::PullRequest => state.pull_request.map(|pr| { + hyperlink = Some(pr.url.clone()); Span::styled( - format!("#{number}"), + format!("#{}", pr.number), Self::segment_style(theme, SegmentStyle::Accent), ) }), @@ -100,7 +139,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 { @@ -121,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, + pub(super) pull_request: Option<&'a PullRequest>, /// Pre-rendered run-state segment from the parent component. pub(super) status_span: Span<'static>, } @@ -138,11 +177,17 @@ enum SegmentStyle { struct RenderedSegment { segment: StatusLineSegment, span: Span<'static>, + /// OSC 8 target for the visible span. + 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 { @@ -185,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, @@ -263,7 +307,11 @@ mod tests { use super::*; fn render_text(segments: Vec, width: u16) -> String { - let line = StatusLine::new(segments).render( + let pr = PullRequest { + number: 86, + url: "https://github.com/o/r/pull/86".to_owned(), + }; + let rendered = StatusLine::new(segments).render( &Theme::default(), &StatusLineState { model: "m", @@ -276,12 +324,14 @@ mod tests { }), cwd: "~/repo", git_branch: Some("main"), - pull_request: Some(86), + pull_request: Some(&pr), status_span: Span::raw("Ready"), }, width, ); - line.spans + rendered + .line + .spans .into_iter() .map(|span| span.content) .collect::() @@ -297,6 +347,13 @@ mod tests { && bytes[4].is_ascii_digit() } + fn pr_state(number: u64) -> PullRequest { + PullRequest { + number, + url: format!("https://github.com/o/r/pull/{number}"), + } + } + // ── StatusLine::render ── #[test] @@ -307,7 +364,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", @@ -321,7 +378,8 @@ mod tests { }, 12, ); - let text = line + let text = rendered + .line .spans .into_iter() .map(|span| span.content) @@ -376,12 +434,59 @@ 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. assert_eq!(render_text(segments.clone(), 22), " main │ #86 │ Ready"); assert_eq!(render_text(segments, 14), " main │ Ready"); } + #[test] + fn render_pull_request_reports_hyperlink_range() { + let pr = pr_state(86); + 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, + ); + 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 render_pull_request_reports_no_hyperlink_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 + .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"); + } + // ── context_label ── #[test] 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; diff --git a/crates/oxide-code/src/tui/terminal.rs b/crates/oxide-code/src/tui/terminal.rs index 2956df41..9ad25104 100644 --- a/crates/oxide-code/src/tui/terminal.rs +++ b/crates/oxide-code/src/tui/terminal.rs @@ -1,11 +1,17 @@ //! Terminal initialization, restore, and panic hook. +use std::fmt; use std::io::{self, Stdout, Write}; use anyhow::Result; +use crossterm::Command; +use crossterm::cursor::{RestorePosition, SavePosition}; use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + 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, @@ -14,10 +20,12 @@ 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 + 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 +40,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 +61,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 +69,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. @@ -75,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 @@ -92,14 +268,18 @@ 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 ── 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 +293,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 +331,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 ── @@ -157,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())); @@ -177,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 ab6edb10..8c907685 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -1,44 +1,27 @@ //! 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; +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", cwd_str, || { + Command::new("git") + .args([ + "-C", + cwd_str, + "--no-optional-locks", + "branch", + "--show-current", + ]) + .output() + })?; parse_branch(&output.stdout) } @@ -56,43 +39,74 @@ 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 { - let Some(cwd_str) = cwd.to_str() else { - debug!(cwd = ?cwd, "gh pr probe: cwd is not valid UTF-8"); +/// 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 cwd_str = cwd_to_str(cwd, "gh pr")?; + let output = run_probe("gh pr", cwd_str, || { + 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", "--jq", ".number"]) - .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`. `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!(cwd = cwd_str, error = %e, "gh pr probe: spawn failed"); + debug!(error = %e, cwd = %cwd, "{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", + cwd = %cwd, + "{probe} probe: non-zero exit", ); return None; } - parse_pr_number(&output.stdout) + Some(output) } -fn parse_pr_number(stdout: &[u8]) -> Option { - std::str::from_utf8(stdout).ok()?.trim().parse().ok() -} - -/// 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); @@ -107,6 +121,13 @@ 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 super::*; // ── current_branch ── @@ -146,6 +167,23 @@ mod tests { assert_eq!(current_branch(dir.path()), None); } + #[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. + let cwd = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff")); + assert_eq!(current_branch(&cwd), None); + } + + // ── current_branch_str ── + + #[test] + 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); + } + // ── parse_branch ── #[test] @@ -165,16 +203,97 @@ mod tests { assert_eq!(current_pull_request(dir.path()), None); } - // ── parse_pr_number ── + #[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); + } + + // ── parse_pull_request ── + + #[test] + 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(), + }), + ); + } + + #[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); + assert_eq!(parse_pull_request(br#"{"number":86,"url":42}"#), None); + } + + // ── cwd_to_str ── #[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 cwd_to_str_returns_path_when_utf8() { + assert_eq!(cwd_to_str(Path::new("/tmp/a"), "p"), Some("/tmp/a")); + } + + #[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); + } + + // ── run_probe ── + + #[test] + fn run_probe_returns_output_on_success() { + let output = run_probe("test", "/tmp/cwd", || { + 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", "/tmp/cwd", || { + 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", "/tmp/cwd", || { + 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 ── @@ -191,4 +310,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"), ""); + } } 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 ── diff --git a/docs/design/README.md b/docs/design/README.md index 99e7cd51..6d1bdaa9 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -34,9 +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 | -| [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 new file mode 100644 index 00000000..4153f4a1 --- /dev/null +++ b/docs/design/tui/mouse-interactions.md @@ -0,0 +1,77 @@ +# Mouse Interactions + +Design policy for mouse behavior in the TUI. + +## Goal + +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). + +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. + +## Decision + +`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_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) + +`\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. + +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`). + +## OSC 8 hyperlink on the PR status segment + +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. 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.** `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 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`. + +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. + +## Native drag-select-and-copy + +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). + +This means we don't need: + +- 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, 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). + +## Out of scope + +- 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. Native terminal selection covers the current need. + +## 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. 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 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/README.md b/docs/research/README.md index 228b95e8..bedece31 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -42,9 +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 | -| [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 new file mode 100644 index 00000000..8cc9528e --- /dev/null +++ b/docs/research/tui/mouse-interactions.md @@ -0,0 +1,98 @@ +# 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`, 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 ``. + +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, 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). + +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, 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, 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, since 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, 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]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, 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. + +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. + +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 + +`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 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 + +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. + +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, 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 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