返回 CodeWhale
osc11.rs
1 //! OSC 11 terminal-background query.
2 //!
3 //! `COLORFGBG` is the only background signal the palette had before this
4 //! module, and most modern terminals never set it — Windows Terminal, conhost,
5 //! VS Code, GNOME Terminal, Alacritty and Ghostty all omit it. Without it a
6 //! white terminal was indistinguishable from a black one, so detection fell
7 //! back to `Dark` and painted dark-tuned text onto a light surface (#4833).
8 //!
9 //! OSC 11 (`ESC ] 11 ; ? BEL`) asks the terminal for its actual background
10 //! color and is answered by every terminal listed above. The reply is an
11 //! `xterm`-style color spec, e.g.
12 //!
13 //! ```text
14 //! ESC ] 11 ; rgb:ffff/ffff/ffff ESC \
15 //! ```
16 //!
17 //! The parse is a pure function so it can be tested without a terminal; the
18 //! query itself is Unix-only, bounded by a short deadline, and never runs when
19 //! stdin/stdout are not both TTYs.
20 //!
21 //! Extracted from the Codewhale engine's `crates/palette/src/osc11.rs`
22 //! (`Hmbown/CodeWhale` `58b1dd3dd`), including the type-ahead carry from
23 //! #5925: bytes the user typed while the probe was reading are handed back,
24 //! never dropped. Hosts replay them with [`take_carried_type_ahead`].
25
26 /// Upper bound on how long startup will wait for a terminal that never
27 /// answers. A terminal that supports OSC 11 replies in well under a
28 /// millisecond; anything past this is a terminal that will never reply, and
29 /// startup latency matters more than the answer.
30 pub const OSC11_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(120);
31
32 /// The query sequence. `ESC \` (ST) is the terminator we prefer in the reply,
33 /// but terminals may answer with BEL instead, so the reader accepts both.
34 ///
35 /// Only the Unix query path writes it — see the note on [`parse_osc11_reply`].
36 #[cfg_attr(not(unix), allow(dead_code))]
37 const OSC11_QUERY: &[u8] = b"\x1b]11;?\x1b\\";
38
39 /// Extract an RGB triple from an OSC 11 reply body.
40 ///
41 /// Accepts the shapes terminals actually emit:
42 /// - `rgb:RRRR/GGGG/BBBB` (xterm, 1–4 hex digits per channel, any width)
43 /// - `#RRGGBB` / `#RGB` / `#RRRRGGGGBBBB`
44 ///
45 /// Leading `ESC ] 11 ;` and the trailing BEL/ST are optional — anything
46 /// outside the color spec is ignored, so a reply that arrived interleaved with
47 /// other terminal chatter still parses.
48 ///
49 /// Returns `None` when no color spec is present or a channel is malformed.
50 /// Channels wider than 8 bits are scaled down, not truncated, so `ffff` is
51 /// `255` rather than `0`.
52 // The parser is deliberately cross-platform while the query is Unix-only:
53 // there is no portable way to read a raw OSC reply off a Windows console
54 // handle yet, so on Windows nothing calls these. They are kept (rather than
55 // cfg'd out) because they are pure, fully tested on every platform, and are
56 // exactly what a future Windows read path would need — but that leaves them
57 // dead in a non-test Windows build, which `-D warnings` rejects.
58 #[cfg_attr(not(unix), allow(dead_code))]
59 #[must_use]
60 pub fn parse_osc11_reply(reply: &str) -> Option<(u8, u8, u8)> {
61 if let Some(idx) = reply.find("rgb:") {
62 return parse_slash_separated(&reply[idx + 4..]);
63 }
64 if let Some(idx) = reply.find('#') {
65 return parse_hash_hex(&reply[idx + 1..]);
66 }
67 None
68 }
69
70 #[cfg_attr(not(unix), allow(dead_code))]
71 fn parse_slash_separated(spec: &str) -> Option<(u8, u8, u8)> {
72 let spec: String = spec
73 .chars()
74 .take_while(|c| c.is_ascii_hexdigit() || *c == '/')
75 .collect();
76 let mut parts = spec.split('/');
77 let r = scale_hex_channel(parts.next()?)?;
78 let g = scale_hex_channel(parts.next()?)?;
79 let b = scale_hex_channel(parts.next()?)?;
80 if parts.next().is_some() {
81 return None;
82 }
83 Some((r, g, b))
84 }
85
86 #[cfg_attr(not(unix), allow(dead_code))]
87 fn parse_hash_hex(spec: &str) -> Option<(u8, u8, u8)> {
88 let digits: String = spec.chars().take_while(char::is_ascii_hexdigit).collect();
89 if !digits.len().is_multiple_of(3) || digits.is_empty() || digits.len() > 12 {
90 return None;
91 }
92 let width = digits.len() / 3;
93 let r = scale_hex_channel(&digits[..width])?;
94 let g = scale_hex_channel(&digits[width..width * 2])?;
95 let b = scale_hex_channel(&digits[width * 2..])?;
96 Some((r, g, b))
97 }
98
99 /// Normalize a hex channel of arbitrary width (1–4 digits) to 8 bits by
100 /// rescaling across the channel's full range: `f` → `255`, `ffff` → `255`,
101 /// `8000` → `128`.
102 #[cfg_attr(not(unix), allow(dead_code))]
103 fn scale_hex_channel(digits: &str) -> Option<u8> {
104 if digits.is_empty() || digits.len() > 4 || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
105 return None;
106 }
107 let value = u32::from_str_radix(digits, 16).ok()?;
108 let max = (1u32 << (4 * digits.len() as u32)) - 1;
109 Some(((value * 255 + max / 2) / max) as u8)
110 }
111
112 /// Ask the terminal for its background color, giving up after `timeout`.
113 ///
114 /// Returns `None` — never blocks past `timeout`, never panics — when:
115 /// - stdin and stdout are not both TTYs (piped output, CI, `codewhale < file`),
116 /// - the platform has no supported query path (non-Unix; see the module docs),
117 /// - the terminal does not answer, or answers with something unparsable.
118 ///
119 /// # Caveat
120 ///
121 /// This reads from stdin, so it must only be called while the terminal is in
122 /// raw mode and before the event loop starts. Bytes that arrive during the
123 /// window and are not part of the reply are *not* discarded: they are the
124 /// user's type-ahead, and are handed to
125 /// [`carry_typed_ahead`] for the event loop to
126 /// replay in order (#5925).
127 #[must_use]
128 pub fn query_terminal_background(timeout: std::time::Duration) -> Option<(u8, u8, u8)> {
129 let reply = query_terminal(OSC11_QUERY, timeout)?;
130 parse_osc11_reply(&String::from_utf8_lossy(&reply))
131 }
132
133 /// Write `query` to the terminal and read back one reply, giving up after
134 /// `timeout`. The reply is the bytes up to (not including) its BEL or `ESC \`
135 /// terminator; an `ESC` that opens the reply is kept. Shared by the OSC 11
136 /// background query and a host's graphics probes, under the
137 /// same caveat as [`query_terminal_background`]: raw mode on, event loop not
138 /// yet reading stdin.
139 #[cfg(unix)]
140 pub fn query_terminal(query: &[u8], timeout: std::time::Duration) -> Option<Vec<u8>> {
141 query_terminal_inner(query, timeout, false)
142 }
143
144 /// CSI-terminated variant of [`query_terminal`] for a primary-DA probe
145 /// (sixel detection): a primary-DA reply ends at its alphabetic final byte
146 /// (`c`), which is neither BEL nor `ESC \`, so the plain reader would keep
147 /// swallowing input — including the user's own typed-ahead keystrokes —
148 /// until its byte cap. Stops after the final byte of a reply that opened
149 /// with `ESC [` and keeps the same raw-mode caveat.
150 #[cfg(unix)]
151 pub fn query_terminal_csi(query: &[u8], timeout: std::time::Duration) -> Option<Vec<u8>> {
152 query_terminal_inner(query, timeout, true)
153 }
154
155 /// Largest reply any of the three probes can produce. Past this the answer
156 /// is not one of ours.
157 const MAX_REPLY_BYTES: usize = 128;
158
159 // ---------------------------------------------------------------------------
160 // Type-ahead carried across the probe window (#5925).
161 //
162 // The probe readers below are the only readers of the tty between raw-mode
163 // entry and the input pump, so anything the user typed at launch arrives in
164 // the same stream as the replies. The reader keeps its reply and parks every
165 // other byte here; the host drains this and replays it into its input. The buffers live in this module rather than with the replay
166 // logic so any host can drain them without depending on its event loop.
167 // ---------------------------------------------------------------------------
168
169 /// Upper bound on carried type-ahead. A terminal that answers a probe does
170 /// so in well under a millisecond, so this only ever holds a line or two;
171 /// the cap stops a wedged tty from growing the buffer without limit.
172 pub const MAX_CARRIED_BYTES: usize = 4096;
173
174 /// Bytes a probe consumed that were not part of its reply.
175 static CARRIED_TYPE_AHEAD: std::sync::Mutex<Vec<u8>> = std::sync::Mutex::new(Vec::new());
176 /// Bytes a probe consumed that cannot be replayed as keystrokes.
177 static CONSUMED_UNREPLAYABLE: std::sync::Mutex<Vec<u8>> = std::sync::Mutex::new(Vec::new());
178
179 /// Park non-reply bytes for the event loop to replay.
180 #[cfg_attr(not(unix), allow(dead_code))]
181 pub fn carry_typed_ahead(bytes: &[u8]) {
182 if bytes.is_empty() {
183 return;
184 }
185 let Ok(mut carried) = CARRIED_TYPE_AHEAD.lock() else {
186 note_consumed_unreplayable(bytes);
187 return;
188 };
189 let room = MAX_CARRIED_BYTES.saturating_sub(carried.len());
190 let (keep, overflow) = bytes.split_at(room.min(bytes.len()));
191 carried.extend_from_slice(keep);
192 drop(carried);
193 note_consumed_unreplayable(overflow);
194 }
195
196 /// Record bytes startup consumed and cannot hand back.
197 pub fn note_consumed_unreplayable(bytes: &[u8]) {
198 if bytes.is_empty() {
199 return;
200 }
201 if let Ok(mut dropped) = CONSUMED_UNREPLAYABLE.lock() {
202 let room = MAX_CARRIED_BYTES.saturating_sub(dropped.len());
203 dropped.extend_from_slice(&bytes[..room.min(bytes.len())]);
204 }
205 }
206
207 /// Take everything parked by [`carry_typed_ahead`].
208 pub fn take_carried_type_ahead() -> Vec<u8> {
209 CARRIED_TYPE_AHEAD
210 .lock()
211 .map(|mut carried| std::mem::take(&mut *carried))
212 .unwrap_or_default()
213 }
214
215 /// Take everything recorded by [`note_consumed_unreplayable`].
216 pub fn take_consumed_unreplayable() -> Vec<u8> {
217 CONSUMED_UNREPLAYABLE
218 .lock()
219 .map(|mut dropped| std::mem::take(&mut *dropped))
220 .unwrap_or_default()
221 }
222
223 /// What the caller should do after feeding one byte to [`ProbeSplit`].
224 #[cfg_attr(not(unix), allow(dead_code))]
225 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
226 pub(crate) enum ProbeStep {
227 /// Keep reading.
228 Continue,
229 /// The reply is complete.
230 Done,
231 /// The reply ended with the `ESC` of an `ESC \` string terminator: read
232 /// one more byte and give it to [`ProbeSplit::finish_string_terminator`].
233 AwaitStringTerminator,
234 /// Carried type-ahead hit its cap; stop reading.
235 Overflow,
236 }
237
238 /// Splits one probe stream into the terminal's reply and the user's
239 /// type-ahead (#5925).
240 ///
241 /// Pure and byte-driven so the split — the actual defect in #5925, where
242 /// everything that was not the reply was thrown away — is testable without a
243 /// terminal. The reply always opens with the same two bytes the query did
244 /// (`ESC ]` for OSC 11, `ESC _` for the kitty graphics query, `ESC [` for the
245 /// sixel primary-DA query); anything before that introducer is input the user
246 /// typed, and an `ESC` that does not go on to match it is theirs too.
247 #[cfg_attr(not(unix), allow(dead_code))]
248 #[derive(Debug)]
249 pub(crate) struct ProbeSplit<'a> {
250 introducer: &'a [u8],
251 stop_at_csi_final: bool,
252 carried: Vec<u8>,
253 /// A partial introducer match, still undecided between reply and input.
254 undecided: Vec<u8>,
255 reply: Vec<u8>,
256 in_reply: bool,
257 }
258
259 #[cfg_attr(not(unix), allow(dead_code))]
260 impl<'a> ProbeSplit<'a> {
261 /// `query` is the sequence just written; its first two bytes are the
262 /// introducer the reply will open with.
263 pub(crate) fn for_query(query: &'a [u8], stop_at_csi_final: bool) -> Self {
264 Self {
265 introducer: if query.len() >= 2 && query[0] == 0x1b {
266 &query[..2]
267 } else {
268 &[]
269 },
270 stop_at_csi_final,
271 carried: Vec::new(),
272 undecided: Vec::new(),
273 reply: Vec::new(),
274 in_reply: false,
275 }
276 }
277
278 pub(crate) fn feed(&mut self, byte: u8) -> ProbeStep {
279 if !self.in_reply {
280 self.feed_before_reply(byte);
281 if self.carried.len() >= MAX_CARRIED_BYTES {
282 return ProbeStep::Overflow;
283 }
284 return ProbeStep::Continue;
285 }
286 // BEL, or the ESC of an `ESC \` string terminator, ends the reply.
287 if byte == 0x07 {
288 return ProbeStep::Done;
289 }
290 if byte == 0x1b {
291 return ProbeStep::AwaitStringTerminator;
292 }
293 self.reply.push(byte);
294 // A CSI reply (`ESC [` …) ends at its first final byte (`@..=~`):
295 // keep the final and stop, so a DA answer never eats past itself.
296 if self.stop_at_csi_final
297 && self.reply.len() >= 3
298 && self.reply[0] == 0x1b
299 && self.reply[1] == b'['
300 && (0x40..=0x7e).contains(&byte)
301 {
302 return ProbeStep::Done;
303 }
304 if self.reply.len() >= MAX_REPLY_BYTES {
305 return ProbeStep::Overflow;
306 }
307 ProbeStep::Continue
308 }
309
310 fn feed_before_reply(&mut self, byte: u8) {
311 if self.undecided.is_empty() {
312 if byte == 0x1b && !self.introducer.is_empty() {
313 self.undecided.push(byte);
314 } else {
315 self.carried.push(byte);
316 }
317 return;
318 }
319 self.undecided.push(byte);
320 if self.introducer.starts_with(&self.undecided) {
321 if self.undecided.len() == self.introducer.len() {
322 self.in_reply = true;
323 self.reply = std::mem::take(&mut self.undecided);
324 }
325 return;
326 }
327 // Not our reply after all — an `Esc` keypress, or an escape sequence
328 // from some other source. Everything held is the user's, except a
329 // fresh `ESC` which may still open the reply we are waiting for.
330 let restarts = byte == 0x1b;
331 if restarts {
332 self.undecided.pop();
333 }
334 self.carried.append(&mut self.undecided);
335 if restarts {
336 self.undecided.push(byte);
337 }
338 }
339
340 /// The byte read after an [`ProbeStep::AwaitStringTerminator`]. The `\`
341 /// of `ESC \` belongs to the reply; anything else is the user's next
342 /// keystroke and must not vanish with the terminator.
343 pub(crate) fn finish_string_terminator(&mut self, byte: u8) {
344 if byte != b'\\' {
345 self.carried.push(byte);
346 }
347 }
348
349 /// Consume the split: `(reply, carried type-ahead)`. An undecided
350 /// introducer is input the terminal never claimed.
351 pub(crate) fn finish(mut self) -> (Vec<u8>, Vec<u8>) {
352 self.carried.append(&mut self.undecided);
353 (self.reply, self.carried)
354 }
355 }
356
357 /// Read a probe reply off stdin without eating the user's type-ahead.
358 ///
359 /// The reply always opens with the same two bytes the query did (`ESC ]` for
360 /// OSC 11, `ESC _` for the kitty graphics query, `ESC [` for the sixel
361 /// primary-DA query), so every byte before that introducer — and the one
362 /// lookahead byte after an `ESC` that turned out not to open `ESC \` — is
363 /// input the user typed, not terminal chatter. Those bytes are carried to
364 /// [`carry_typed_ahead`] for replay instead of being dropped on the
365 /// floor (#5925). Bytes this reader consumed but cannot hand back (a reply
366 /// the terminal never terminated) are recorded as dropped so the startup
367 /// receipt names them.
368 #[cfg(unix)]
369 fn query_terminal_inner(
370 query: &[u8],
371 timeout: std::time::Duration,
372 stop_at_csi_final: bool,
373 ) -> Option<Vec<u8>> {
374 use std::io::Write;
375 use std::os::fd::AsRawFd;
376
377 let stdin = std::io::stdin();
378 let stdout = std::io::stdout();
379 let in_fd = stdin.as_raw_fd();
380 let out_fd = stdout.as_raw_fd();
381
382 // SAFETY: `isatty` only inspects the descriptor; both fds are owned by the
383 // std handles held above for the duration of the call.
384 let both_tty = unsafe { libc::isatty(in_fd) == 1 && libc::isatty(out_fd) == 1 };
385 if !both_tty {
386 return None;
387 }
388
389 {
390 let mut out = stdout.lock();
391 out.write_all(query).ok()?;
392 out.flush().ok()?;
393 }
394
395 settle_terminal_reply(in_fd, query, timeout, stop_at_csi_final)
396 }
397
398 /// Read the reply to a query already written, park the user's type-ahead for
399 /// replay, and account for anything consumed that cannot be replayed.
400 #[cfg(unix)]
401 fn settle_terminal_reply(
402 in_fd: std::os::fd::RawFd,
403 query: &[u8],
404 timeout: std::time::Duration,
405 stop_at_csi_final: bool,
406 ) -> Option<Vec<u8>> {
407 let (answered, reply, carried) = read_terminal_reply(in_fd, query, timeout, stop_at_csi_final);
408 carry_typed_ahead(&carried);
409 if !answered {
410 // An incomplete control reply is not safe to replay as typing.
411 note_consumed_unreplayable(&reply);
412 return None;
413 }
414 Some(reply)
415 }
416
417 #[cfg(unix)]
418 fn read_terminal_reply(
419 in_fd: std::os::fd::RawFd,
420 query: &[u8],
421 timeout: std::time::Duration,
422 stop_at_csi_final: bool,
423 ) -> (bool, Vec<u8>, Vec<u8>) {
424 use std::time::Instant;
425
426 let deadline = Instant::now() + timeout;
427 let mut split = ProbeSplit::for_query(query, stop_at_csi_final);
428 let answered = loop {
429 let Some(byte) = read_terminal_byte(in_fd, deadline) else {
430 break false;
431 };
432 match split.feed(byte) {
433 ProbeStep::Continue => {}
434 ProbeStep::Done => break true,
435 ProbeStep::Overflow => break false,
436 ProbeStep::AwaitStringTerminator => {
437 // Consume the `\` of an `ESC \` terminator so it cannot
438 // surface later as a keypress once the event loop owns
439 // stdin. Anything else is the user's next keystroke.
440 let terminator_deadline = Instant::now() + std::time::Duration::from_millis(5);
441 if let Some(byte) = read_terminal_byte(in_fd, terminator_deadline) {
442 split.finish_string_terminator(byte);
443 }
444 break true;
445 }
446 }
447 };
448
449 let (reply, carried) = split.finish();
450 (answered, reply, carried)
451 }
452
453 /// Poll and read the same unbuffered descriptor. `StdinLock` reads ahead into
454 /// Rust's shared buffer: polling the tty afterward misses those bytes, and
455 /// crossterm's later fd reader cannot recover them either.
456 #[cfg(unix)]
457 fn read_terminal_byte(fd: std::os::fd::RawFd, deadline: std::time::Instant) -> Option<u8> {
458 loop {
459 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
460 if remaining.is_zero() || !wait_readable(fd, remaining) {
461 return None;
462 }
463 let mut byte = 0u8;
464 // SAFETY: the caller owns the open fd throughout the startup probe;
465 // `byte` is writable for exactly the single byte requested. The input
466 // pump has not started, so there is no competing reader.
467 let count = unsafe { libc::read(fd, std::ptr::addr_of_mut!(byte).cast(), 1) };
468 if count == 1 {
469 return Some(byte);
470 }
471 if count != -1 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
472 {
473 return None;
474 }
475 }
476 }
477
478 #[cfg(all(test, unix))]
479 #[path = "osc11_tests.rs"]
480 mod tests;
481
482 /// Block until `fd` has data or `timeout` elapses. `true` means readable.
483 #[cfg(unix)]
484 fn wait_readable(fd: std::os::fd::RawFd, timeout: std::time::Duration) -> bool {
485 let mut pollfd = libc::pollfd {
486 fd,
487 events: libc::POLLIN,
488 revents: 0,
489 };
490 let millis = i32::try_from(timeout.as_millis())
491 .unwrap_or(i32::MAX)
492 .max(1);
493 // SAFETY: `pollfd` is a live, correctly-initialized single-element array
494 // and the count matches.
495 let rc = unsafe { libc::poll(std::ptr::addr_of_mut!(pollfd), 1, millis) };
496 rc > 0 && (pollfd.revents & libc::POLLIN) != 0
497 }
498
499 /// Non-Unix platforms have no portable way to read a raw OSC reply back off
500 /// the console handle, so detection falls through to the environment-based
501 /// sources. Callers treat `None` as "no evidence", never as "dark".
502 #[cfg(not(unix))]
503 pub fn query_terminal(_query: &[u8], _timeout: std::time::Duration) -> Option<Vec<u8>> {
504 None
505 }
506
507 /// Non-Unix twin of [`query_terminal_csi`]: no console to ask, no evidence.
508 #[cfg(not(unix))]
509 pub fn query_terminal_csi(_query: &[u8], _timeout: std::time::Duration) -> Option<Vec<u8>> {
510 None
511 }
512
512 lines RUST