返回 CodeWhale
startup_input.rs
根目录 / crates / tui / src / tui / startup_input.rs
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
413 lines RUST