返回 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 #[cfg(test)]
77 pub fn link_regions_for_lines(
78 area: ratatui::layout::Rect,
79 links: &[Vec<LineLink>],
80 ) -> Vec<LinkRegion> {
81 link_regions_for_plan(
82 &codewhale_ratatui::TranscriptViewport::new(&[]).plan(area),
83 links,
84 )
85 }
86
87 /// Project targets through the final painted geometry; no target enters kit data.
88 #[must_use]
89 pub fn link_regions_for_plan(
90 plan: &codewhale_ratatui::TranscriptViewportPlan,
91 links: &[Vec<LineLink>],
92 ) -> Vec<LinkRegion> {
93 let mut regions = Vec::new();
94 for (row, links) in links.iter().take(usize::from(plan.area.height)).enumerate() {
95 for link in links {
96 for rect in plan.link_rects(row, link.col_start, link.col_end) {
97 regions.push(LinkRegion {
98 row: rect.y,
99 col_start: rect.x,
100 col_end: rect.right().saturating_sub(1),
101 target: link.target.clone(),
102 });
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 ANSI/OSC/control sequences from `s` into `out`.
237 ///
238 /// Delegates to the single shared implementation in
239 /// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so `/export`, `/structcopy`,
240 /// and the renderer cannot drift.
241 pub fn strip_ansi_into(s: &str, out: &mut String) {
242 codewhale_secrets::sanitize::strip_ansi_into(s, out);
243 }
244
245 /// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
246 /// underline, reset) pass through untouched so a renderer that understands
247 /// them can paint the output as the tool emitted it. Everything else — OSC
248 /// (including OSC 8 hyperlink wrappers), cursor movement, DCS, lone control
249 /// bytes — is still removed; only the styling survives.
250 pub fn strip_ansi_keep_sgr_into(s: &str, out: &mut String) {
251 codewhale_secrets::sanitize::strip_ansi_keep_sgr_into(s, out);
252 }
253
254 /// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back
255 /// to `1` for continuation bytes / invalid leads so callers always make
256 /// forward progress.
257 ///
258 /// Delegates to the shared implementation in [`codewhale_secrets::sanitize`].
259 fn utf8_seq_len(lead: u8) -> usize {
260 codewhale_secrets::sanitize::utf8_seq_len(lead)
261 }
262
263 /// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
264 /// label text. Other escapes (color, style) pass through untouched. The
265 /// implementation handles both the standard `ESC \` and the lone `BEL`
266 /// terminators that some emitters use.
267 pub fn strip_into(s: &str, out: &mut String) {
268 let bytes = s.as_bytes();
269 let mut i = 0;
270 while i < bytes.len() {
271 // Look for the OSC 8 prefix `ESC ] 8 ;`
272 if i + 4 <= bytes.len()
273 && bytes[i] == 0x1b
274 && bytes[i + 1] == b']'
275 && bytes[i + 2] == b'8'
276 && bytes[i + 3] == b';'
277 {
278 // Skip until the string terminator (ESC \) or BEL.
279 let mut j = i + 4;
280 while j < bytes.len() {
281 if bytes[j] == 0x07 {
282 j += 1;
283 break;
284 }
285 if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
286 j += 2;
287 break;
288 }
289 j += 1;
290 }
291 i = j;
292 continue;
293 }
294 let b = bytes[i];
295 if b < 0x80 {
296 out.push(b as char);
297 i += 1;
298 } else {
299 let len = utf8_seq_len(b);
300 let end = (i + len).min(bytes.len());
301 if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
302 out.push_str(chunk);
303 }
304 i = end;
305 }
306 }
307 }
308
309 #[cfg(test)]
310 mod tests {
311 use super::*;
312 use std::sync::Mutex;
313
314 /// Serialize tests that read or write the `ENABLED` flag so they don't
315 /// race each other under cargo's default parallel test runner.
316 static FLAG_GUARD: Mutex<()> = Mutex::new(());
317
318 fn strip(s: &str) -> String {
319 let mut out = String::with_capacity(s.len());
320 strip_into(s, &mut out);
321 out
322 }
323
324 fn wrapped_link(target: &str, label: &str) -> String {
325 format!("{OSC8_PREFIX}{target}{OSC8_TERMINATOR}{label}{OSC8_CLOSE}")
326 }
327
328 #[test]
329 fn wrapped_link_fixture_is_osc_8_compliant() {
330 let wrapped = wrapped_link("https://example.com", "click me");
331 assert_eq!(
332 wrapped,
333 "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"
334 );
335 }
336
337 #[test]
338 fn strip_removes_wrapper_keeps_label() {
339 let wrapped = wrapped_link("https://example.com", "click me");
340 assert_eq!(strip(&wrapped), "click me");
341 }
342
343 #[test]
344 fn strip_handles_bel_terminator() {
345 let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07";
346 assert_eq!(strip(wrapped), "click me");
347 }
348
349 #[test]
350 fn strip_passes_through_text_with_no_escapes() {
351 let plain = "no escapes here";
352 assert_eq!(strip(plain), plain);
353 }
354
355 #[test]
356 fn strip_preserves_non_osc_8_escapes() {
357 // Color escape stays in place; only OSC 8 wrappers are removed.
358 let mixed = format!(
359 "\x1b[31mred\x1b[0m {wrapped}",
360 wrapped = wrapped_link("https://example.com", "click")
361 );
362 assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click");
363 }
364
365 fn strip_ansi(s: &str) -> String {
366 let mut out = String::with_capacity(s.len());
367 strip_ansi_into(s, &mut out);
368 out
369 }
370
371 #[test]
372 fn strip_ansi_removes_csi_sgr_and_keeps_text() {
373 let coloured = "526 \x1b[1;32mOPEN\x1b[0m bug fix";
374 assert_eq!(strip_ansi(coloured), "526 OPEN bug fix");
375 }
376
377 #[test]
378 fn strip_keep_sgr_keeps_colour_and_drops_everything_else() {
379 let mut out = String::new();
380 strip_ansi_keep_sgr_into(
381 "\x1b]8;;https://x\x07\x1b[1;32mok\x1b[0m\x1b]8;;\x07\x1b[2K\x1b[?25l tail",
382 &mut out,
383 );
384 assert_eq!(out, "\x1b[1;32mok\x1b[0m tail");
385 let mut plain = String::new();
386 strip_ansi_into(&out, &mut plain);
387 assert_eq!(plain, "ok tail");
388 }
389
390 #[test]
391 fn strip_ansi_removes_osc_8_wrapper() {
392 let wrapped = wrapped_link("https://example.com", "click");
393 assert_eq!(strip_ansi(&wrapped), "click");
394 }
395
396 #[test]
397 fn strip_ansi_preserves_newlines_tabs_and_cr() {
398 let s = "a\nb\tc\rd";
399 assert_eq!(strip_ansi(s), "a\nb\tc\rd");
400 }
401
402 #[test]
403 fn strip_ansi_drops_lone_control_bytes() {
404 // Bare BEL or other C0 control bytes that aren't \n/\r/\t are dropped
405 // so they can't paint as visible cells.
406 let s = "a\x07b\x01c";
407 assert_eq!(strip_ansi(s), "abc");
408 }
409
410 #[test]
411 fn strip_ansi_preserves_utf8_multibyte_chars() {
412 // CJK, accented Latin, and emoji must survive the strip without being
413 // re-decoded as Latin-1 (which would explode 你 -> ä½ ).
414 let s = "Phase 1: 第一步 README é 🚀";
415 assert_eq!(strip_ansi(s), "Phase 1: 第一步 README é 🚀");
416
417 let coloured = "\x1b[1;32m第一步\x1b[0m done";
418 assert_eq!(strip_ansi(coloured), "第一步 done");
419 }
420
421 #[test]
422 fn strip_preserves_utf8_multibyte_chars() {
423 let wrapped = wrapped_link("https://example.com", "点击我");
424 assert_eq!(strip(&wrapped), "点击我");
425 }
426
427 #[test]
428 fn open_sequence_percent_encodes_target_control_injection() {
429 let target = "https://safe.test/a\x07b\x1b]8;;https://evil.test\x1b\\c\x7f\u{009c}";
430 let mut bytes = Vec::new();
431 write_osc8_open(&mut bytes, target).expect("write OSC 8 open");
432 let rendered = String::from_utf8(bytes.clone()).expect("valid UTF-8 output");
433
434 assert_eq!(rendered.matches(OSC8_PREFIX).count(), 1, "{rendered:?}");
435 assert_eq!(rendered.matches(OSC8_TERMINATOR).count(), 1, "{rendered:?}");
436 assert_eq!(bytes.iter().filter(|byte| **byte == 0x1b).count(), 2);
437 assert!(!bytes.contains(&0x07), "BEL escaped: {rendered:?}");
438 assert!(!bytes.contains(&0x7f), "DEL escaped: {rendered:?}");
439 assert!(
440 rendered.contains("a%07b%1B]8;;https://evil.test%1B\\c%7F%C2%9C"),
441 "control bytes must be percent-encoded: {rendered:?}"
442 );
443 }
444
445 #[test]
446 fn enabled_is_true_by_default_when_untouched() {
447 // Hold the flag guard so we observe the initial state, not a value
448 // mid-flight from `set_enabled_round_trips`. The flag *defaults* to
449 // true at static init and tests in this module are the only writers.
450 let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
451 assert!(enabled());
452 }
453
454 #[test]
455 fn set_enabled_round_trips() {
456 let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
457 let prior = enabled();
458 set_enabled(false);
459 assert!(!enabled());
460 set_enabled(true);
461 assert!(enabled());
462 set_enabled(prior);
463 }
464
465 #[test]
466 fn line_links_translate_to_absolute_clipped_regions() {
467 let area = ratatui::layout::Rect::new(7, 3, 8, 2);
468 let links = vec![
469 vec![
470 LineLink {
471 col_start: 2,
472 col_end: 20,
473 target: "https://example.test/long".to_string(),
474 },
475 LineLink {
476 col_start: 8,
477 col_end: 9,
478 target: "outside".to_string(),
479 },
480 ],
481 vec![LineLink {
482 col_start: 0,
483 col_end: 1,
484 target: "https://example.test/next".to_string(),
485 }],
486 vec![LineLink {
487 col_start: 0,
488 col_end: 0,
489 target: "below viewport".to_string(),
490 }],
491 ];
492
493 assert_eq!(
494 link_regions_for_lines(area, &links),
495 vec![
496 LinkRegion {
497 row: 3,
498 col_start: 9,
499 col_end: 14,
500 target: "https://example.test/long".to_string(),
501 },
502 LinkRegion {
503 row: 4,
504 col_start: 7,
505 col_end: 8,
506 target: "https://example.test/next".to_string(),
507 },
508 ]
509 );
510 }
511
512 #[test]
513 fn opaque_overlay_replaces_and_clips_underlying_regions() {
514 set_frame_links(vec![
515 LinkRegion {
516 row: 4,
517 col_start: 0,
518 col_end: 20,
519 target: "under-wide".to_string(),
520 },
521 LinkRegion {
522 row: 5,
523 col_start: 6,
524 col_end: 8,
525 target: "under-covered".to_string(),
526 },
527 ]);
528 overlay_frame_links(
529 ratatui::layout::Rect::new(5, 4, 10, 2),
530 vec![LinkRegion {
531 row: 4,
532 col_start: 7,
533 col_end: 8,
534 target: "modal".to_string(),
535 }],
536 );
537
538 assert_eq!(
539 take_frame_links(),
540 vec![
541 LinkRegion {
542 row: 4,
543 col_start: 0,
544 col_end: 4,
545 target: "under-wide".to_string(),
546 },
547 LinkRegion {
548 row: 4,
549 col_start: 15,
550 col_end: 20,
551 target: "under-wide".to_string(),
552 },
553 LinkRegion {
554 row: 4,
555 col_start: 7,
556 col_end: 8,
557 target: "modal".to_string(),
558 },
559 ]
560 );
561 }
562 }
563
563 lines RUST