返回 CodeWhale
osc8.rs
根目录 / crates / tui / src / tui / osc8.rs
1 //! OSC 8 hyperlink emission and stripping.
2 //!
3 //! Modern terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm,
4 //! Alacritty, recent gnome-terminal/konsole) make a substring clickable when
5 //! it is wrapped in:
6 //!
7 //! ```text
8 //! \x1b]8;;TARGET\x1b\\LABEL\x1b]8;;\x1b\\
9 //! ```
10 //!
11 //! Terminals that don't understand the sequence simply render the visible
12 //! `LABEL` and ignore the escape. So emitting OSC 8 is a strict UX upgrade for
13 //! supporting terminals and a no-op for the rest.
14 //!
15 //! # Architecture (#3029)
16 //!
17 //! Link targets never enter `Span::content` or a ratatui `Buffer`. Markdown
18 //! wrapping produces plain visible spans plus parallel [`LineLink`] metadata.
19 //! Transcript surfaces translate those relative columns into absolute
20 //! [`LinkRegion`]s for the current viewport. `ColorCompatBackend::draw` then
21 //! emits OSC 8 escapes around the corresponding cell runs. This keeps text
22 //! layout, selection, and clipboard extraction byte-for-byte identical with
23 //! links enabled or disabled, including long links wrapped across rows.
24 //! Markdown contributes only normalized HTTP(S) and absolute `file://`
25 //! targets, and emission percent-encodes terminal control characters as
26 //! defense in depth.
27 //!
28 //! Opening is terminal-owned: supporting terminals conventionally use
29 //! Cmd-click on macOS or Ctrl-click on Linux/Windows. CodeWhale does not
30 //! intercept those gestures or launch URLs itself, so mouse selection remains
31 //! independent of browser-opening policy.
32 //!
33 //! The clipboard/selection extraction path still strips any residual codes via
34 //! [`strip_into`] / [`strip_ansi_into`] as a defense-in-depth.
35
36 use std::sync::atomic::{AtomicBool, Ordering};
37
38 const OSC8_PREFIX: &str = "\x1b]8;;";
39 const OSC8_TERMINATOR: &str = "\x1b\\";
40 const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
41
42 /// A contiguous run of cells on one terminal row that share a hyperlink target.
43 #[derive(Debug, Clone, PartialEq, Eq)]
44 pub struct LinkRegion {
45 pub row: u16,
46 pub col_start: u16,
47 pub col_end: u16,
48 pub target: String,
49 }
50
51 /// Hyperlink metadata for one already-wrapped visible line. Columns are
52 /// zero-based display columns relative to that line and `col_end` is
53 /// inclusive, matching [`LinkRegion`].
54 #[derive(Debug, Clone, PartialEq, Eq)]
55 pub struct LineLink {
56 pub col_start: usize,
57 pub col_end: usize,
58 pub target: String,
59 }
60
61 impl LineLink {
62 #[must_use]
63 pub fn shifted(&self, columns: usize) -> Self {
64 Self {
65 col_start: self.col_start.saturating_add(columns),
66 col_end: self.col_end.saturating_add(columns),
67 target: self.target.clone(),
68 }
69 }
70 }
71
72 /// Translate per-line relative metadata into absolute terminal regions for a
73 /// rendered viewport. Metadata outside `area` is clipped rather than allowed
74 /// to hyperlink adjacent chrome (for example the transcript scrollbar).
75 #[must_use]
76 pub fn link_regions_for_lines(
77 area: ratatui::layout::Rect,
78 links: &[Vec<LineLink>],
79 ) -> Vec<LinkRegion> {
80 if area.width == 0 || area.height == 0 {
81 return Vec::new();
82 }
83 let width = usize::from(area.width);
84 let mut regions = Vec::new();
85 for (line_index, line_links) in links.iter().take(usize::from(area.height)).enumerate() {
86 let row = area
87 .y
88 .saturating_add(u16::try_from(line_index).unwrap_or(u16::MAX));
89 for link in line_links {
90 if link.col_start >= width || link.col_end < link.col_start {
91 continue;
92 }
93 let start = link.col_start;
94 let end = link.col_end.min(width.saturating_sub(1));
95 regions.push(LinkRegion {
96 row,
97 col_start: area
98 .x
99 .saturating_add(u16::try_from(start).unwrap_or(u16::MAX)),
100 col_end: area
101 .x
102 .saturating_add(u16::try_from(end).unwrap_or(u16::MAX)),
103 target: link.target.clone(),
104 });
105 }
106 }
107 regions
108 }
109
110 /// Write an OSC 8 hyperlink open sequence for `target` to `w`.
111 pub fn write_osc8_open(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> {
112 w.write_all(OSC8_PREFIX.as_bytes())?;
113 write_sanitized_target(w, target)?;
114 w.write_all(OSC8_TERMINATOR.as_bytes())
115 }
116
117 /// Percent-encode terminal control characters before they enter an OSC
118 /// parameter. Markdown and restored transcripts are untrusted input: a raw
119 /// BEL, ESC/ST, or other control byte could terminate the link and inject an
120 /// arbitrary terminal sequence. Printable Unicode and ordinary URL bytes are
121 /// preserved byte-for-byte.
122 fn write_sanitized_target(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> {
123 const HEX: &[u8; 16] = b"0123456789ABCDEF";
124 let mut encoded = [0u8; 4];
125 for ch in target.chars() {
126 let value = ch.encode_utf8(&mut encoded);
127 if ch.is_control() {
128 for &byte in value.as_bytes() {
129 w.write_all(&[
130 b'%',
131 HEX[usize::from(byte >> 4)],
132 HEX[usize::from(byte & 0x0f)],
133 ])?;
134 }
135 } else {
136 w.write_all(value.as_bytes())?;
137 }
138 }
139 Ok(())
140 }
141
142 /// Write an OSC 8 hyperlink close sequence to `w`.
143 pub fn write_osc8_close(w: &mut impl std::io::Write) -> std::io::Result<()> {
144 w.write_all(OSC8_CLOSE.as_bytes())
145 }
146
147 /// Process-wide enable flag. Set once at app init from `[tui] osc8_links`
148 /// (when present); otherwise defaults to on for macOS/Linux and off for
149 /// Windows legacy consoles (see `ui.rs`'s `osc8_default_on`). Read by the
150 /// renderer to gate out-of-band OSC 8 emission.
151 static ENABLED: AtomicBool = AtomicBool::new(true);
152
153 /// Set the process-wide OSC 8 enable flag. Intended to be called once at
154 /// startup; subsequent calls take effect immediately.
155 pub fn set_enabled(enabled: bool) {
156 ENABLED.store(enabled, Ordering::Relaxed);
157 }
158
159 /// Whether OSC 8 hyperlink emission is currently enabled.
160 #[must_use]
161 pub fn enabled() -> bool {
162 ENABLED.load(Ordering::Relaxed)
163 }
164
165 // --- Thread-local link region accumulator (#3029) ---
166
167 use std::cell::RefCell;
168
169 thread_local! {
170 /// Link regions collected during the current render frame.
171 /// Populated by transcript widgets from their parallel line metadata;
172 /// consumed and cleared by `ColorCompatBackend::draw()`.
173 pub static FRAME_LINKS: RefCell<Vec<LinkRegion>> = const { RefCell::new(Vec::new()) };
174 }
175
176 /// Replace the thread-local frame link buffer with `links`.
177 pub fn set_frame_links(links: Vec<LinkRegion>) {
178 FRAME_LINKS.with(|cell| {
179 *cell.borrow_mut() = links;
180 });
181 }
182
183 /// Append `links` to the thread-local frame link buffer. Used when more than
184 /// one widget renders link-bearing content into the same frame (e.g. the main
185 /// transcript and the live-transcript overlay): each seam appends rather than
186 /// replacing, so all regions reach `ColorCompatBackend::draw`.
187 pub fn append_frame_links(links: Vec<LinkRegion>) {
188 FRAME_LINKS.with(|cell| cell.borrow_mut().extend(links));
189 }
190
191 /// Replace the portion of the current frame-link map covered by an opaque
192 /// overlay, preserving (and clipping) regions that remain visible around it.
193 /// This prevents a transcript URL underneath a modal from making unrelated
194 /// popup text clickable when both widgets paint in the same terminal frame.
195 pub fn overlay_frame_links(area: ratatui::layout::Rect, links: Vec<LinkRegion>) {
196 if area.width == 0 || area.height == 0 {
197 append_frame_links(links);
198 return;
199 }
200 let x_start = area.x;
201 let x_end = area.right();
202 let y_start = area.y;
203 let y_end = area.bottom();
204 FRAME_LINKS.with(|cell| {
205 let mut current = cell.borrow_mut();
206 let mut visible = Vec::with_capacity(current.len().saturating_add(links.len()));
207 for region in current.drain(..) {
208 if region.row < y_start
209 || region.row >= y_end
210 || region.col_end < x_start
211 || region.col_start >= x_end
212 {
213 visible.push(region);
214 continue;
215 }
216 if region.col_start < x_start {
217 let mut left = region.clone();
218 left.col_end = x_start.saturating_sub(1);
219 visible.push(left);
220 }
221 if region.col_end >= x_end {
222 let mut right = region;
223 right.col_start = x_end;
224 visible.push(right);
225 }
226 }
227 visible.extend(links);
228 *current = visible;
229 });
230 }
231
232 /// Take the thread-local frame links, leaving an empty vec behind.
233 pub fn take_frame_links() -> Vec<LinkRegion> {
234 FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
235 }
236
237 /// Strip ANSI/OSC/control sequences from `s` into `out`.
238 ///
239 /// Delegates to the single shared implementation in
240 /// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so `/export`, `/structcopy`,
241 /// and the renderer cannot drift.
242 pub fn strip_ansi_into(s: &str, out: &mut String) {
243 codewhale_secrets::sanitize::strip_ansi_into(s, out);
244 }
245
246 /// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
247 /// underline, reset) pass through untouched so a renderer that understands
248 /// them can paint the output as the tool emitted it. Everything else — OSC
249 /// (including OSC 8 hyperlink wrappers), cursor movement, DCS, lone control
250 /// bytes — is still removed; only the styling survives.
251 pub fn strip_ansi_keep_sgr_into(s: &str, out: &mut String) {
252 codewhale_secrets::sanitize::strip_ansi_keep_sgr_into(s, out);
253 }
254
255 /// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back
256 /// to `1` for continuation bytes / invalid leads so callers always make
257 /// forward progress.
258 ///
259 /// Delegates to the shared implementation in [`codewhale_secrets::sanitize`].
260 fn utf8_seq_len(lead: u8) -> usize {
261 codewhale_secrets::sanitize::utf8_seq_len(lead)
262 }
263
264 /// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
265 /// label text. Other escapes (color, style) pass through untouched. The
266 /// implementation handles both the standard `ESC \` and the lone `BEL`
267 /// terminators that some emitters use.
268 pub fn strip_into(s: &str, out: &mut String) {
269 let bytes = s.as_bytes();
270 let mut i = 0;
271 while i < bytes.len() {
272 // Look for the OSC 8 prefix `ESC ] 8 ;`
273 if i + 4 <= bytes.len()
274 && bytes[i] == 0x1b
275 && bytes[i + 1] == b']'
276 && bytes[i + 2] == b'8'
277 && bytes[i + 3] == b';'
278 {
279 // Skip until the string terminator (ESC \) or BEL.
280 let mut j = i + 4;
281 while j < bytes.len() {
282 if bytes[j] == 0x07 {
283 j += 1;
284 break;
285 }
286 if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
287 j += 2;
288 break;
289 }
290 j += 1;
291 }
292 i = j;
293 continue;
294 }
295 let b = bytes[i];
296 if b < 0x80 {
297 out.push(b as char);
298 i += 1;
299 } else {
300 let len = utf8_seq_len(b);
301 let end = (i + len).min(bytes.len());
302 if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
303 out.push_str(chunk);
304 }
305 i = end;
306 }
307 }
308 }
309
310 #[cfg(test)]
311 mod tests {
312 use super::*;
313 use std::sync::Mutex;
314
315 /// Serialize tests that read or write the `ENABLED` flag so they don't
316 /// race each other under cargo's default parallel test runner.
317 static FLAG_GUARD: Mutex<()> = Mutex::new(());
318
319 fn strip(s: &str) -> String {
320 let mut out = String::with_capacity(s.len());
321 strip_into(s, &mut out);
322 out
323 }
324
325 fn wrapped_link(target: &str, label: &str) -> String {
326 format!("{OSC8_PREFIX}{target}{OSC8_TERMINATOR}{label}{OSC8_CLOSE}")
327 }
328
329 #[test]
330 fn wrapped_link_fixture_is_osc_8_compliant() {
331 let wrapped = wrapped_link("https://example.com", "click me");
332 assert_eq!(
333 wrapped,
334 "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"
335 );
336 }
337
338 #[test]
339 fn strip_removes_wrapper_keeps_label() {
340 let wrapped = wrapped_link("https://example.com", "click me");
341 assert_eq!(strip(&wrapped), "click me");
342 }
343
344 #[test]
345 fn strip_handles_bel_terminator() {
346 let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07";
347 assert_eq!(strip(wrapped), "click me");
348 }
349
350 #[test]
351 fn strip_passes_through_text_with_no_escapes() {
352 let plain = "no escapes here";
353 assert_eq!(strip(plain), plain);
354 }
355
356 #[test]
357 fn strip_preserves_non_osc_8_escapes() {
358 // Color escape stays in place; only OSC 8 wrappers are removed.
359 let mixed = format!(
360 "\x1b[31mred\x1b[0m {wrapped}",
361 wrapped = wrapped_link("https://example.com", "click")
362 );
363 assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click");
364 }
365
366 fn strip_ansi(s: &str) -> String {
367 let mut out = String::with_capacity(s.len());
368 strip_ansi_into(s, &mut out);
369 out
370 }
371
372 #[test]
373 fn strip_ansi_removes_csi_sgr_and_keeps_text() {
374 let coloured = "526 \x1b[1;32mOPEN\x1b[0m bug fix";
375 assert_eq!(strip_ansi(coloured), "526 OPEN bug fix");
376 }
377
378 #[test]
379 fn strip_keep_sgr_keeps_colour_and_drops_everything_else() {
380 let mut out = String::new();
381 strip_ansi_keep_sgr_into(
382 "\x1b]8;;https://x\x07\x1b[1;32mok\x1b[0m\x1b]8;;\x07\x1b[2K\x1b[?25l tail",
383 &mut out,
384 );
385 assert_eq!(out, "\x1b[1;32mok\x1b[0m tail");
386 let mut plain = String::new();
387 strip_ansi_into(&out, &mut plain);
388 assert_eq!(plain, "ok tail");
389 }
390
391 #[test]
392 fn strip_ansi_removes_osc_8_wrapper() {
393 let wrapped = wrapped_link("https://example.com", "click");
394 assert_eq!(strip_ansi(&wrapped), "click");
395 }
396
397 #[test]
398 fn strip_ansi_preserves_newlines_tabs_and_cr() {
399 let s = "a\nb\tc\rd";
400 assert_eq!(strip_ansi(s), "a\nb\tc\rd");
401 }
402
403 #[test]
404 fn strip_ansi_drops_lone_control_bytes() {
405 // Bare BEL or other C0 control bytes that aren't \n/\r/\t are dropped
406 // so they can't paint as visible cells.
407 let s = "a\x07b\x01c";
408 assert_eq!(strip_ansi(s), "abc");
409 }
410
411 #[test]
412 fn strip_ansi_preserves_utf8_multibyte_chars() {
413 // CJK, accented Latin, and emoji must survive the strip without being
414 // re-decoded as Latin-1 (which would explode 你 -> ä½ ).
415 let s = "Phase 1: 第一步 README é 🚀";
416 assert_eq!(strip_ansi(s), "Phase 1: 第一步 README é 🚀");
417
418 let coloured = "\x1b[1;32m第一步\x1b[0m done";
419 assert_eq!(strip_ansi(coloured), "第一步 done");
420 }
421
422 #[test]
423 fn strip_preserves_utf8_multibyte_chars() {
424 let wrapped = wrapped_link("https://example.com", "点击我");
425 assert_eq!(strip(&wrapped), "点击我");
426 }
427
428 #[test]
429 fn open_sequence_percent_encodes_target_control_injection() {
430 let target = "https://safe.test/a\x07b\x1b]8;;https://evil.test\x1b\\c\x7f\u{009c}";
431 let mut bytes = Vec::new();
432 write_osc8_open(&mut bytes, target).expect("write OSC 8 open");
433 let rendered = String::from_utf8(bytes.clone()).expect("valid UTF-8 output");
434
435 assert_eq!(rendered.matches(OSC8_PREFIX).count(), 1, "{rendered:?}");
436 assert_eq!(rendered.matches(OSC8_TERMINATOR).count(), 1, "{rendered:?}");
437 assert_eq!(bytes.iter().filter(|byte| **byte == 0x1b).count(), 2);
438 assert!(!bytes.contains(&0x07), "BEL escaped: {rendered:?}");
439 assert!(!bytes.contains(&0x7f), "DEL escaped: {rendered:?}");
440 assert!(
441 rendered.contains("a%07b%1B]8;;https://evil.test%1B\\c%7F%C2%9C"),
442 "control bytes must be percent-encoded: {rendered:?}"
443 );
444 }
445
446 #[test]
447 fn enabled_is_true_by_default_when_untouched() {
448 // Hold the flag guard so we observe the initial state, not a value
449 // mid-flight from `set_enabled_round_trips`. The flag *defaults* to
450 // true at static init and tests in this module are the only writers.
451 let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
452 assert!(enabled());
453 }
454
455 #[test]
456 fn set_enabled_round_trips() {
457 let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
458 let prior = enabled();
459 set_enabled(false);
460 assert!(!enabled());
461 set_enabled(true);
462 assert!(enabled());
463 set_enabled(prior);
464 }
465
466 #[test]
467 fn line_links_translate_to_absolute_clipped_regions() {
468 let area = ratatui::layout::Rect::new(7, 3, 8, 2);
469 let links = vec![
470 vec![
471 LineLink {
472 col_start: 2,
473 col_end: 20,
474 target: "https://example.test/long".to_string(),
475 },
476 LineLink {
477 col_start: 8,
478 col_end: 9,
479 target: "outside".to_string(),
480 },
481 ],
482 vec![LineLink {
483 col_start: 0,
484 col_end: 1,
485 target: "https://example.test/next".to_string(),
486 }],
487 vec![LineLink {
488 col_start: 0,
489 col_end: 0,
490 target: "below viewport".to_string(),
491 }],
492 ];
493
494 assert_eq!(
495 link_regions_for_lines(area, &links),
496 vec![
497 LinkRegion {
498 row: 3,
499 col_start: 9,
500 col_end: 14,
501 target: "https://example.test/long".to_string(),
502 },
503 LinkRegion {
504 row: 4,
505 col_start: 7,
506 col_end: 8,
507 target: "https://example.test/next".to_string(),
508 },
509 ]
510 );
511 }
512
513 #[test]
514 fn opaque_overlay_replaces_and_clips_underlying_regions() {
515 set_frame_links(vec![
516 LinkRegion {
517 row: 4,
518 col_start: 0,
519 col_end: 20,
520 target: "under-wide".to_string(),
521 },
522 LinkRegion {
523 row: 5,
524 col_start: 6,
525 col_end: 8,
526 target: "under-covered".to_string(),
527 },
528 ]);
529 overlay_frame_links(
530 ratatui::layout::Rect::new(5, 4, 10, 2),
531 vec![LinkRegion {
532 row: 4,
533 col_start: 7,
534 col_end: 8,
535 target: "modal".to_string(),
536 }],
537 );
538
539 assert_eq!(
540 take_frame_links(),
541 vec![
542 LinkRegion {
543 row: 4,
544 col_start: 0,
545 col_end: 4,
546 target: "under-wide".to_string(),
547 },
548 LinkRegion {
549 row: 4,
550 col_start: 15,
551 col_end: 20,
552 target: "under-wide".to_string(),
553 },
554 LinkRegion {
555 row: 4,
556 col_start: 7,
557 col_end: 8,
558 target: "modal".to_string(),
559 },
560 ]
561 );
562 }
563 }
564
564 lines RUST