Skip to content

Commit 86d19db

Browse files
committed
feat: terminal rendering fixes
1 parent bcf7e85 commit 86d19db

4 files changed

Lines changed: 348 additions & 2 deletions

File tree

.github/workflows/pages.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
- 'CNAME'
99
- 'docs/**'
1010
- 'public/**'
11+
- 'session/**'
1112
workflow_dispatch:
1213

1314
permissions:

src-rust/crates/tui/src/app.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6078,8 +6078,24 @@ impl App {
60786078
}
60796079
}
60806080

6081-
// Draw the frame
6082-
terminal.draw(|f| render::render_app(f, self))?;
6081+
// Draw the frame, and immediately scan the *just-rendered*
6082+
// buffer for URL runs. ratatui swaps its two buffers at the
6083+
// end of draw(), so by the time draw() returns,
6084+
// `terminal.current_buffer_mut()` points at the empty next-frame
6085+
// slot. `CompletedFrame.buffer` is the one we actually want.
6086+
let osc8_hits = {
6087+
let completed = terminal.draw(|f| render::render_app(f, self))?;
6088+
crate::osc8::scan_buffer_for_urls(completed.buffer)
6089+
};
6090+
6091+
// Post-paint OSC 8 overlay: re-emit URL cells wrapped in
6092+
// hyperlink escapes so terminals that support OSC 8 (Windows
6093+
// Terminal, iTerm2, WezTerm, Kitty, Konsole, VS Code, …) make
6094+
// them Ctrl/Cmd-clickable. Failure is non-fatal — we never want
6095+
// an overlay glitch to kill the TUI.
6096+
if let Err(err) = crate::osc8::emit_hits(&osc8_hits) {
6097+
tracing::debug!(target: "osc8", "hyperlink overlay write failed: {err}");
6098+
}
60836099

60846100
// Replay a key that was saved by try_detect_paste_burst in a
60856101
// previous iteration (e.g. a modifier key that terminated a burst).

src-rust/crates/tui/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ pub mod app;
5353
pub mod input;
5454
/// All ratatui rendering logic.
5555
pub mod render;
56+
/// Post-paint OSC 8 hyperlink emission — makes URLs Ctrl/Cmd-clickable.
57+
pub mod osc8;
5658
/// Permission dialogs and confirmation dialogs.
5759
pub mod dialogs;
5860
/// Notification / banner system.

