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