1use crate::{
9 ShellKind, TerminalColor, TerminalMouseTrackingMode, TerminalProcessStatus, TerminalState,
10 TerminalWarning,
11};
12
13pub trait TerminalParser {
35 fn advance(&mut self, state: &mut TerminalState, bytes: &[u8]);
53}
54
55pub struct VteTerminalParser {
72 parser: vte::Parser,
74}
75
76impl VteTerminalParser {
77 pub fn new() -> Self {
90 Self {
91 parser: vte::Parser::new(),
92 }
93 }
94}
95
96impl Default for VteTerminalParser {
97 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl TerminalParser for VteTerminalParser {
111 fn advance(&mut self, state: &mut TerminalState, bytes: &[u8]) {
113 let mut performer = TerminalPerformer { state };
114 self.parser.advance(&mut performer, bytes);
115 }
116}
117
118struct TerminalPerformer<'a> {
120 state: &'a mut TerminalState,
122}
123
124impl vte::Perform for TerminalPerformer<'_> {
125 fn print(&mut self, c: char) {
127 self.state.write_char(c);
128 self.state.update_prompt_heuristic();
129 }
130
131 fn execute(&mut self, byte: u8) {
133 match byte {
134 b'\n' | 0x0b | 0x0c => self.state.line_feed(),
135 b'\r' => self.state.carriage_return(),
136 b'\t' => self.state.tab(),
137 0x08 => self.state.backspace(),
138 0x07 => {}
139 _ => self.unsupported(format!("C0 0x{byte:02x}")),
140 }
141 }
142
143 fn hook(&mut self, _params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) {
145 self.unsupported(format!("DCS {action}"));
146 }
147
148 fn put(&mut self, _byte: u8) {}
150
151 fn unhook(&mut self) {}
153
154 fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
160 let Some(code) = params
161 .first()
162 .and_then(|bytes| std::str::from_utf8(bytes).ok())
163 else {
164 self.unsupported("OSC invalid");
165 return;
166 };
167 let payload = join_params(¶ms[1..]);
168
169 match code {
170 "0" | "1" | "2" => self.state.set_title(payload),
171 "7" => self.state.set_cwd_uri(payload),
172 "8" => {
173 let link_params = params
174 .get(1)
175 .and_then(|bytes| std::str::from_utf8(bytes).ok())
176 .unwrap_or_default();
177 let uri = params
178 .get(2)
179 .and_then(|bytes| std::str::from_utf8(bytes).ok())
180 .unwrap_or_default();
181 if uri.is_empty() {
182 self.state.close_hyperlink();
183 } else {
184 self.state.open_hyperlink(link_params, uri);
185 }
186 }
187 "52" => {
188 if self.state.security.allow_clipboard_write {
189 self.unsupported("OSC 52 clipboard write");
190 } else {
191 self.state.warnings.push(TerminalWarning::blocked_sequence(
192 "OSC 52",
193 "clipboard write disabled by terminal security policy",
194 ));
195 }
196 }
197 "133" => self.dispatch_osc_133(¶ms[1..]),
198 "1337" => self.dispatch_osc_1337(¶ms[1..]),
199 "9001" => self.dispatch_ailloli_ui_shell_osc(¶ms[1..]),
200 _ => self.unsupported(format!("OSC {code}")),
201 }
202 }
203
204 fn csi_dispatch(
210 &mut self,
211 params: &vte::Params,
212 intermediates: &[u8],
213 ignore: bool,
214 action: char,
215 ) {
216 if ignore {
217 self.unsupported(format!("CSI ignored {action}"));
218 return;
219 }
220
221 if intermediates == b"?" {
222 self.dispatch_private_csi(params, action);
223 return;
224 }
225
226 match action {
227 'A' => self.state.move_cursor_up(param_or(params, 0, 1) as usize),
228 'B' => self.state.move_cursor_down(param_or(params, 0, 1) as usize),
229 'C' => self
230 .state
231 .move_cursor_forward(param_or(params, 0, 1) as usize),
232 'D' => self.state.move_cursor_back(param_or(params, 0, 1) as usize),
233 'E' => self
234 .state
235 .move_cursor_next_line(param_or(params, 0, 1) as usize),
236 'F' => self
237 .state
238 .move_cursor_previous_line(param_or(params, 0, 1) as usize),
239 'G' => self
240 .state
241 .set_cursor_column_ansi(param_or(params, 0, 1) as usize),
242 'H' | 'f' => {
243 let row = param_or(params, 0, 1) as usize;
244 let col = param_or(params, 1, 1) as usize;
245 self.state.set_cursor_position_ansi(row.max(1), col.max(1));
246 }
247 'd' => self
248 .state
249 .set_cursor_row_ansi(param_or(params, 0, 1) as usize),
250 'e' => self.state.move_cursor_down(param_or(params, 0, 1) as usize),
251 '@' => self
252 .state
253 .insert_blank_chars(param_or(params, 0, 1) as usize),
254 'J' => {
255 let mode = param_or(params, 0, 0);
256 if matches!(mode, 0..=2) {
257 self.state.erase_display(mode);
258 } else {
259 self.unsupported(format!("CSI {mode} J"));
260 }
261 }
262 'K' => {
263 let mode = param_or(params, 0, 0);
264 if matches!(mode, 0..=2) {
265 self.state.erase_line(mode);
266 } else {
267 self.unsupported(format!("CSI {mode} K"));
268 }
269 }
270 'L' => self
271 .state
272 .insert_lines(self.state.cursor.row, param_or(params, 0, 1) as usize),
273 'M' => self
274 .state
275 .delete_lines(self.state.cursor.row, param_or(params, 0, 1) as usize),
276 'P' => self.state.delete_chars(param_or(params, 0, 1) as usize),
277 'X' => self.state.erase_chars(param_or(params, 0, 1) as usize),
278 'r' => {
279 let rows = self.state.active_screen().rows;
280 let top = param_or(params, 0, 1).max(1) as usize;
281 let bottom = param_or(params, 1, rows as u16).max(1) as usize;
282 self.state
283 .set_scroll_region(top.saturating_sub(1), bottom.saturating_sub(1));
284 self.state.set_cursor_position(0, 0);
285 }
286 'm' => self.dispatch_sgr(params),
287 's' => self.state.save_cursor(),
288 'u' => self.state.restore_cursor(),
289 _ => self.unsupported(format!("CSI {:?} {action}", params_to_debug(params))),
290 }
291 }
292
293 fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) {
295 if ignore {
296 self.unsupported(format!("ESC ignored 0x{byte:02x}"));
297 return;
298 }
299 match (intermediates, byte) {
300 (b"", b'7') => self.state.save_cursor(),
301 (b"", b'8') => self.state.restore_cursor(),
302 (b"", b'=') => self.state.set_application_keypad_mode(true),
303 (b"", b'>') => self.state.set_application_keypad_mode(false),
304 _ => self.unsupported(format!("ESC {intermediates:?} 0x{byte:02x}")),
305 }
306 }
307}
308
309impl TerminalPerformer<'_> {
310 fn dispatch_ailloli_ui_shell_osc(&mut self, params: &[&[u8]]) {
316 let Some(parts) = shell_parts(params) else {
317 self.unsupported("OSC 9001 invalid shell payload");
318 return;
319 };
320 let Some(action) = parts.first().and_then(|part| {
321 part.strip_prefix("ailloli_ui:")
322 .or_else(|| part.strip_prefix("octavui:"))
323 }) else {
324 self.unsupported("OSC 9001 missing ailloli_ui action");
325 return;
326 };
327 let fields = shell_fields(&parts[1..]);
328
329 match action {
330 "prompt_start" => self.state.mark_shell_prompt_start(),
331 "command_start" => {
332 let command_line = shell_field_value(&fields, &["cmd", "command"])
333 .or_else(|| shell_positional_value(&fields, 0))
334 .unwrap_or_default();
335 let cwd_uri = shell_field_value(&fields, &["cwd", "cwd_uri"]);
336 let started_at_ms = shell_u64_value(&fields, &["started_at_ms", "time_ms"]);
337 self.state
338 .start_shell_command(command_line, cwd_uri, started_at_ms);
339 }
340 "command_end" => {
341 let exit_code = shell_i32_value(&fields, &["exit", "exit_code", "code"]);
342 let signal = shell_i32_value(&fields, &["signal"]);
343 let ended_at_ms = shell_u64_value(&fields, &["ended_at_ms", "time_ms"]);
344 let duration_ms = shell_u64_value(&fields, &["duration_ms"]);
345 self.state
346 .finish_shell_command(exit_code, signal, ended_at_ms, duration_ms);
347 }
348 "cwd" => {
349 let Some(cwd_uri) = shell_field_value(&fields, &["uri", "cwd", "cwd_uri"])
350 .or_else(|| shell_positional_value(&fields, 0))
351 else {
352 self.unsupported("OSC 9001 cwd missing uri");
353 return;
354 };
355 self.state.set_cwd_uri(cwd_uri);
356 }
357 "shell" => {
358 let Some(shell) = shell_field_value(&fields, &["kind", "shell"])
359 .or_else(|| shell_positional_value(&fields, 0))
360 else {
361 self.unsupported("OSC 9001 shell missing kind");
362 return;
363 };
364 self.state.set_shell_kind(ShellKind::from_name(&shell));
365 }
366 "process_status" => {
367 let Some(status) = shell_process_status(&fields) else {
368 self.unsupported("OSC 9001 process_status invalid");
369 return;
370 };
371 self.state.set_shell_process_status(status);
372 }
373 _ => self.unsupported(format!("OSC 9001 ailloli_ui:{action}")),
374 }
375 }
376
377 fn dispatch_osc_133(&mut self, params: &[&[u8]]) {
383 let Some(parts) = shell_parts(params) else {
384 self.unsupported("OSC 133 invalid shell payload");
385 return;
386 };
387 let Some(action) = parts.first().map(String::as_str) else {
388 self.unsupported("OSC 133 missing action");
389 return;
390 };
391 let fields = shell_fields(&parts[1..]);
392
393 match action {
394 "A" => self.state.mark_shell_prompt_start(),
395 "B" => {
396 let command_line = shell_field_value(&fields, &["cmd", "command"])
397 .or_else(|| shell_positional_value(&fields, 0))
398 .unwrap_or_default();
399 let cwd_uri = shell_field_value(&fields, &["cwd", "cwd_uri"]);
400 self.state.start_shell_command(command_line, cwd_uri, None);
401 }
402 "C" => {
403 let exit_code =
404 shell_i32_value(&fields, &["exit", "exit_code", "code"]).or_else(|| {
405 shell_positional_value(&fields, 0).and_then(|value| value.parse().ok())
406 });
407 let signal = shell_i32_value(&fields, &["signal"]);
408 self.state
409 .finish_shell_command(exit_code, signal, None, None);
410 }
411 _ => self.unsupported(format!("OSC 133 {action}")),
412 }
413 }
414
415 fn dispatch_osc_1337(&mut self, params: &[&[u8]]) {
417 let Some(parts) = shell_parts(params) else {
418 self.unsupported("OSC 1337 invalid payload");
419 return;
420 };
421 let Some(first) = parts.first() else {
422 self.unsupported("OSC 1337 missing payload");
423 return;
424 };
425 if let Some(cwd_uri) = first.strip_prefix("CurrentDir=") {
426 self.state.set_cwd_uri(cwd_uri.to_string());
427 } else {
428 self.unsupported(format!("OSC 1337 {first}"));
429 }
430 }
431
432 fn dispatch_private_csi(&mut self, params: &vte::Params, action: char) {
437 match action {
438 'h' | 'l' => {
439 let enabled = action == 'h';
440 for param in flat_params(params) {
441 match param {
442 25 => self.state.set_cursor_visible(enabled),
443 1 => self.state.set_application_cursor_mode(enabled),
444 7 => self.state.set_wraparound_mode(enabled),
445 9 => self.state.set_mouse_tracking_mode(if enabled {
446 TerminalMouseTrackingMode::X10
447 } else {
448 TerminalMouseTrackingMode::Off
449 }),
450 1000 => self.state.set_mouse_tracking_mode(if enabled {
451 TerminalMouseTrackingMode::Normal
452 } else {
453 TerminalMouseTrackingMode::Off
454 }),
455 1002 => self.state.set_mouse_tracking_mode(if enabled {
456 TerminalMouseTrackingMode::ButtonMotion
457 } else {
458 TerminalMouseTrackingMode::Off
459 }),
460 1003 => self.state.set_mouse_tracking_mode(if enabled {
461 TerminalMouseTrackingMode::AnyMotion
462 } else {
463 TerminalMouseTrackingMode::Off
464 }),
465 1006 => self.state.set_sgr_mouse_mode(enabled),
466 1049 => {
467 if enabled {
468 self.state.switch_to_alternate_screen();
469 } else {
470 self.state.switch_to_normal_screen();
471 }
472 }
473 2004 => self.state.set_bracketed_paste_mode(enabled),
474 _ => self.unsupported(format!(
475 "CSI ? {param} {}",
476 if enabled { 'h' } else { 'l' }
477 )),
478 }
479 }
480 }
481 _ => self.unsupported(format!("CSI ? {:?} {action}", params_to_debug(params))),
482 }
483 }
484
485 fn dispatch_sgr(&mut self, params: &vte::Params) {
492 let grouped = grouped_params(params);
493 if grouped.iter().any(|param| param.len() > 1) {
494 self.unsupported(format!("CSI {grouped:?} m"));
495 return;
496 }
497
498 let codes = if grouped.is_empty() {
499 vec![0]
500 } else {
501 grouped.iter().map(|param| param[0]).collect::<Vec<_>>()
502 };
503
504 let mut style = self.state.current_style;
505 let mut idx = 0;
506 while idx < codes.len() {
507 match codes[idx] {
508 0 => style.reset_sgr(),
509 1 => style.bold = true,
510 2 => style.dim = true,
511 3 => style.italic = true,
512 4 => style.underline = true,
513 7 => style.inverse = true,
514 9 => style.strike = true,
515 22 => {
516 style.bold = false;
517 style.dim = false;
518 }
519 23 => style.italic = false,
520 24 => style.underline = false,
521 27 => style.inverse = false,
522 29 => style.strike = false,
523 30..=37 => style.fg = TerminalColor::Ansi((codes[idx] - 30) as u8),
524 40..=47 => style.bg = TerminalColor::Ansi((codes[idx] - 40) as u8),
525 90..=97 => style.fg = TerminalColor::Ansi((codes[idx] - 90 + 8) as u8),
526 100..=107 => style.bg = TerminalColor::Ansi((codes[idx] - 100 + 8) as u8),
527 39 => style.fg = TerminalColor::DefaultFg,
528 49 => style.bg = TerminalColor::DefaultBg,
529 38 | 48 => {
530 let Some((color, consumed)) = parse_extended_color(&codes[idx + 1..]) else {
531 self.unsupported(format!("CSI {codes:?} m"));
532 return;
533 };
534 if codes[idx] == 38 {
535 style.fg = color;
536 } else {
537 style.bg = color;
538 }
539 idx += consumed;
540 }
541 code => self.unsupported(format!("SGR {code}")),
542 }
543 idx += 1;
544 }
545 self.state.current_style = style;
546 }
547
548 fn unsupported(&mut self, sequence: impl Into<String>) {
550 self.state
551 .warnings
552 .push(TerminalWarning::unsupported_sequence(sequence));
553 }
554}
555
556fn parse_extended_color(codes: &[u16]) -> Option<(TerminalColor, usize)> {
561 match codes {
562 [5, index, ..] if *index <= 255 => Some((TerminalColor::Indexed(*index as u8), 2)),
563 [2, r, g, b, ..] if *r <= 255 && *g <= 255 && *b <= 255 => {
564 Some((TerminalColor::Rgb(*r as u8, *g as u8, *b as u8), 4))
565 }
566 _ => None,
567 }
568}
569
570fn grouped_params(params: &vte::Params) -> Vec<Vec<u16>> {
572 params.iter().map(|param| param.to_vec()).collect()
573}
574
575fn flat_params(params: &vte::Params) -> Vec<u16> {
577 let values = params
578 .iter()
579 .flat_map(|param| param.iter().copied())
580 .collect::<Vec<_>>();
581 if values.is_empty() {
582 vec![0]
583 } else {
584 values
585 }
586}
587
588fn param_or(params: &vte::Params, index: usize, default: u16) -> u16 {
590 params
591 .iter()
592 .nth(index)
593 .and_then(|param| param.first().copied())
594 .filter(|value| *value != 0)
595 .unwrap_or(default)
596}
597
598fn params_to_debug(params: &vte::Params) -> Vec<Vec<u16>> {
600 grouped_params(params)
601}
602
603fn join_params(params: &[&[u8]]) -> String {
605 let mut joined = Vec::new();
606 for (index, param) in params.iter().enumerate() {
607 if index > 0 {
608 joined.push(b';');
609 }
610 joined.extend_from_slice(param);
611 }
612 String::from_utf8_lossy(&joined).into_owned()
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
617struct ShellField {
618 key: Option<String>,
620 value: String,
622}
623
624fn shell_parts(params: &[&[u8]]) -> Option<Vec<String>> {
629 params
630 .iter()
631 .map(|param| {
632 let value = std::str::from_utf8(param).ok()?;
633 percent_decode(value)
634 })
635 .collect()
636}
637
638fn shell_fields(parts: &[String]) -> Vec<ShellField> {
640 parts
641 .iter()
642 .map(|part| {
643 if let Some((key, value)) = part.split_once('=') {
644 ShellField {
645 key: Some(key.to_ascii_lowercase()),
646 value: value.to_string(),
647 }
648 } else {
649 ShellField {
650 key: None,
651 value: part.to_string(),
652 }
653 }
654 })
655 .collect()
656}
657
658fn shell_field_value(fields: &[ShellField], keys: &[&str]) -> Option<String> {
660 fields.iter().find_map(|field| {
661 let key = field.key.as_deref()?;
662 keys.contains(&key).then(|| field.value.clone())
663 })
664}
665
666fn shell_positional_value(fields: &[ShellField], index: usize) -> Option<String> {
668 fields
669 .iter()
670 .filter(|field| field.key.is_none())
671 .nth(index)
672 .map(|field| field.value.clone())
673}
674
675fn shell_i32_value(fields: &[ShellField], keys: &[&str]) -> Option<i32> {
677 shell_field_value(fields, keys).and_then(|value| value.parse().ok())
678}
679
680fn shell_u64_value(fields: &[ShellField], keys: &[&str]) -> Option<u64> {
682 shell_field_value(fields, keys).and_then(|value| value.parse().ok())
683}
684
685fn shell_process_status(fields: &[ShellField]) -> Option<TerminalProcessStatus> {
690 let status = shell_field_value(fields, &["status"])
691 .or_else(|| shell_positional_value(fields, 0))?
692 .to_ascii_lowercase();
693 match status.as_str() {
694 "starting" => Some(TerminalProcessStatus::Starting),
695 "running" => Some(TerminalProcessStatus::Running),
696 "exited" | "exit" => Some(TerminalProcessStatus::Exited {
697 code: shell_i32_value(fields, &["code", "exit", "exit_code"]),
698 }),
699 "signaled" | "signal" => Some(TerminalProcessStatus::Signaled {
700 signal: shell_i32_value(fields, &["signal"]),
701 }),
702 "unknown" => Some(TerminalProcessStatus::Unknown),
703 _ => None,
704 }
705}
706
707fn percent_decode(input: &str) -> Option<String> {
712 let bytes = input.as_bytes();
713 let mut decoded = Vec::with_capacity(bytes.len());
714 let mut index = 0;
715 while index < bytes.len() {
716 if bytes[index] == b'%' {
717 let hi = *bytes.get(index + 1)?;
718 let lo = *bytes.get(index + 2)?;
719 decoded.push((hex_value(hi)? << 4) | hex_value(lo)?);
720 index += 3;
721 } else {
722 decoded.push(bytes[index]);
723 index += 1;
724 }
725 }
726 String::from_utf8(decoded).ok()
727}
728
729fn hex_value(byte: u8) -> Option<u8> {
731 match byte {
732 b'0'..=b'9' => Some(byte - b'0'),
733 b'a'..=b'f' => Some(byte - b'a' + 10),
734 b'A'..=b'F' => Some(byte - b'A' + 10),
735 _ => None,
736 }
737}
738
739#[cfg(test)]
740mod tests {
742 use super::*;
743 use crate::{
744 ActiveScreen, CommandStatus, ShellEvent, ShellKind, TerminalConfig,
745 TerminalMouseTrackingMode, TerminalProcessStatus, TerminalSecurityPolicy, TerminalSize,
746 TerminalStyle, TerminalWarningKind,
747 };
748
749 fn state(rows: usize, cols: usize) -> TerminalState {
751 TerminalState::with_config(TerminalConfig {
752 size: TerminalSize::new(rows, cols),
753 scrollback_limit: 10,
754 security: TerminalSecurityPolicy::default(),
755 })
756 }
757
758 #[test]
759 fn parser_handles_incremental_chunks_and_partial_utf8() {
760 let mut parser = VteTerminalParser::new();
761 let mut state = state(2, 8);
762 let wide = "界".as_bytes();
763
764 parser.advance(&mut state, b"he");
765 parser.advance(&mut state, &wide[..1]);
766 parser.advance(&mut state, &wide[1..]);
767
768 assert_eq!(state.screen.cell(0, 0).expect("cell").text, "h");
769 assert_eq!(state.screen.cell(0, 1).expect("cell").text, "e");
770 assert_eq!(state.screen.cell(0, 2).expect("cell").text, "界");
771 }
772
773 #[test]
774 fn parser_handles_c0_controls() {
775 let mut parser = VteTerminalParser::new();
776 let mut state = state(3, 8);
777
778 parser.advance(&mut state, b"ab\rZ\nY\x08X");
779
780 assert_eq!(state.screen.line(0).expect("line").plain_text(), "Zb ");
781 assert_eq!(state.screen.line(1).expect("line").plain_text(), " X ");
782 assert_eq!(state.cursor.row, 1);
783 assert_eq!(state.cursor.col, 2);
784 }
785
786 #[test]
787 fn parser_handles_cursor_position_and_movement() {
788 let mut parser = VteTerminalParser::new();
789 let mut state = state(4, 5);
790
791 parser.advance(&mut state, b"\x1b[2;3HX\x1b[AY\x1b[2DZ");
792
793 assert_eq!(state.screen.cell(1, 2).expect("cell").text, "X");
794 assert_eq!(state.screen.cell(0, 3).expect("cell").text, "Y");
795 assert_eq!(state.screen.cell(0, 2).expect("cell").text, "Z");
796 }
797
798 #[test]
799 fn terminal_parser_supports_shell_redraw_sequences() {
800 let mut parser = VteTerminalParser::new();
801 let mut state = state(3, 12);
802
803 parser.advance(&mut state, b"abcdef\x1b[3GZ");
804 assert_eq!(
805 state.screen.line(0).expect("line").plain_text(),
806 "abZdef "
807 );
808
809 parser.advance(&mut state, b"\x1b[s\x1b[1;8HX\x1b[uY");
810 assert_eq!(state.screen.cell(0, 3).expect("restored").text, "Y");
811 assert_eq!(state.screen.cell(0, 7).expect("absolute").text, "X");
812
813 parser.advance(&mut state, b"\x1b7\x1b[2;2HE\x1b8R");
814 assert_eq!(state.screen.cell(1, 1).expect("esc save target").text, "E");
815 assert_eq!(state.screen.cell(0, 4).expect("esc restored").text, "R");
816
817 parser.advance(&mut state, b"\x1b[1;1H\x1b[2Kabcdef\x1b[3G\x1b[2X");
818 assert_eq!(
819 state.screen.line(0).expect("erase").plain_text(),
820 "ab ef "
821 );
822
823 parser.advance(&mut state, b"\x1b[1;1H\x1b[2Kabcdef\x1b[3G\x1b[2P");
824 assert_eq!(
825 state.screen.line(0).expect("delete").plain_text(),
826 "abef "
827 );
828
829 parser.advance(&mut state, b"\x1b[1;1H\x1b[2Kabcdef\x1b[3G\x1b[2@");
830 assert_eq!(
831 state.screen.line(0).expect("insert").plain_text(),
832 "ab cdef "
833 );
834 }
835
836 #[test]
837 fn terminal_parser_supports_multiline_redraw_cursor_sequences() {
838 let mut parser = VteTerminalParser::new();
839 let mut state = state(5, 8);
840
841 parser.advance(&mut state, b"\x1b[2;4HX\x1b[2FY");
842 assert_eq!(state.cursor.row, 0);
843 assert_eq!(state.cursor.col, 1);
844 assert_eq!(state.screen.cell(1, 3).expect("cell").text, "X");
845 assert_eq!(state.screen.cell(0, 0).expect("cell").text, "Y");
846
847 parser.advance(&mut state, b"\x1b[3EZ");
848 assert_eq!(state.screen.cell(3, 0).expect("cell").text, "Z");
849
850 parser.advance(&mut state, b"\x1b[5dV\x1b[1eW");
851 assert_eq!(state.screen.cell(4, 1).expect("cell").text, "V");
852 assert_eq!(state.screen.cell(4, 2).expect("cell").text, "W");
853 }
854
855 #[test]
856 fn parser_handles_clear_screen_and_line() {
857 let mut parser = VteTerminalParser::new();
858 let mut state = state(3, 5);
859
860 parser.advance(&mut state, b"abcde\x1b[1;3H\x1b[K");
861 assert_eq!(state.screen.line(0).expect("line").plain_text(), "ab ");
862 assert_eq!(state.screen.line(1).expect("line").plain_text(), " ");
863
864 parser.advance(&mut state, b"\x1b[2J");
865 assert_eq!(state.screen.line(0).expect("line").plain_text(), " ");
866 assert_eq!(state.screen.line(1).expect("line").plain_text(), " ");
867 }
868
869 #[test]
870 fn parser_handles_insert_and_delete_lines() {
871 let mut parser = VteTerminalParser::new();
872 let mut state = state(4, 3);
873
874 parser.advance(
875 &mut state,
876 b"\x1b[1;1H0\x1b[2;1H1\x1b[3;1H2\x1b[4;1H3\x1b[2;1H\x1b[L",
877 );
878 assert_eq!(state.screen.line(1).expect("line").plain_text(), " ");
879 assert_eq!(state.screen.line(2).expect("line").plain_text(), "1 ");
880
881 parser.advance(&mut state, b"\x1b[M");
882 assert_eq!(state.screen.line(1).expect("line").plain_text(), "1 ");
883 }
884
885 #[test]
886 fn parser_handles_sgr_basic_indexed_and_truecolor() {
887 let mut parser = VteTerminalParser::new();
888 let mut state = state(2, 8);
889
890 parser.advance(
891 &mut state,
892 b"\x1b[31;1mR\x1b[38;5;123mI\x1b[48;2;1;2;3mB\x1b[0mN",
893 );
894
895 let red = state.screen.cell(0, 0).expect("red");
896 assert_eq!(red.style.fg, TerminalColor::Ansi(1));
897 assert!(red.style.bold);
898 assert_eq!(
899 state.screen.cell(0, 1).expect("indexed").style.fg,
900 TerminalColor::Indexed(123)
901 );
902 assert_eq!(
903 state.screen.cell(0, 2).expect("rgb").style.bg,
904 TerminalColor::Rgb(1, 2, 3)
905 );
906 assert_eq!(
907 state.screen.cell(0, 3).expect("normal").style,
908 TerminalStyle::default()
909 );
910 }
911
912 #[test]
913 fn parser_handles_private_cursor_and_alternate_screen() {
914 let mut parser = VteTerminalParser::new();
915 let mut state = state(2, 8);
916
917 parser.advance(&mut state, b"main\x1b[?25l\x1b[?1049halt\x1b[?25h");
918
919 assert_eq!(state.active_screen, ActiveScreen::Alternate);
920 assert_eq!(
921 state.alternate_screen.line(0).expect("line").plain_text(),
922 "alt "
923 );
924 assert!(state.cursor.visible);
925
926 parser.advance(&mut state, b"\x1b[?1049l");
927 assert_eq!(state.active_screen, ActiveScreen::Normal);
928 assert_eq!(state.screen.line(0).expect("line").plain_text(), "main ");
929 }
930
931 #[test]
932 fn terminal_parser_handles_tui_modes_scroll_region_and_keypad() {
933 let mut parser = VteTerminalParser::new();
934 let mut state = state(5, 6);
935
936 parser.advance(
937 &mut state,
938 b"\x1b[?1h\x1b[?7l\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b=\x1b[2;4r",
939 );
940
941 assert!(state.modes.application_cursor);
942 assert!(!state.modes.wraparound);
943 assert_eq!(
944 state.modes.mouse_tracking,
945 TerminalMouseTrackingMode::ButtonMotion
946 );
947 assert!(state.modes.sgr_mouse);
948 assert!(state.modes.bracketed_paste);
949 assert!(state.modes.application_keypad);
950 assert_eq!(state.screen.scroll_top, 1);
951 assert_eq!(state.screen.scroll_bottom, 3);
952 assert_eq!(state.cursor.row, 0);
953 assert_eq!(state.cursor.col, 0);
954
955 parser.advance(
956 &mut state,
957 b"\x1b[?1l\x1b[?7h\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b>",
958 );
959
960 assert!(!state.modes.application_cursor);
961 assert!(state.modes.wraparound);
962 assert_eq!(state.modes.mouse_tracking, TerminalMouseTrackingMode::Off);
963 assert!(!state.modes.sgr_mouse);
964 assert!(!state.modes.bracketed_paste);
965 assert!(!state.modes.application_keypad);
966 }
967
968 #[test]
969 fn parser_handles_osc_title_cwd_hyperlink_and_blocked_clipboard() {
970 let mut parser = VteTerminalParser::new();
971 let mut state = state(2, 16);
972
973 parser.advance(
974 &mut state,
975 b"\x1b]0;Ailloli UI\x07\x1b]7;file:///tmp/ailloli_ui\x07\x1b]8;id=one;https://example.test\x07x\x1b]8;;\x07y\x1b]52;c;abcd\x07",
976 );
977
978 assert_eq!(state.title.as_deref(), Some("Ailloli UI"));
979 assert_eq!(state.cwd_uri.as_deref(), Some("file:///tmp/ailloli_ui"));
980 assert_eq!(state.hyperlinks.len(), 1);
981 assert_eq!(state.hyperlinks[0].uri, "https://example.test");
982 assert_eq!(
983 state.screen.cell(0, 0).expect("linked").hyperlink,
984 Some(state.hyperlinks[0].id)
985 );
986 assert_eq!(state.screen.cell(0, 1).expect("plain").hyperlink, None);
987 assert!(state
988 .warnings
989 .iter()
990 .any(|warning| warning.kind == TerminalWarningKind::BlockedSequence));
991 }
992
993 #[test]
994 fn parser_shell_osc_9001_tracks_command_state() {
995 let mut parser = VteTerminalParser::new();
996 let mut state = state(4, 16);
997
998 parser.advance(
999 &mut state,
1000 b"\x1b]9001;ailloli_ui:shell;kind=zsh\x07\
1001 \x1b]9001;ailloli_ui:cwd;uri=file:///tmp/ailloli_ui\x07\
1002 \x1b]9001;ailloli_ui:prompt_start\x07\
1003 \x1b]9001;ailloli_ui:command_start;cmd=cargo%20test;started_at_ms=10\x07ok\n\
1004 \x1b]9001;ailloli_ui:command_end;exit=0;ended_at_ms=25\x07",
1005 );
1006
1007 assert_eq!(state.shell.shell_kind, ShellKind::Zsh);
1008 assert_eq!(state.cwd_uri.as_deref(), Some("file:///tmp/ailloli_ui"));
1009 assert_eq!(
1010 state.shell.cwd_uri.as_deref(),
1011 Some("file:///tmp/ailloli_ui")
1012 );
1013 let command = state.shell.last_command.as_ref().expect("last command");
1014 assert_eq!(command.command_line, "cargo test");
1015 assert_eq!(command.status, CommandStatus::Succeeded);
1016 assert_eq!(command.duration_ms, Some(15));
1017 assert!(command.output_range.end_line.is_some());
1018 assert_eq!(
1019 state.shell.process_status,
1020 TerminalProcessStatus::Exited { code: Some(0) }
1021 );
1022 }
1023
1024 #[test]
1025 fn parser_shell_osc_accepts_legacy_namespace() {
1026 let mut parser = VteTerminalParser::new();
1027 let mut state = state(2, 16);
1028
1029 parser.advance(
1030 &mut state,
1031 b"\x1b]9001;octavui:cwd;uri=file:///tmp/legacy-project\x07",
1032 );
1033
1034 assert_eq!(state.cwd_uri.as_deref(), Some("file:///tmp/legacy-project"));
1035 }
1036
1037 #[test]
1038 fn parser_shell_osc_aliases_track_prompt_command_and_cwd() {
1039 let mut parser = VteTerminalParser::new();
1040 let mut state = state(4, 16);
1041
1042 parser.advance(
1043 &mut state,
1044 b"\x1b]1337;CurrentDir=file:///workspace/user\x07\
1045 \x1b]133;A\x07\
1046 \x1b]133;B;cmd=ls%20-la\x07output\n\
1047 \x1b]133;C;exit=2\x07",
1048 );
1049
1050 assert_eq!(state.cwd_uri.as_deref(), Some("file:///workspace/user"));
1051 assert!(state.shell.last_prompt_line.is_some());
1052 let command = state.shell.last_command.as_ref().expect("last command");
1053 assert_eq!(command.command_line, "ls -la");
1054 assert_eq!(command.status, CommandStatus::Failed);
1055 assert_eq!(command.exit_code, Some(2));
1056 }
1057
1058 #[test]
1059 fn parser_shell_prompt_heuristic_marks_prompt_without_command_exit() {
1060 let mut parser = VteTerminalParser::new();
1061 let mut state = state(2, 16);
1062
1063 parser.advance(&mut state, b"c@h:~$ ");
1064
1065 assert!(state.shell.prompt_visible);
1066 assert!(state.shell.current_command.is_none());
1067 assert!(state.shell.last_command.is_none());
1068 }
1069
1070 #[test]
1071 fn parser_shell_malformed_osc_warns_without_mutating_command_state() {
1072 let mut parser = VteTerminalParser::new();
1073 let mut state = state(2, 16);
1074
1075 parser.advance(
1076 &mut state,
1077 b"\x1b]9001;bad-action\x07\x1b]9001;ailloli_ui:cwd\x07",
1078 );
1079
1080 assert!(state.shell.current_command.is_none());
1081 assert!(state.shell.history.is_empty());
1082 assert!(state
1083 .warnings
1084 .iter()
1085 .any(|warning| warning.kind == TerminalWarningKind::UnsupportedSequence));
1086 }
1087
1088 #[test]
1089 fn parser_shell_events_are_ordered_and_drainable() {
1090 let mut parser = VteTerminalParser::new();
1091 let mut state = state(2, 16);
1092
1093 parser.advance(
1094 &mut state,
1095 b"\x1b]9001;ailloli_ui:process_status;status=starting\x07\
1096 \x1b]9001;ailloli_ui:process_status;status=running\x07",
1097 );
1098
1099 let events = state.shell.drain_events();
1100 assert!(matches!(
1101 events.as_slice(),
1102 [
1103 ShellEvent::ProcessStatusChanged {
1104 status: TerminalProcessStatus::Starting
1105 },
1106 ShellEvent::ProcessStatusChanged {
1107 status: TerminalProcessStatus::Running
1108 }
1109 ]
1110 ));
1111 }
1112
1113 #[test]
1114 fn parser_records_unsupported_warnings() {
1115 let mut parser = VteTerminalParser::new();
1116 let mut state = state(2, 8);
1117
1118 parser.advance(&mut state, b"\x1b[?999h\x1bP1;2zignored\x1b\\");
1119
1120 assert!(state
1121 .warnings
1122 .iter()
1123 .any(|warning| warning.kind == TerminalWarningKind::UnsupportedSequence));
1124 }
1125}