src-rust/crates/tui/src/osc8.rs

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
//! OSC 8 hyperlink overlay for the ratatui-based TUI.
2+
//!
3+
//! ratatui 0.29 has no native hyperlink primitive — its render path writes
4+
//! one Cell at a time and never carries OSC 8 state across cells. We hook
5+
//! the main draw loop, scan the painted buffer for URLs, and re-emit just
6+
//! those cells wrapped in `OSC 8 ; ; URL ESC \` ... `OSC 8 ;; ESC \` so
7+
//! terminals that implement the protocol (Windows Terminal, iTerm2,
8+
//! WezTerm, Kitty, Konsole, VS Code, …) make them Ctrl/Cmd-clickable.
9+
//! Terminals without OSC 8 support silently ignore the unknown OSC.
10+
//!
11+
//! The detection mirrors `messages::markdown::URL_PATTERN`, so what the
12+
//! markdown renderer underlines in cyan is exactly what gets linked here.
13+
//!
14+
//! Disable with `CLAURST_NO_HYPERLINKS=1`.
15+
16+
use std::io::{self, Write};
17+
18+
use crossterm::{
19+
cursor::{MoveTo, RestorePosition, SavePosition},
20+
style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
21+
QueueableCommand,
22+
};
23+
use once_cell::sync::Lazy;
24+
use ratatui::buffer::Buffer;
25+
use regex::Regex;
26+
27+
const OSC8_OPEN_PREFIX: &str = "\x1b]8;;";
28+
const OSC8_ST: &str = "\x1b\\";
29+
const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
30+
31+
static URL_RE: Lazy<Regex> = Lazy::new(|| {
32+
// Conservative URL set — restricting body chars to the RFC 3986 reserved/
33+
// unreserved alphabet avoids accidentally pulling in trailing whitespace
34+
// or display punctuation that the markdown styler also rejects.
35+
Regex::new(
36+
r#"(?:https?|ftp)://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+|www\.[A-Za-z0-9\-]+\.[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+"#,
37+
)
38+
.expect("OSC 8 URL regex")
39+
});
40+
41+
#[derive(Debug, Clone)]
42+
pub struct UrlHit {
43+
/// Visual column of the first cell, absolute (already includes area.x).
44+
pub col: u16,
45+
/// Visual row, absolute (already includes area.y).
46+
pub row: u16,
47+
/// URL passed to the terminal — normalized (e.g., `www.…` → `https://…`).
48+
pub url: String,
49+
/// Original on-screen text — re-printed verbatim so the row looks unchanged.
50+
pub display: String,
51+
}
52+
53+
fn enabled() -> bool {
54+
match std::env::var("CLAURST_NO_HYPERLINKS").as_deref() {
55+
Ok(v) => !matches!(v.trim(), "1" | "true" | "yes" | "on"),
56+
Err(_) => true,
57+
}
58+
}
59+
60+
/// Strip trailing punctuation that is almost certainly *not* part of the URL.
61+
/// Parentheses are only stripped when unbalanced — `https://en.wikipedia.org/wiki/Foo_(bar)`
62+
/// should keep its closing paren.
63+
fn trim_url_punct(matched: &str) -> &str {
64+
let bytes = matched.as_bytes();
65+
let mut paren_balance: i32 = 0;
66+
for &b in bytes {
67+
if b == b'(' {
68+
paren_balance += 1;
69+
} else if b == b')' {
70+
paren_balance -= 1;
71+
}
72+
}
73+
let mut end = bytes.len();
74+
while end > 0 {
75+
let last = bytes[end - 1];
76+
let strip = match last {
77+
b'.' | b',' | b';' | b':' | b'!' | b'?' | b'\'' | b'"' | b'>' => true,
78+
b']' | b'}' => true,
79+
b')' => paren_balance < 0,
80+
_ => false,
81+
};
82+
if strip {
83+
if last == b')' {
84+
paren_balance += 1;
85+
}
86+
end -= 1;
87+
} else {
88+
break;
89+
}
90+
}
91+
&matched[..end]
92+
}
93+
94+
/// Scan a buffer that's just been rendered (e.g. `CompletedFrame::buffer`
95+
/// or `Frame::buffer_mut()` inside a `Terminal::draw` closure) for URL
96+
/// runs and return their visual positions + normalized targets.
97+
///
98+
/// **Important**: do NOT call this on `Terminal::current_buffer_mut()`
99+
/// after `draw()` — ratatui swaps buffers at the end of draw and
100+
/// `current_buffer_mut()` then points at the cleared next-frame slot.
101+
/// Use the `CompletedFrame` returned by `draw()` instead.
102+
pub fn scan_buffer_for_urls(buf: &Buffer) -> Vec<UrlHit> {
103+
if !enabled() {
104+
return Vec::new();
105+
}
106+
scan_buffer(buf)
107+
}
108+
109+
/// Write OSC 8 hyperlink wrappers for the given hits to stdout. Saves and
110+
/// restores the cursor position so the user-visible cursor stays where
111+
/// ratatui put it. No-op when `hits` is empty or hyperlinks are disabled.
112+
pub fn emit_hits(hits: &[UrlHit]) -> io::Result<()> {
113+
if !enabled() || hits.is_empty() {
114+
return Ok(());
115+
}
116+
let stdout = io::stdout();
117+
let mut lock = stdout.lock();
118+
write_hits(&mut lock, hits)
119+
}
120+
121+
fn scan_buffer(buf: &Buffer) -> Vec<UrlHit> {
122+
let area = buf.area();
123+
if area.width == 0 || area.height == 0 {
124+
return Vec::new();
125+
}
126+
127+
let mut hits = Vec::new();
128+
let mut row_text = String::new();
129+
// Maps each byte index of `row_text` back to its visual column. Wide
130+
// cells (CJK, emoji) leave a width-1 contribution per pushed byte; the
131+
// continuation cell at col+1 has an empty symbol and is skipped.
132+
let mut col_of_byte: Vec<u16> = Vec::new();
133+
134+
for row in 0..area.height {
135+
row_text.clear();
136+
col_of_byte.clear();
137+
for col in 0..area.width {
138+
let cell = &buf[(area.x + col, area.y + row)];
139+
let sym = cell.symbol();
140+
if sym.is_empty() {
141+
continue;
142+
}
143+
let before = row_text.len();
144+
row_text.push_str(sym);
145+
for _ in before..row_text.len() {
146+
col_of_byte.push(col);
147+
}
148+
}
149+
150+
for m in URL_RE.find_iter(&row_text) {
151+
let matched = &row_text[m.start()..m.end()];
152+
let cleaned = trim_url_punct(matched);
153+
if cleaned.is_empty() {
154+
continue;
155+
}
156+
let start_byte = m.start();
157+
// Defensive: a regex match should always be in bounds; if not, skip.
158+
let Some(&start_col) = col_of_byte.get(start_byte) else {
159+
continue;
160+
};
161+
hits.push(UrlHit {
162+
col: area.x + start_col,
163+
row: area.y + row,
164+
url: normalize_url(cleaned),
165+
display: cleaned.to_string(),
166+
});
167+
}
168+
}
169+
hits
170+
}
171+
172+
fn normalize_url(s: &str) -> String {
173+
if s.starts_with("www.") {
174+
format!("https://{s}")
175+
} else {
176+
s.to_string()
177+
}
178+
}
179+
180+
fn write_hits(writer: &mut impl Write, hits: &[UrlHit]) -> io::Result<()> {
181+
writer.queue(SavePosition)?;
182+
for h in hits {
183+
writer.queue(MoveTo(h.col, h.row))?;
184+
// Match the markdown renderer's URL styling so the overlay looks
185+
// identical to ratatui's original paint of these cells.
186+
writer.queue(SetForegroundColor(Color::Cyan))?;
187+
writer.queue(SetAttribute(Attribute::Underlined))?;
188+
writer.queue(Print(format!("{OSC8_OPEN_PREFIX}{}{OSC8_ST}", h.url)))?;
189+
writer.queue(Print(&h.display))?;
190+
writer.queue(Print(OSC8_CLOSE))?;
191+
writer.queue(SetAttribute(Attribute::NoUnderline))?;
192+
writer.queue(ResetColor)?;
193+
}
194+
writer.queue(RestorePosition)?;
195+
writer.flush()?;
196+
Ok(())
197+
}
198+
199+
#[cfg(test)]
200+
mod tests {
201+
use super::*;
202+
use ratatui::buffer::Buffer;
203+
use ratatui::layout::Rect;
204+
use ratatui::style::Style;
205+
206+
fn buffer_with(lines: &[&str]) -> Buffer {
207+
let h = lines.len() as u16;
208+
let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
209+
let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
210+
for (y, line) in lines.iter().enumerate() {
211+
buf.set_string(0, y as u16, *line, Style::default());
212+
}
213+
buf
214+
}
215+
216+
#[test]
217+
fn detects_simple_http_url() {
218+
let buf = buffer_with(&["Visit https://example.com today"]);
219+
let hits = scan_buffer_for_urls(&buf);
220+
assert_eq!(hits.len(), 1);
221+
assert_eq!(hits[0].display, "https://example.com");
222+
assert_eq!(hits[0].url, "https://example.com");
223+
assert_eq!(hits[0].col, 6);
224+
assert_eq!(hits[0].row, 0);
225+
}
226+
227+
#[test]
228+
fn strips_trailing_period_and_paren() {
229+
let buf = buffer_with(&["See (https://example.com)."]);
230+
let hits = scan_buffer_for_urls(&buf);
231+
assert_eq!(hits.len(), 1, "hits: {hits:?}");
232+
assert_eq!(hits[0].display, "https://example.com");
233+
}
234+
235+
#[test]
236+
fn keeps_balanced_paren_inside_url() {
237+
let buf = buffer_with(&["see https://en.wikipedia.org/wiki/Foo_(bar) ok"]);
238+
let hits = scan_buffer_for_urls(&buf);
239+
assert_eq!(hits.len(), 1);
240+
assert_eq!(hits[0].display, "https://en.wikipedia.org/wiki/Foo_(bar)");
241+
}
242+
243+
#[test]
244+
fn detects_www_and_normalizes_to_https() {
245+
let buf = buffer_with(&["go to www.example.com now"]);
246+
let hits = scan_buffer_for_urls(&buf);
247+
assert_eq!(hits.len(), 1);
248+
assert_eq!(hits[0].display, "www.example.com");
249+
assert_eq!(hits[0].url, "https://www.example.com");
250+
}
251+
252+
#[test]
253+
fn no_urls_no_hits() {
254+
let buf = buffer_with(&["just some text without urls"]);
255+
let hits = scan_buffer_for_urls(&buf);
256+
assert!(hits.is_empty());
257+
}
258+
259+
#[test]
260+
fn two_urls_one_line() {
261+
let buf = buffer_with(&["a https://one.test and https://two.test x"]);
262+
let hits = scan_buffer_for_urls(&buf);
263+
assert_eq!(hits.len(), 2);
264+
assert_eq!(hits[0].display, "https://one.test");
265+
assert_eq!(hits[1].display, "https://two.test");
266+
// Columns advance as expected.
267+
assert!(hits[1].col > hits[0].col);
268+
}
269+
270+
#[test]
271+
fn handles_share_url_with_hash_fragment() {
272+
let url = "https://claurst.kuber.studio/session/#c2cc4dd0ae0d3fa6dc7ab21f2a79d7a1";
273+
let line = format!("Share URL: {url}");
274+
let buf = buffer_with(&[&line]);
275+
let hits = scan_buffer_for_urls(&buf);
276+
assert_eq!(hits.len(), 1);
277+
assert_eq!(hits[0].display, url);
278+
}
279+
280+
#[test]
281+
fn write_hits_emits_osc8_envelope() {
282+
let hits = vec![UrlHit {
283+
col: 6,
284+
row: 0,
285+
url: "https://example.com".to_string(),
286+
display: "https://example.com".to_string(),
287+
}];
288+
let mut out: Vec<u8> = Vec::new();
289+
write_hits(&mut out, &hits).unwrap();
290+
let s = String::from_utf8(out).unwrap();
291+
assert!(s.contains("\x1b]8;;https://example.com\x1b\\"), "missing OSC 8 open in: {s:?}");
292+
assert!(s.contains("\x1b]8;;\x1b\\"), "missing OSC 8 close in: {s:?}");
293+
assert!(s.contains("https://example.com"));
294+
}
295+
296+
#[test]
297+
fn write_hits_no_output_when_empty() {
298+
// Sanity: emit_hyperlinks bails before calling write_hits when there
299+
// are no hits, but the function itself should still be cheap.
300+
let mut out: Vec<u8> = Vec::new();
301+
write_hits(&mut out, &[]).unwrap();
302+
// Even with no hits we still issue Save/Restore + flush; just confirm
303+
// it doesn't panic and produces a finite byte sequence.
304+
assert!(out.len() < 64, "spurious bytes: {} bytes", out.len());
305+
}
306+
307+
#[test]
308+
fn enabled_respects_env_var() {
309+
// Save & restore the env var around the asserts so other tests don't
310+
// observe stray state. (No #[serial] crate available here.)
311+
let prev = std::env::var("CLAURST_NO_HYPERLINKS").ok();
312+
313+
std::env::remove_var("CLAURST_NO_HYPERLINKS");
314+
assert!(enabled());
315+
316+
std::env::set_var("CLAURST_NO_HYPERLINKS", "1");
317+
assert!(!enabled());
318+
319+
std::env::set_var("CLAURST_NO_HYPERLINKS", "0");
320+
assert!(enabled());
321+
322+
match prev {
323+
Some(v) => std::env::set_var("CLAURST_NO_HYPERLINKS", v),
324+
None => std::env::remove_var("CLAURST_NO_HYPERLINKS"),
325+
}
326+
}
327+
}

0 commit comments

Comments
 (0)