Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a0fcd50
feat(util): probe PR number and URL together for the status segment
hakula139 May 15, 2026
a210cbe
feat(tui): route left-click on the jump overlay to scroll-to-bottom
hakula139 May 15, 2026
c5fe7e8
feat(tui): render PR status segment as an OSC 8 hyperlink
hakula139 May 15, 2026
982ea58
feat(tui): add selection theme slot for mouse-drag highlight
hakula139 May 15, 2026
2dd5b2a
feat(tui): copy-on-select with OSC 52 over chat
hakula139 May 15, 2026
f38dfbd
docs(tui): design and research notes for mouse interactions
hakula139 May 15, 2026
a8ffbd0
fix(tui): preserve combining marks and sanitize OSC 8 URL
hakula139 May 15, 2026
6d03159
refactor(tui): drop interior mutability and reuse Rect::contains
hakula139 May 15, 2026
e23f50c
refactor(tui): tighten selection visibility and regroup chat accessors
hakula139 May 15, 2026
3bbe443
refactor(tui): align test section dividers with production fns
hakula139 May 15, 2026
3994352
test(tui): plumb writable sink and cover OSC 52 boundary cases
hakula139 May 15, 2026
5b70dec
style(tui): tighten code comments per project prose rules
hakula139 May 15, 2026
a395a6e
docs(tui): clean prose in mouse-interactions research note
hakula139 May 15, 2026
f296719
fix(tui): paint OSC 8 PR hyperlink via Cell::set_symbol
hakula139 May 15, 2026
30f22fc
fix(tui): forward OSC 52 through tmux DCS pass-through and flush stdout
hakula139 May 15, 2026
9b6a055
fix(tui): terminate OSC 8 cells with BEL for xterm.js compatibility
hakula139 May 18, 2026
c5eda1d
refactor(tui): drop OSC 52 drag-selection in favor of native terminal…
hakula139 May 18, 2026
1c58f02
feat(tui): drop mouse capture and route wheel via DECSET 1007
hakula139 May 18, 2026
901877d
docs(tui): rewrite mouse-interactions notes around the no-capture design
hakula139 May 18, 2026
e21f4d0
fix(tui): emit PR-segment OSC 8 hyperlink out-of-band after flush
hakula139 May 18, 2026
31683f1
docs(tui): note tmux mouse-mode off-by-one in mouse-interactions rese…
hakula139 May 18, 2026
d55794d
fix(tui): restore cursor after out-of-band OSC 8 emission
hakula139 May 18, 2026
ef9dd95
fix(tui): bracket OSC 8 emission with DECSC / DECRC instead of DSR
hakula139 May 18, 2026
19bb191
refactor(util/git): extract probe helpers and cover failure paths
hakula139 May 18, 2026
0ac6d86
refactor(tui/status): regroup tests, derive margin constant, tighten …
hakula139 May 18, 2026
d4d546f
refactor(tui/app): tighten prose and cover style replay branches
hakula139 May 18, 2026
2ad118b
docs(tui): refresh mouse-interactions design for DECSC/DECRC restore
hakula139 May 18, 2026
8ea8aab
fix(tui): wrap OSC 8 in tmux DCS pass-through so ctrl+click survives …
hakula139 May 18, 2026
7a8ba86
refactor(tui): lift test-mod imports and dedupe hyperlink fixtures
hakula139 May 18, 2026
8de4975
fix(tui): harden OSC 8 replay against modifier leak and stale tmux env
hakula139 May 19, 2026
bb2796a
refactor(tui): apply convention and prose review findings
hakula139 May 19, 2026
6fa13b1
feat(tui): surface drag-copy and PR-click in the welcome tip pool
hakula139 May 19, 2026
248c88c
test(util): isolate tildify HOME fixtures
hakula139 May 19, 2026
39ec1ec
refactor(tui): align mouse PR code with terminal behavior
hakula139 May 19, 2026
35d15c6
docs(tui): tighten mouse interaction prose
hakula139 May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .cspell/words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ anthropic
anthropics
anyhow
APFS
arboard
atuin
cachain
catppuccin
Expand All @@ -17,6 +18,8 @@ codex
CREAT
creds
crossterm
DECRC
DECSC
dedupe
deque
deserialize
Expand All @@ -32,6 +35,7 @@ ESRCH
feff
frappe
getpwuid
Ghostty
gitui
hakula
impls
Expand All @@ -40,9 +44,11 @@ insta
isatty
killpg
Kobalte
Konsole
latte
macchiato
misparse
misparses
MMIX
mplan
mpsc
Expand All @@ -63,9 +69,11 @@ pgid
pkgs
pname
println
Ptmux
pulldown
RAII
ratatui
rects
replacen
reqwest
reserialized
Expand All @@ -84,6 +92,7 @@ sysname
SYSPROMPT
tengu
Tera
termio
thiserror
throbber
tildified
Expand All @@ -99,10 +108,12 @@ unresumable
unrotated
untap
urandom
URXVT
usize
venv
vtable
writeln
xhigh
Xresources
xxhash
yazi
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ ox # Start an interactive session
│ │ └── searchable_list.rs # Generic SearchableList<T: SearchableItem>: 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
Expand Down
143 changes: 105 additions & 38 deletions crates/oxide-code/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -206,7 +206,7 @@ impl App {
self.chat.handle_event(event);
}
}
Event::Mouse(..) => {
Event::Mouse(_) => {
self.chat.handle_event(event);
}
Event::Resize(..) => {}
Expand Down Expand Up @@ -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<W: std::io::Write>(
&mut self,
terminal: &mut ratatui::Terminal<ratatui::prelude::CrosstermBackend<W>>,
) -> Result<()> {
let links = self.status_bar.take_pending_hyperlinks();
if links.is_empty() {
return Ok(());
}
write_status_hyperlinks(terminal.backend_mut(), &links)?;
Ok(())
}

Expand Down Expand Up @@ -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)."),
Expand All @@ -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(),
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)."
);
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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<Mutex<Vec<u8>>>);

impl std::io::Write for SharedSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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::<u8>::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]
Expand Down Expand Up @@ -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");
Expand All @@ -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]
Expand All @@ -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;
Expand All @@ -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)");
}
}
11 changes: 7 additions & 4 deletions crates/oxide-code/src/tui/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,7 @@ impl ChatView {
code: KeyCode::End,
modifiers: KeyModifiers::CONTROL,
..
}) => {
self.scroll_to_bottom();
self.auto_scroll = true;
}
}) => self.jump_to_bottom(),
_ => {}
}
}
Expand All @@ -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 ──
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "
" "
Loading