返回 DeepSeek-TUI-2026
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 //! The TUI emits these inside `Span::content` strings so the existing
16 //! ratatui pipeline carries them through. The tradeoff is that the clipboard
17 //! / selection extraction path must strip the codes before handing text to the
18 //! user — that's what [`strip_into`] is for.
19
20 use std::sync::atomic::{AtomicBool, Ordering};
21
22 const OSC8_PREFIX: &str = "\x1b]8;;";
23 const OSC8_TERMINATOR: &str = "\x1b\\";
24
25 /// Process-wide enable flag. `true` by default. Set once at app init from
26 /// `[ui] osc8_links` (when present) and read by the renderer.
27 static ENABLED: AtomicBool = AtomicBool::new(true);
28
29 /// Set the process-wide OSC 8 enable flag. Intended to be called once at
30 /// startup; subsequent calls take effect immediately.
31 pub fn set_enabled(enabled: bool) {
32 ENABLED.store(enabled, Ordering::Relaxed);
33 }
34
35 /// Whether OSC 8 hyperlink emission is currently enabled.
36 #[must_use]
37 pub fn enabled() -> bool {
38 ENABLED.load(Ordering::Relaxed)
39 }
40
41 /// Wrap `label` so it links to `target` in OSC 8-aware terminals. The returned
42 /// string contains the full `\x1b]8;;TARGET\x1b\LABEL\x1b]8;;\x1b\` payload.
43 ///
44 /// Does **not** check [`enabled()`]; callers wanting the runtime gate should
45 /// branch on it before calling this. That keeps the helper test-friendly.
46 #[must_use]
47 pub fn wrap_link(target: &str, label: &str) -> String {
48 let mut out = String::with_capacity(target.len() + label.len() + 12);
49 out.push_str(OSC8_PREFIX);
50 out.push_str(target);
51 out.push_str(OSC8_TERMINATOR);
52 out.push_str(label);
53 out.push_str(OSC8_PREFIX);
54 out.push_str(OSC8_TERMINATOR);
55 out
56 }
57
58 /// Strip every ANSI escape sequence from `s` into `out`, preserving only the
59 /// visible characters. ratatui's buffer drops the leading `ESC` byte but
60 /// happily paints every other byte of an escape (`[`, `0`, `;`, `m`, OSC
61 /// payloads, etc.) into a buffer cell, drifting columns. Tool stdout that
62 /// includes ANSI (e.g. `gh`/`git` with color forced on, anything run through
63 /// a PTY) must be sanitized before it enters the transcript.
64 ///
65 /// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS,
66 /// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers
67 /// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest.
68 pub fn strip_ansi_into(s: &str, out: &mut String) {
69 let bytes = s.as_bytes();
70 let mut i = 0;
71 while i < bytes.len() {
72 if bytes[i] == 0x1b && i + 1 < bytes.len() {
73 let next = bytes[i + 1];
74 match next {
75 // CSI: ESC [ ... <final byte 0x40..=0x7E>
76 b'[' => {
77 let mut j = i + 2;
78 while j < bytes.len() {
79 let b = bytes[j];
80 if (0x40..=0x7e).contains(&b) {
81 j += 1;
82 break;
83 }
84 j += 1;
85 }
86 i = j;
87 continue;
88 }
89 // OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL
90 b']' | b'P' | b'X' | b'^' | b'_' => {
91 let mut j = i + 2;
92 while j < bytes.len() {
93 if bytes[j] == 0x07 {
94 j += 1;
95 break;
96 }
97 if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
98 j += 2;
99 break;
100 }
101 j += 1;
102 }
103 i = j;
104 continue;
105 }
106 // Standalone two-byte ESC sequence (RIS, charset selection, etc.)
107 _ => {
108 i += 2;
109 continue;
110 }
111 }
112 }
113 // Strip lone control bytes that ratatui would otherwise drop (and which
114 // mean nothing in transcript output) but keep \n, \r, \t as legitimate
115 // formatting.
116 let b = bytes[i];
117 if b < 0x80 {
118 if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' {
119 i += 1;
120 continue;
121 }
122 out.push(b as char);
123 i += 1;
124 } else {
125 // UTF-8 multi-byte sequence: copy the whole code point intact.
126 // Pushing `b as char` would mis-decode it as Latin-1 and mangle
127 // non-ASCII text (CJK, accented Latin, emoji, …).
128 let len = utf8_seq_len(b);
129 let end = (i + len).min(bytes.len());
130 if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
131 out.push_str(chunk);
132 }
133 i = end;
134 }
135 }
136 }
137
138 /// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back
139 /// to `1` for continuation bytes / invalid leads so callers always make
140 /// forward progress.
141 fn utf8_seq_len(lead: u8) -> usize {
142 if lead < 0xc0 {
143 1
144 } else if lead < 0xe0 {
145 2
146 } else if lead < 0xf0 {
147 3
148 } else {
149 4
150 }
151 }
152
153 /// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
154 /// label text. Other escapes (color, style) pass through untouched. The
155 /// implementation handles both the standard `ESC \` and the lone `BEL`
156 /// terminators that some emitters use.
157 pub fn strip_into(s: &str, out: &mut String) {
158 let bytes = s.as_bytes();
159 let mut i = 0;
160 while i < bytes.len() {
161 // Look for the OSC 8 prefix `ESC ] 8 ;`
162 if i + 4 <= bytes.len()
163 && bytes[i] == 0x1b
164 && bytes[i + 1] == b']'
165 && bytes[i + 2] == b'8'
166 && bytes[i + 3] == b';'
167 {
168 // Skip until the string terminator (ESC \) or BEL.
169 let mut j = i + 4;
170 while j < bytes.len() {
171 if bytes[j] == 0x07 {
172 j += 1;
173 break;
174 }
175 if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
176 j += 2;
177 break;
178 }
179 j += 1;
180 }
181 i = j;
182 continue;
183 }
184 let b = bytes[i];
185 if b < 0x80 {
186 out.push(b as char);
187 i += 1;
188 } else {
189 let len = utf8_seq_len(b);
190 let end = (i + len).min(bytes.len());
191 if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
192 out.push_str(chunk);
193 }
194 i = end;
195 }
196 }
197 }
198
199 #[cfg(test)]
200 mod tests {
201 use super::*;
202 use std::sync::Mutex;
203
204 /// Serialize tests that read or write the `ENABLED` flag so they don't
205 /// race each other under cargo's default parallel test runner.
206 static FLAG_GUARD: Mutex<()> = Mutex::new(());
207
208 fn strip(s: &str) -> String {
209 let mut out = String::with_capacity(s.len());
210 strip_into(s, &mut out);
211 out
212 }
213
214 #[test]
215 fn wrap_link_shape_is_osc_8_compliant() {
216 let wrapped = wrap_link("https://example.com", "click me");
217 assert_eq!(
218 wrapped,
219 "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"
220 );
221 }
222
223 #[test]
224 fn strip_removes_wrapper_keeps_label() {
225 let wrapped = wrap_link("https://example.com", "click me");
226 assert_eq!(strip(&wrapped), "click me");
227 }
228
229 #[test]
230 fn strip_handles_bel_terminator() {
231 let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07";
232 assert_eq!(strip(wrapped), "click me");
233 }
234
235 #[test]
236 fn strip_passes_through_text_with_no_escapes() {
237 let plain = "no escapes here";
238 assert_eq!(strip(plain), plain);
239 }
240
241 #[test]
242 fn strip_preserves_non_osc_8_escapes() {
243 // Color escape stays in place; only OSC 8 wrappers are removed.
244 let mixed = format!(
245 "\x1b[31mred\x1b[0m {wrapped}",
246 wrapped = wrap_link("https://example.com", "click")
247 );
248 assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click");
249 }
250
251 fn strip_ansi(s: &str) -> String {
252 let mut out = String::with_capacity(s.len());
253 strip_ansi_into(s, &mut out);
254 out
255 }
256
257 #[test]
258 fn strip_ansi_removes_csi_sgr_and_keeps_text() {
259 let coloured = "526 \x1b[1;32mOPEN\x1b[0m bug fix";
260 assert_eq!(strip_ansi(coloured), "526 OPEN bug fix");
261 }
262
263 #[test]
264 fn strip_ansi_removes_osc_8_wrapper() {
265 let wrapped = wrap_link("https://example.com", "click");
266 assert_eq!(strip_ansi(&wrapped), "click");
267 }
268
269 #[test]
270 fn strip_ansi_preserves_newlines_tabs_and_cr() {
271 let s = "a\nb\tc\rd";
272 assert_eq!(strip_ansi(s), "a\nb\tc\rd");
273 }
274
275 #[test]
276 fn strip_ansi_drops_lone_control_bytes() {
277 // Bare BEL or other C0 control bytes that aren't \n/\r/\t are dropped
278 // so they can't paint as visible cells.
279 let s = "a\x07b\x01c";
280 assert_eq!(strip_ansi(s), "abc");
281 }
282
283 #[test]
284 fn strip_ansi_preserves_utf8_multibyte_chars() {
285 // CJK, accented Latin, and emoji must survive the strip without being
286 // re-decoded as Latin-1 (which would explode 你 -> ä½ ).
287 let s = "Phase 1: 第一步 README é 🚀";
288 assert_eq!(strip_ansi(s), "Phase 1: 第一步 README é 🚀");
289
290 let coloured = "\x1b[1;32m第一步\x1b[0m done";
291 assert_eq!(strip_ansi(coloured), "第一步 done");
292 }
293
294 #[test]
295 fn strip_preserves_utf8_multibyte_chars() {
296 let wrapped = wrap_link("https://example.com", "点击我");
297 assert_eq!(strip(&wrapped), "点击我");
298 }
299
300 #[test]
301 fn enabled_is_true_by_default_when_untouched() {
302 // Hold the flag guard so we observe the initial state, not a value
303 // mid-flight from `set_enabled_round_trips`. The flag *defaults* to
304 // true at static init and tests in this module are the only writers.
305 let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
306 assert!(enabled());
307 }
308
309 #[test]
310 fn set_enabled_round_trips() {
311 let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
312 let prior = enabled();
313 set_enabled(false);
314 assert!(!enabled());
315 set_enabled(true);
316 assert!(enabled());
317 set_enabled(prior);
318 }
319 }
320
320 lines RUST