| 1 | //! Type-ahead integrity across TUI startup (#5925). |
| 2 | //! |
| 3 | //! Startup asks the terminal three questions whose answers arrive on stdin — |
| 4 | //! the OSC 11 background query, the kitty graphics probe, and the sixel |
| 5 | //! primary-DA probe (see [`codewhale_palette::osc11`]). All three run after raw |
| 6 | //! mode is on and before the [`crate::tui::ui::TerminalInputPump`] exists, so |
| 7 | //! for that window Codewhale is the only reader of the tty. Anything the user |
| 8 | //! has already typed sits in the same buffer as the replies. |
| 9 | //! |
| 10 | //! Before this module the probe readers consumed those bytes and threw them |
| 11 | //! away: a `/plugin install …` typed at launch reached the composer as |
| 12 | //! `gin install …`, no longer began with `/`, and was submitted to the model |
| 13 | //! as a prose prompt (#5925). |
| 14 | //! |
| 15 | //! The contract now is: a probe reader keeps only its own reply and hands |
| 16 | //! every other byte it consumed to [`osc11::carry_typed_ahead`]. The event |
| 17 | //! loop replays that buffer, in order, into the same `pending` queue the input |
| 18 | //! pump feeds, before the pump is spawned — so replayed keys are delivered |
| 19 | //! ahead of anything still sitting in the tty. Bytes that cannot be turned |
| 20 | //! back into a key event (a control byte, an escape sequence, invalid UTF-8) |
| 21 | //! are never guessed at: they are named in an INFO receipt and recorded as |
| 22 | //! evidence that the shell did not see the whole line. |
| 23 | |
| 24 | use std::collections::VecDeque; |
| 25 | |
| 26 | use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; |
| 27 | |
| 28 | use codewhale_palette::osc11; |
| 29 | |
| 30 | /// What [`decode`] could and could not turn back into key events. |
| 31 | #[derive(Debug, Default, PartialEq, Eq)] |
| 32 | pub(crate) struct DecodedTypeAhead { |
| 33 | pub(crate) events: Vec<Event>, |
| 34 | /// Bytes deliberately not replayed. Never guessed at — an escape |
| 35 | /// sequence replayed as a bare `Esc` plus letters would type garbage |
| 36 | /// into the composer, and a synthesized Ctrl+C would cancel work the |
| 37 | /// user never asked to cancel. |
| 38 | pub(crate) undecodable: Vec<u8>, |
| 39 | } |
| 40 | |
| 41 | fn plain_key(code: KeyCode) -> Event { |
| 42 | Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) |
| 43 | } |
| 44 | |
| 45 | /// Byte length of the UTF-8 sequence a lead byte opens, or `None` when it is |
| 46 | /// not a valid lead byte. |
| 47 | fn utf8_sequence_len(lead: u8) -> Option<usize> { |
| 48 | match lead { |
| 49 | 0x00..=0x7f => Some(1), |
| 50 | 0xc2..=0xdf => Some(2), |
| 51 | 0xe0..=0xef => Some(3), |
| 52 | 0xf0..=0xf4 => Some(4), |
| 53 | _ => None, |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// Exclusive end of the escape sequence that starts at `start` (an `ESC`). |
| 58 | /// |
| 59 | /// `ESC [` / `ESC O` run to their final byte (`@`..=`~`); control strings |
| 60 | /// run to ST (OSC also accepts BEL). A bare `ESC x` is two bytes; a trailing |
| 61 | /// `ESC` is one. Used only to keep a sequence together |
| 62 | /// so it is dropped as a unit. |
| 63 | fn escape_sequence_end(bytes: &[u8], start: usize) -> usize { |
| 64 | match bytes.get(start + 1) { |
| 65 | Some(kind @ (b']' | b'_' | b'P' | b'^' | b'X')) => { |
| 66 | let mut end = start + 2; |
| 67 | while end < bytes.len() { |
| 68 | if *kind == b']' && bytes[end] == 0x07 { |
| 69 | return end + 1; |
| 70 | } |
| 71 | if bytes[end..].starts_with(b"\x1b\\") { |
| 72 | return end + 2; |
| 73 | } |
| 74 | end += 1; |
| 75 | } |
| 76 | end |
| 77 | } |
| 78 | Some(b'[' | b'O') => { |
| 79 | let mut end = start + 2; |
| 80 | while end < bytes.len() { |
| 81 | let byte = bytes[end]; |
| 82 | end += 1; |
| 83 | if (0x40..=0x7e).contains(&byte) { |
| 84 | break; |
| 85 | } |
| 86 | } |
| 87 | end |
| 88 | } |
| 89 | Some(_) => start + 2, |
| 90 | None => start + 1, |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// Turn carried bytes back into the key events the pump would have produced. |
| 95 | /// |
| 96 | /// Only unambiguous keys are reconstructed: printable text (any UTF-8 |
| 97 | /// scalar), Enter, Tab, and Backspace. Everything else — `ESC`, other C0 |
| 98 | /// control bytes, invalid UTF-8 — is reported as undecodable rather than |
| 99 | /// approximated. |
| 100 | pub(crate) fn decode(bytes: &[u8]) -> DecodedTypeAhead { |
| 101 | let mut decoded = DecodedTypeAhead::default(); |
| 102 | let mut index = 0; |
| 103 | while index < bytes.len() { |
| 104 | let byte = bytes[index]; |
| 105 | match byte { |
| 106 | b'\r' | b'\n' => { |
| 107 | decoded.events.push(plain_key(KeyCode::Enter)); |
| 108 | index += 1; |
| 109 | // A CRLF pair is one Enter, not two. |
| 110 | if byte == b'\r' && bytes.get(index) == Some(&b'\n') { |
| 111 | index += 1; |
| 112 | } |
| 113 | } |
| 114 | b'\t' => { |
| 115 | decoded.events.push(plain_key(KeyCode::Tab)); |
| 116 | index += 1; |
| 117 | } |
| 118 | 0x08 | 0x7f => { |
| 119 | decoded.events.push(plain_key(KeyCode::Backspace)); |
| 120 | index += 1; |
| 121 | } |
| 122 | 0x1b => { |
| 123 | // An escape sequence is taken whole, never in pieces: an |
| 124 | // arrow key replayed as its tail would type `[A` into the |
| 125 | // composer, which is worse than losing it with a receipt. |
| 126 | let end = escape_sequence_end(bytes, index); |
| 127 | let sequence = &bytes[index..end]; |
| 128 | // An OSC 11 answer can arrive after its probe timed out, |
| 129 | // during the next startup probe. It is a color measurement, |
| 130 | // not a partially consumed user command. |
| 131 | let color_reply = sequence |
| 132 | .strip_prefix(b"\x1b]11;") |
| 133 | .and_then(|body| { |
| 134 | body.strip_suffix(b"\x1b\\") |
| 135 | .or_else(|| body.strip_suffix(b"\x07")) |
| 136 | }) |
| 137 | .and_then(|body| std::str::from_utf8(body).ok()) |
| 138 | .filter(|body| { |
| 139 | body.strip_prefix("rgb:").is_some_and(|spec| { |
| 140 | spec.bytes().all(|b| b.is_ascii_hexdigit() || b == b'/') |
| 141 | }) || body |
| 142 | .strip_prefix('#') |
| 143 | .is_some_and(|spec| spec.bytes().all(|b| b.is_ascii_hexdigit())) |
| 144 | }) |
| 145 | .and_then(osc11::parse_osc11_reply) |
| 146 | .is_some(); |
| 147 | // Focus notifications and the successful answer to our exact |
| 148 | // kitty probe can also arrive during a later startup query. |
| 149 | // They carry no composer input. Unknown or incomplete escape |
| 150 | // strings still retain the lost-input guard below. |
| 151 | let terminal_reply = color_reply |
| 152 | || matches!(sequence, b"\x1b[I" | b"\x1b[O" | b"\x1b_Gi=31;OK\x1b\\"); |
| 153 | if !terminal_reply { |
| 154 | decoded.undecodable.extend_from_slice(sequence); |
| 155 | } |
| 156 | index = end; |
| 157 | } |
| 158 | 0x00..=0x1f => { |
| 159 | decoded.undecodable.push(byte); |
| 160 | index += 1; |
| 161 | } |
| 162 | _ => { |
| 163 | let width = utf8_sequence_len(byte).unwrap_or(0); |
| 164 | let end = index + width; |
| 165 | match bytes |
| 166 | .get(index..end) |
| 167 | .filter(|_| width > 0) |
| 168 | .and_then(|slice| std::str::from_utf8(slice).ok()) |
| 169 | { |
| 170 | Some(text) => { |
| 171 | decoded |
| 172 | .events |
| 173 | .extend(text.chars().map(|ch| plain_key(KeyCode::Char(ch)))); |
| 174 | index = end; |
| 175 | } |
| 176 | None => { |
| 177 | decoded.undecodable.push(byte); |
| 178 | index += 1; |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | decoded |
| 185 | } |
| 186 | |
| 187 | /// Printable rendering of raw bytes for a log receipt. |
| 188 | pub(crate) fn escape_bytes(bytes: &[u8]) -> String { |
| 189 | bytes |
| 190 | .iter() |
| 191 | .map(|byte| match byte { |
| 192 | 0x20..=0x7e => (*byte as char).to_string(), |
| 193 | _ => format!("\\x{byte:02x}"), |
| 194 | }) |
| 195 | .collect() |
| 196 | } |
| 197 | |
| 198 | /// What the replay did, for the caller's own bookkeeping. |
| 199 | #[derive(Debug, Default, PartialEq, Eq)] |
| 200 | pub(crate) struct StartupInputReceipt { |
| 201 | /// Key events pushed onto the pending queue. |
| 202 | pub(crate) replayed: usize, |
| 203 | /// Bytes startup consumed that never became key events. |
| 204 | pub(crate) dropped: Vec<u8>, |
| 205 | } |
| 206 | |
| 207 | impl StartupInputReceipt { |
| 208 | /// Whether the shell can prove it saw everything the user typed. When |
| 209 | /// this is false the composer holds the next submit instead of sending |
| 210 | /// a line it cannot vouch for. |
| 211 | pub(crate) fn whole_line_proven(&self) -> bool { |
| 212 | self.dropped.is_empty() |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | /// Replay everything startup consumed into `pending`, oldest byte first. |
| 217 | /// |
| 218 | /// Call once, before the input pump is spawned, so replayed keys are ahead |
| 219 | /// of anything the pump reads next. Emits an INFO receipt whenever startup |
| 220 | /// touched the user's input at all — a future report of a mangled command |
| 221 | /// then has a line naming the exact bytes. |
| 222 | pub(crate) fn replay_into(pending: &mut VecDeque<Event>) -> StartupInputReceipt { |
| 223 | let carried = osc11::take_carried_type_ahead(); |
| 224 | let mut dropped = osc11::take_consumed_unreplayable(); |
| 225 | let mut decoded = decode(&carried); |
| 226 | dropped.extend_from_slice(&decoded.undecodable); |
| 227 | |
| 228 | // A queued Enter is not a fresh acknowledgement of missing startup |
| 229 | // bytes. Preserve line boundaries as literal text, so even multiple |
| 230 | // queued Enters cannot clear the hold and then submit a damaged line. |
| 231 | // The next real submit still goes through the composer's existing hold. |
| 232 | if !dropped.is_empty() { |
| 233 | for event in &mut decoded.events { |
| 234 | if *event == plain_key(KeyCode::Enter) { |
| 235 | *event = plain_key(KeyCode::Char('\n')); |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | let receipt = StartupInputReceipt { |
| 241 | replayed: decoded.events.len(), |
| 242 | dropped, |
| 243 | }; |
| 244 | if carried.is_empty() && receipt.dropped.is_empty() { |
| 245 | return receipt; |
| 246 | } |
| 247 | // The replay is prepended, not appended: these bytes were consumed |
| 248 | // before anything still sitting in the tty, so they must be delivered |
| 249 | // first or the line is reordered. |
| 250 | for event in decoded.events.into_iter().rev() { |
| 251 | pending.push_front(event); |
| 252 | } |
| 253 | tracing::info!( |
| 254 | target: "startup_input", |
| 255 | consumed_bytes = carried.len(), |
| 256 | replayed_keys = receipt.replayed, |
| 257 | replayed = %escape_bytes(&carried), |
| 258 | dropped_bytes = receipt.dropped.len(), |
| 259 | dropped = %escape_bytes(&receipt.dropped), |
| 260 | "startup terminal probes consumed typed-ahead input; replaying it into the composer" |
| 261 | ); |
| 262 | receipt |
| 263 | } |
| 264 | |
| 265 | #[cfg(test)] |
| 266 | mod tests { |
| 267 | use super::*; |
| 268 | |
| 269 | #[test] |
| 270 | fn decodes_a_typed_slash_command_line_in_order() { |
| 271 | let decoded = decode(b"/plugin list\r"); |
| 272 | let typed: String = decoded |
| 273 | .events |
| 274 | .iter() |
| 275 | .filter_map(|event| match event { |
| 276 | Event::Key(KeyEvent { |
| 277 | code: KeyCode::Char(ch), |
| 278 | .. |
| 279 | }) => Some(*ch), |
| 280 | _ => None, |
| 281 | }) |
| 282 | .collect(); |
| 283 | assert_eq!(typed, "/plugin list"); |
| 284 | assert_eq!( |
| 285 | decoded.events.last(), |
| 286 | Some(&plain_key(KeyCode::Enter)), |
| 287 | "the trailing carriage return must replay as Enter" |
| 288 | ); |
| 289 | assert!(decoded.undecodable.is_empty()); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn crlf_replays_as_one_enter() { |
| 294 | let decoded = decode(b"hi\r\n"); |
| 295 | assert_eq!( |
| 296 | decoded |
| 297 | .events |
| 298 | .iter() |
| 299 | .filter(|e| **e == plain_key(KeyCode::Enter)) |
| 300 | .count(), |
| 301 | 1 |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn delayed_color_replies_are_consumed_without_damaging_typeahead() { |
| 307 | for reply in [ |
| 308 | "\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\", |
| 309 | "\x1b]11;rgb:1e/1e/1e\x07", |
| 310 | "\x1b]11;#1e1e1e\x07", |
| 311 | ] { |
| 312 | assert_eq!( |
| 313 | decode(format!("/plugin{reply} list\r").as_bytes()), |
| 314 | decode(b"/plugin list\r"), |
| 315 | "late reply: {reply:?}" |
| 316 | ); |
| 317 | } |
| 318 | let literal = "11;rgb:1e1e/1e1e/1e1e"; |
| 319 | assert_eq!( |
| 320 | decode(literal.as_bytes()).events, |
| 321 | literal |
| 322 | .chars() |
| 323 | .map(|c| plain_key(KeyCode::Char(c))) |
| 324 | .collect::<Vec<_>>() |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn unknown_or_incomplete_control_strings_never_replay_their_payload() { |
| 330 | for sequence in [ |
| 331 | "\x1b]11;rgb:1e/1e/1egarbage\x07", |
| 332 | "\x1b]11;rgb:1e/1e/1e\r\r\x07", |
| 333 | "\x1b]52;payload\r\r\x1b\\", |
| 334 | "\x1b_payload\r\r\x1b\\", |
| 335 | "\x1bPpayload\r\r\x1b\\", |
| 336 | "\x1b^payload\r\r\x1b\\", |
| 337 | "\x1bXpayload\r\r\x1b\\", |
| 338 | "\x1b]11;rgb:1e/1e/1e", |
| 339 | "\x1b_Gi=31;OK", |
| 340 | "\x1b_Gi=32;OK\x1b\\", |
| 341 | "\x1b_Gi=31;OK\r\r\x1b\\", |
| 342 | ] { |
| 343 | let decoded = decode(sequence.as_bytes()); |
| 344 | assert!(decoded.events.is_empty(), "{sequence:?}: {decoded:?}"); |
| 345 | assert_eq!(decoded.undecodable, sequence.as_bytes()); |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | #[test] |
| 350 | fn complete_focus_and_kitty_probe_replies_do_not_damage_input() { |
| 351 | for reply in ["\x1b[I", "\x1b[O", "\x1b_Gi=31;OK\x1b\\"] { |
| 352 | assert_eq!( |
| 353 | decode(format!("/plugin{reply} list\r").as_bytes()), |
| 354 | decode(b"/plugin list\r"), |
| 355 | "terminal metadata: {reply:?}" |
| 356 | ); |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | #[test] |
| 361 | fn escape_sequences_and_invalid_utf8_are_reported_never_guessed() { |
| 362 | let decoded = decode(b"a\x1b[Ab\xff"); |
| 363 | // The arrow key is dropped whole — replaying its tail would type |
| 364 | // `[A` into the composer. Only `a` and `b` come back as keys. |
| 365 | assert_eq!(decoded.undecodable, b"\x1b[A\xff".to_vec()); |
| 366 | assert_eq!(decoded.events.len(), 2); |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn a_lone_escape_at_the_end_is_one_dropped_byte() { |
| 371 | let decoded = decode(b"hi\x1b"); |
| 372 | assert_eq!(decoded.undecodable, vec![0x1b]); |
| 373 | assert_eq!(decoded.events.len(), 2); |
| 374 | } |
| 375 | |
| 376 | #[test] |
| 377 | fn multibyte_text_survives_the_round_trip() { |
| 378 | let decoded = decode("héllo→".as_bytes()); |
| 379 | let typed: String = decoded |
| 380 | .events |
| 381 | .iter() |
| 382 | .filter_map(|event| match event { |
| 383 | Event::Key(KeyEvent { |
| 384 | code: KeyCode::Char(ch), |
| 385 | .. |
| 386 | }) => Some(*ch), |
| 387 | _ => None, |
| 388 | }) |
| 389 | .collect(); |
| 390 | assert_eq!(typed, "héllo→"); |
| 391 | assert!(decoded.undecodable.is_empty()); |
| 392 | } |
| 393 | |
| 394 | #[test] |
| 395 | fn a_receipt_with_dropped_bytes_does_not_prove_the_whole_line() { |
| 396 | let proven = StartupInputReceipt { |
| 397 | replayed: 3, |
| 398 | dropped: Vec::new(), |
| 399 | }; |
| 400 | assert!(proven.whole_line_proven()); |
| 401 | let lossy = StartupInputReceipt { |
| 402 | replayed: 3, |
| 403 | dropped: vec![0x1b], |
| 404 | }; |
| 405 | assert!(!lossy.whole_line_proven()); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn escape_bytes_names_unprintable_bytes() { |
| 410 | assert_eq!(escape_bytes(b"/pl\x1b"), "/pl\\x1b"); |
| 411 | } |
| 412 | } |
| 413 |