1#[cfg(unix)]
8use std::{fs::OpenOptions, os::fd::AsFd as _};
9use std::{
10 io::{self, IsTerminal as _, Read, Write},
11 thread,
12 time::{Duration, Instant},
13};
14
15#[cfg(unix)]
16use nix::{
17 fcntl::{FcntlArg, OFlag, fcntl},
18 poll::{PollFd, PollFlags, PollTimeout, poll},
19 sys::termios::{SetArg, cfmakeraw, tcgetattr, tcsetattr},
20};
21
22use crate::{Graphics, escape::esc};
23
24const FORCE_IMAGE_PROTOCOL: &str = "OMP_FORCE_IMAGE_PROTOCOL";
25const FORCE_CHARSET: &str = "OMP_TUI_CHARSET";
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum TerminalId {
31 Base,
33 TrueColor,
35 Kitty,
37 Ghostty,
39 Wezterm,
41 Iterm2,
43 Vscode,
45 Alacritty,
47 Warp,
49}
50
51impl TerminalId {
52 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Base => "base",
56 Self::TrueColor => "trueColor",
57 Self::Kitty => "kitty",
58 Self::Ghostty => "ghostty",
59 Self::Wezterm => "wezterm",
60 Self::Iterm2 => "iterm2",
61 Self::Vscode => "vscode",
62 Self::Alacritty => "alacritty",
63 Self::Warp => "warp",
64 }
65 }
66}
67
68impl std::fmt::Display for TerminalId {
69 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 formatter.write_str(self.as_str())
71 }
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum TerminalPlatform {
77 Windows,
79 Linux,
81 MacOs,
83 Other,
85}
86
87impl TerminalPlatform {
88 const fn current() -> Self {
89 #[cfg(target_os = "windows")]
90 return Self::Windows;
91 #[cfg(target_os = "linux")]
92 return Self::Linux;
93 #[cfg(target_os = "macos")]
94 return Self::MacOs;
95 #[allow(unreachable_code, reason = "supported platforms return above")]
96 Self::Other
97 }
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum NotifyProtocol {
103 Bell,
105 Osc9,
107 Osc99,
109}
110
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub struct TerminalCaps {
114 pub id: TerminalId,
116 pub charset: crate::Charset,
119 pub graphics: Graphics,
121 pub kitty_placeholders: bool,
123 pub cell_px: Option<(u16, u16)>,
125 pub sixel_color_registers: Option<u16>,
127 pub sync_output: bool,
129 pub kitty_keyboard: Option<u8>,
131 pub screen_to_scrollback: bool,
133 pub margin_scrollback: bool,
136 pub hyperlinks: bool,
138 pub text_sizing: bool,
140 pub deccara: bool,
142 pub notify: NotifyProtocol,
144 pub osc99_confirmed: bool,
146 pub background: Option<(u16, u16, u16)>,
148 pub appearance_notifications: bool,
150 pub in_band_resize: bool,
152 pub paste_events: bool,
154 pub xterm_scroll_to_bottom_on_output: bool,
156 pub xterm_scroll_to_bottom_on_key_press: bool,
158 pub jamo_width: u8,
160 pub inside_tmux: bool,
162 pub inside_multiplexer: bool,
164 sync_output_override: Option<bool>,
165}
166
167impl TerminalCaps {
168 pub fn resolve(
175 mut env_caps: Self,
176 probe: Option<&ProbeResults>,
177 forced: Option<Graphics>,
178 ) -> Self {
179 let graphics_forced = forced.is_some();
180 if let Some(graphics) = forced {
181 env_caps.graphics = graphics;
182 env_caps.kitty_placeholders = graphics == Graphics::KittyPlaceholders;
183 }
184 let mut probe_overrode_graphics = false;
185 if let Some(probe) = probe {
186 env_caps.cell_px = probe.cell_px.or(env_caps.cell_px);
187 env_caps.sixel_color_registers = probe
188 .sixel_color_registers
189 .or(env_caps.sixel_color_registers);
190 if env_caps.sync_output_override.is_none()
191 && let Some(sync_output) = probe.sync_output
192 {
193 env_caps.sync_output = sync_output;
194 }
195 if let Some(kitty_keyboard) = probe.kitty_keyboard {
196 env_caps.kitty_keyboard = Some(kitty_keyboard);
197 }
198 env_caps.background = probe.background.or(env_caps.background);
199 env_caps.osc99_confirmed |= probe.osc99_confirmed;
200 env_caps.appearance_notifications |= probe.appearance_notifications;
201 env_caps.in_band_resize |= probe.in_band_resize;
202 env_caps.paste_events |= probe.paste_events;
203 env_caps.xterm_scroll_to_bottom_on_output = probe.xterm_scroll_to_bottom_on_output;
204 env_caps.xterm_scroll_to_bottom_on_key_press = probe.xterm_scroll_to_bottom_on_key_press;
205 if !graphics_forced {
206 if probe.kitty_graphics == Some(true) {
207 probe_overrode_graphics = true;
208 env_caps.graphics = if env_caps.kitty_placeholders {
209 Graphics::KittyPlaceholders
210 } else {
211 Graphics::KittyDirect
212 };
213 } else if probe.supports_sixel() {
214 probe_overrode_graphics = true;
215 env_caps.graphics = Graphics::Sixel;
216 } else if probe.da1_attributes.is_some() {
217 probe_overrode_graphics = true;
218 env_caps.graphics = Graphics::Cells;
219 }
220 }
221 }
222 if probe_overrode_graphics && env_caps.inside_multiplexer && !env_caps.inside_tmux {
223 env_caps.graphics = Graphics::Cells;
224 env_caps.kitty_placeholders = false;
225 }
226 env_caps
227 }
228}
229
230#[derive(Clone, Debug, Default, Eq, PartialEq)]
232pub struct ProbeResults {
233 pub kitty_graphics: Option<bool>,
235 pub cell_px: Option<(u16, u16)>,
237 pub sixel_color_registers: Option<u16>,
239 pub sixel_status: Option<u16>,
241 pub da1_attributes: Option<Vec<u16>>,
243 pub sync_output: Option<bool>,
245 pub kitty_keyboard: Option<u8>,
247 pub background: Option<(u16, u16, u16)>,
249 pub osc99_confirmed: bool,
251 pub appearance_notifications_set: bool,
253 pub appearance_notifications: bool,
255 pub in_band_resize_set: bool,
257 pub in_band_resize: bool,
259 pub paste_events: bool,
261 pub insert_mode_set: bool,
263 pub newline_mode_set: bool,
265 pub xterm_scroll_to_bottom_on_output: bool,
269 pub xterm_scroll_to_bottom_on_key_press: bool,
273 pub preserved_input: Vec<u8>,
275 pub timed_out: bool,
277}
278
279impl ProbeResults {
280 pub fn supports_sixel(&self) -> bool {
282 self
283 .da1_attributes
284 .as_ref()
285 .is_some_and(|attributes| attributes.contains(&4))
286 || (self.sixel_status == Some(0)
287 && self
288 .sixel_color_registers
289 .is_some_and(|registers| registers > 0))
290 }
291}
292
293#[derive(Debug, Default)]
295pub struct ProbeParser {
296 pending: Vec<u8>,
297 results: ProbeResults,
298 complete: bool,
299}
300
301impl ProbeParser {
302 pub fn new() -> Self {
304 Self::default()
305 }
306
307 pub fn feed(&mut self, bytes: &[u8], preserved: &mut Vec<u8>) {
312 self.pending.extend_from_slice(bytes);
313 let mut cursor = 0;
314 while cursor < self.pending.len() {
315 if self.pending[cursor] != 0x1b {
316 preserved.push(self.pending[cursor]);
317 cursor += 1;
318 continue;
319 }
320 if cursor + 1 == self.pending.len() {
321 break;
322 }
323 match self.pending[cursor + 1] {
324 b'_' => {
325 if cursor + 2 == self.pending.len() {
326 break;
327 }
328 if self.pending[cursor + 2] != b'G' {
329 preserved.push(0x1b);
330 cursor += 1;
331 continue;
332 }
333 let Some(relative_end) = self.pending[cursor + 3..]
334 .windows(2)
335 .position(|window| window == b"\x1b\\")
336 else {
337 break;
338 };
339 let end = cursor + 3 + relative_end;
340 let sequence = self.pending[cursor + 3..end].to_vec();
341 if !self.parse_kitty(&sequence) {
342 preserved.extend_from_slice(&self.pending[cursor..end + 2]);
343 }
344 cursor = end + 2;
345 },
346 b'[' => {
347 let Some(relative_end) = self.pending[cursor + 2..]
348 .iter()
349 .position(|byte| (0x40..=0x7e).contains(byte))
350 else {
351 break;
352 };
353 let end = cursor + 2 + relative_end;
354 let sequence = self.pending[cursor + 2..=end].to_vec();
355 if !self.parse_csi(&sequence) {
356 preserved.extend_from_slice(&self.pending[cursor..=end]);
357 }
358 cursor = end + 1;
359 },
360 b']' => {
361 let mut terminator = None;
362 let mut index = cursor + 2;
363 while index < self.pending.len() {
364 if self.pending[index] == 0x07 {
365 terminator = Some((index, 1));
366 break;
367 }
368 if self.pending[index] == 0x1b && self.pending.get(index + 1) == Some(&b'\\') {
369 terminator = Some((index, 2));
370 break;
371 }
372 index += 1;
373 }
374 let Some((end, terminator_len)) = terminator else {
375 break;
376 };
377 let payload = self.pending[cursor + 2..end].to_vec();
378 if !self.parse_osc(&payload) {
379 preserved.extend_from_slice(&self.pending[cursor..end + terminator_len]);
380 }
381 cursor = end + terminator_len;
382 },
383 _ => {
384 preserved.push(0x1b);
385 cursor += 1;
386 },
387 }
388 }
389 self.pending.drain(..cursor);
390 }
391
392 pub const fn is_complete(&self) -> bool {
394 self.complete
395 }
396
397 pub const fn results(&self) -> &ProbeResults {
399 &self.results
400 }
401
402 pub fn finish(&mut self, preserved: &mut Vec<u8>) {
404 preserved.append(&mut self.pending);
405 }
406
407 fn parse_kitty(&mut self, payload: &[u8]) -> bool {
408 let Some(separator) = payload.iter().position(|byte| *byte == b';') else {
409 return false;
410 };
411 if !payload[..separator]
412 .split(|byte| *byte == b',')
413 .any(|parameter| parameter == b"i=31")
414 {
415 return false;
416 }
417 self.results.kitty_graphics = Some(&payload[separator + 1..] == b"OK");
418 true
419 }
420
421 fn parse_osc(&mut self, payload: &[u8]) -> bool {
422 if let Some(color) = payload.strip_prefix(b"11;").and_then(parse_osc11_color) {
423 self.results.background = Some(color);
424 return true;
425 }
426 let Some(payload) = payload.strip_prefix(b"99;") else {
427 return false;
428 };
429 let Some(separator) = payload.iter().position(|byte| *byte == b';') else {
430 return false;
431 };
432 let metadata = &payload[..separator];
433 if key_value(metadata, b"i") != Some(OSC99_PROBE_ID)
434 || key_value(metadata, b"p") != Some(b"?")
435 {
436 return false;
437 }
438 let Some(types) = key_value(&payload[separator + 1..], b"p") else {
439 return true;
440 };
441 self.results.osc99_confirmed = types
442 .split(|byte| *byte == b',')
443 .any(|kind| kind == b"title");
444 true
445 }
446
447 fn parse_csi(&mut self, sequence: &[u8]) -> bool {
448 let Some((&final_byte, body)) = sequence.split_last() else {
449 return false;
450 };
451 match final_byte {
452 b'S' => {
453 let Some(parameters) = body.strip_prefix(b"?2;") else {
454 return false;
455 };
456 let mut fields = parameters.split(|byte| *byte == b';');
457 let Some(status) = fields.next().and_then(parse_u16) else {
458 return false;
459 };
460 let Some(registers) = fields.next().and_then(parse_u16) else {
461 return false;
462 };
463 self.results.sixel_status = Some(status);
464 self.results.sixel_color_registers = Some(registers);
465 true
466 },
467 b't' => {
468 let Some(parameters) = body.strip_prefix(b"6;") else {
469 return false;
470 };
471 let mut fields = parameters.split(|byte| *byte == b';');
472 let Some(height) = fields.next().and_then(parse_u16) else {
473 return false;
474 };
475 let Some(width) = fields.next().and_then(parse_u16) else {
476 return false;
477 };
478 self.results.cell_px = Some((width, height));
479 true
480 },
481 b'c' => {
482 let Some(parameters) = body.strip_prefix(b"?") else {
483 return false;
484 };
485 let attributes = parameters
486 .split(|byte| *byte == b';')
487 .filter(|field| !field.is_empty())
488 .map(parse_u16)
489 .collect::<Option<Vec<_>>>();
490 let Some(attributes) = attributes else {
491 return false;
492 };
493 self.results.da1_attributes = Some(attributes);
494 self.complete = true;
495 true
496 },
497 b'y' => {
498 let (private, parameters) = match body.strip_prefix(b"?") {
499 Some(parameters) => (true, parameters),
500 None => (false, body),
501 };
502 let Some(parameters) = parameters.strip_suffix(b"$") else {
503 return false;
504 };
505 let mut fields = parameters.split(|byte| *byte == b';');
506 let Some(mode) = fields.next().and_then(parse_u16) else {
507 return false;
508 };
509 let Some(status) = fields.next().and_then(parse_u16) else {
510 return false;
511 };
512 if fields.next().is_some() {
513 return false;
514 }
515 match (private, mode) {
516 (false, 4) => self.results.insert_mode_set = status == 1,
517 (false, 20) => self.results.newline_mode_set = status == 1,
518 (true, 1010) => {
519 self.results.xterm_scroll_to_bottom_on_output = status == 1;
520 },
521 (true, 1011) => {
522 self.results.xterm_scroll_to_bottom_on_key_press = status == 1;
523 },
524 (true, 2026) => self.results.sync_output = Some(matches!(status, 1 | 2)),
525 (true, mode @ (2031 | 2048)) => {
526 let supported = matches!(status, 1..=3);
527 let set = matches!(status, 1 | 3);
528 if mode == 2031 {
529 self.results.appearance_notifications = supported;
530 self.results.appearance_notifications_set = set;
531 } else {
532 self.results.in_band_resize = supported;
533 self.results.in_band_resize_set = set;
534 }
535 },
536 (true, 5522) => self.results.paste_events = matches!(status, 1 | 2),
537 _ => return false,
538 }
539 true
540 },
541 b'u' => {
542 let Some(flags) = body
543 .strip_prefix(b"?")
544 .and_then(parse_u16)
545 .and_then(|flags| u8::try_from(flags).ok())
546 else {
547 return false;
548 };
549 self.results.kitty_keyboard = Some(flags);
550 true
551 },
552 _ => false,
553 }
554 }
555
556 fn into_results(mut self, preserved: Vec<u8>, timed_out: bool) -> ProbeResults {
557 self.results.preserved_input = preserved;
558 self.results.timed_out = timed_out;
559 self.results
560 }
561}
562
563fn parse_u16(bytes: &[u8]) -> Option<u16> {
564 (!bytes.is_empty() && bytes.iter().all(u8::is_ascii_digit))
565 .then(|| std::str::from_utf8(bytes).ok()?.parse().ok())
566 .flatten()
567}
568
569const OSC99_PROBE_ID: &[u8] = b"omp-tui";
570
571fn key_value<'a>(section: &'a [u8], key: &[u8]) -> Option<&'a [u8]> {
572 section.split(|byte| *byte == b':').find_map(|field| {
573 let separator = field.iter().position(|byte| *byte == b'=')?;
574 (&field[..separator] == key).then_some(&field[separator + 1..])
575 })
576}
577
578fn parse_osc11_color(payload: &[u8]) -> Option<(u16, u16, u16)> {
579 let components = payload
580 .strip_prefix(b"rgb:")
581 .or_else(|| payload.strip_prefix(b"rgba:"))?;
582 let mut components = components.split(|byte| *byte == b'/');
583 let red = components.next().and_then(parse_hex_component)?;
584 let green = components.next().and_then(parse_hex_component)?;
585 let blue = components.next().and_then(parse_hex_component)?;
586 components.next().is_none().then_some((red, green, blue))
587}
588
589fn parse_hex_component(component: &[u8]) -> Option<u16> {
590 if !(1..=4).contains(&component.len()) || !component.iter().all(u8::is_ascii_hexdigit) {
591 return None;
592 }
593 let value = u32::from_str_radix(std::str::from_utf8(component).ok()?, 16).ok()?;
594 let maximum = 16_u32.pow(u32::try_from(component.len()).ok()?) - 1;
595 u16::try_from(value * u32::from(u16::MAX) / maximum).ok()
596}
597
598const PROBE_BATCH: &[u8] = esc!(
599 kitty_graphics_query,
600 sixel_color_registers_query,
601 cell_pixels_query,
602 background_color_query,
603 osc99_query,
604 ?insert_mode,
605 ?newline_mode,
606 ?scroll_on_output,
607 ?scroll_on_key_press,
608 ?sync_output,
609 ?appearance_notifications,
610 ?in_band_resize,
611 ?paste_events,
612 kitty_keyboard_query,
613 primary_device_attributes_query,
614)
615.as_bytes();
616const PROBE_BATCH_NO_OSC99: &[u8] = esc!(
617 kitty_graphics_query,
618 sixel_color_registers_query,
619 cell_pixels_query,
620 background_color_query,
621 ?insert_mode,
622 ?newline_mode,
623 ?scroll_on_output,
624 ?scroll_on_key_press,
625 ?sync_output,
626 ?appearance_notifications,
627 ?in_band_resize,
628 ?paste_events,
629 kitty_keyboard_query,
630 primary_device_attributes_query,
631)
632.as_bytes();
633
634fn materialize_probe_batch(
635 inside_tmux: bool,
636 include_osc99: bool,
637) -> std::borrow::Cow<'static, [u8]> {
638 let batch = if include_osc99 {
639 PROBE_BATCH
640 } else {
641 PROBE_BATCH_NO_OSC99
642 };
643 if !inside_tmux {
644 return std::borrow::Cow::Borrowed(batch);
645 }
646 let mut wrapped = Vec::with_capacity(batch.len() + 16);
647 wrapped.extend_from_slice(esc!(dcs, "tmux;").as_bytes());
648 for byte in batch {
649 wrapped.push(*byte);
650 if *byte == 0x1b {
651 wrapped.extend_from_slice(esc!(escape).as_bytes());
652 }
653 }
654 wrapped.extend_from_slice(esc!(st).as_bytes());
655 std::borrow::Cow::Owned(wrapped)
656}
657
658pub fn probe_terminal(tty: &mut (impl Read + Write), timeout: Duration) -> ProbeResults {
667 let caps = detect();
668 let batch = materialize_probe_batch(
669 caps.inside_tmux,
670 caps.notify == NotifyProtocol::Osc99 && !caps.inside_multiplexer,
671 );
672 if tty.write_all(&batch).and_then(|()| tty.flush()).is_err() {
673 return ProbeResults { timed_out: true, ..ProbeResults::default() };
674 }
675 let deadline = Instant::now() + timeout;
676 let mut parser = ProbeParser::new();
677 let mut preserved = Vec::new();
678 let mut buffer = [0; 256];
679 while !parser.is_complete() && Instant::now() < deadline {
680 match tty.read(&mut buffer) {
681 Ok(0) => break,
682 Ok(read) => parser.feed(&buffer[..read], &mut preserved),
683 Err(error)
684 if matches!(error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) =>
685 {
686 thread::sleep(Duration::from_millis(1));
687 },
688 Err(_) => break,
689 }
690 }
691
692 let timed_out = !parser.is_complete();
693 parser.finish(&mut preserved);
694 parser.into_results(preserved, timed_out)
695}
696pub fn negotiate(timeout: Duration) -> (TerminalCaps, ProbeResults) {
704 #[cfg(not(unix))]
705 let _ = timeout;
706 let env_caps = detect();
707 let forced = forced_graphics_from_environment(env_caps);
708 #[cfg(unix)]
709 let probe = probe_controlling_terminal(
710 timeout,
711 env_caps.inside_tmux,
712 env_caps.notify == NotifyProtocol::Osc99 && !env_caps.inside_multiplexer,
713 );
714 #[cfg(not(unix))]
715 let probe: Option<ProbeResults> = None;
716 let caps = TerminalCaps::resolve(env_caps, probe.as_ref(), forced);
717 (caps, probe.unwrap_or_default())
718}
719pub async fn negotiate_async(timeout: Duration) -> (TerminalCaps, ProbeResults) {
724 tokio::task::spawn_blocking(move || negotiate(timeout))
725 .await
726 .unwrap_or_else(|_| (detect(), ProbeResults::default()))
727}
728
729fn forced_graphics_from_environment(caps: TerminalCaps) -> Option<Graphics> {
730 let vars = |name: &str| std::env::var(name).ok();
731 forced_protocol(&vars).map(|protocol| match protocol {
732 ForcedProtocol::Force(ImageProtocol::Kitty) if caps.kitty_placeholders => {
733 Graphics::KittyPlaceholders
734 },
735 ForcedProtocol::Force(ImageProtocol::Kitty) => Graphics::KittyDirect,
736 ForcedProtocol::Force(ImageProtocol::Sixel) => Graphics::Sixel,
737 ForcedProtocol::Force(ImageProtocol::Iterm2) => Graphics::Iterm2,
738 ForcedProtocol::Disable => Graphics::Cells,
739 })
740}
741
742#[cfg(unix)]
743fn probe_controlling_terminal(
744 timeout: Duration,
745 inside_tmux: bool,
746 include_osc99: bool,
747) -> Option<ProbeResults> {
748 let mut tty = crate::tty::open(OpenOptions::new().read(true).write(true)).ok()?;
749 let original_termios = tcgetattr(&tty).ok()?;
750 let mut raw_termios = original_termios.clone();
751 cfmakeraw(&mut raw_termios);
752 tcsetattr(&tty, SetArg::TCSANOW, &raw_termios).ok()?;
753 let Ok(original_flags) = fcntl(&tty, FcntlArg::F_GETFL) else {
754 let _ = tcsetattr(&tty, SetArg::TCSANOW, &original_termios);
755 return None;
756 };
757 let flags = OFlag::from_bits_truncate(original_flags);
758 if fcntl(&tty, FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK)).is_err() {
759 let _ = tcsetattr(&tty, SetArg::TCSANOW, &original_termios);
760 return None;
761 }
762
763 let result = probe_polled(&mut tty, timeout, inside_tmux, include_osc99);
764 let _ = fcntl(&tty, FcntlArg::F_SETFL(flags));
765 let _ = tcsetattr(&tty, SetArg::TCSANOW, &original_termios);
766 Some(result)
767}
768
769#[cfg(unix)]
770fn probe_polled(
771 tty: &mut std::fs::File,
772 timeout: Duration,
773 inside_tmux: bool,
774 include_osc99: bool,
775) -> ProbeResults {
776 let batch = materialize_probe_batch(inside_tmux, include_osc99);
777 if tty.write_all(&batch).and_then(|()| tty.flush()).is_err() {
778 return ProbeResults { timed_out: true, ..ProbeResults::default() };
779 }
780 let deadline = Instant::now() + timeout;
781 let mut parser = ProbeParser::new();
782 let mut preserved = Vec::new();
783 let mut buffer = [0; 256];
784 while !parser.is_complete() {
785 let remaining = deadline.saturating_duration_since(Instant::now());
786 if remaining.is_zero() {
787 break;
788 }
789 let mut descriptors = [PollFd::new(tty.as_fd(), PollFlags::POLLIN)];
790 let poll_timeout = PollTimeout::try_from(remaining).unwrap_or(PollTimeout::MAX);
791 match poll(&mut descriptors, poll_timeout) {
792 Ok(0) | Err(_) => break,
793 Ok(_) => match tty.read(&mut buffer) {
794 Ok(0) => break,
795 Ok(read) => parser.feed(&buffer[..read], &mut preserved),
796 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {},
797 Err(_) => break,
798 },
799 }
800 }
801 let timed_out = !parser.is_complete();
802 parser.finish(&mut preserved);
803 parser.into_results(preserved, timed_out)
804}
805
806pub fn detect() -> TerminalCaps {
808 detect_with(
809 &|name| std::env::var(name).ok(),
810 TerminalPlatform::current(),
811 std::io::stdout().is_terminal() || crate::tty::overridden(),
812 )
813}
814
815pub fn detect_from(
821 vars: &impl Fn(&str) -> Option<String>,
822 platform: TerminalPlatform,
823) -> TerminalCaps {
824 detect_with(vars, platform, true)
825}
826
827#[derive(Clone, Copy, Eq, PartialEq)]
828enum ImageProtocol {
829 Kitty,
830 Iterm2,
831 Sixel,
832}
833
834fn value(vars: &impl Fn(&str) -> Option<String>, name: &str) -> Option<String> {
835 vars(name).filter(|value| !value.is_empty())
836}
837
838fn detect_terminal_id(vars: &impl Fn(&str) -> Option<String>) -> TerminalId {
839 for (marker, id) in [
840 ("KITTY_WINDOW_ID", TerminalId::Kitty),
841 ("GHOSTTY_RESOURCES_DIR", TerminalId::Ghostty),
842 ("WEZTERM_PANE", TerminalId::Wezterm),
843 ("ITERM_SESSION_ID", TerminalId::Iterm2),
844 ("VSCODE_PID", TerminalId::Vscode),
845 ("ALACRITTY_WINDOW_ID", TerminalId::Alacritty),
846 ] {
847 if value(vars, marker).is_some() {
848 return id;
849 }
850 }
851 if let Some(program) = value(vars, "TERM_PROGRAM") {
852 for (name, id) in [
853 ("kitty", TerminalId::Kitty),
854 ("ghostty", TerminalId::Ghostty),
855 ("wezterm", TerminalId::Wezterm),
856 ("iterm.app", TerminalId::Iterm2),
857 ("vscode", TerminalId::Vscode),
858 ("alacritty", TerminalId::Alacritty),
859 ("warpterminal", TerminalId::Warp),
860 ] {
861 if program.eq_ignore_ascii_case(name) {
862 return id;
863 }
864 }
865 }
866 if value(vars, "TERM").is_some_and(|term| term.to_ascii_lowercase().contains("ghostty")) {
867 return TerminalId::Ghostty;
868 }
869 if value(vars, "COLORTERM").is_some_and(|color| {
870 color.eq_ignore_ascii_case("truecolor") || color.eq_ignore_ascii_case("24bit")
871 }) {
872 return TerminalId::TrueColor;
873 }
874 TerminalId::Base
875}
876
877fn detect_charset(vars: &impl Fn(&str) -> Option<String>, id: TerminalId) -> crate::Charset {
882 if let Some(forced) = value(vars, FORCE_CHARSET) {
883 match forced.trim().to_ascii_lowercase().as_str() {
884 "ascii" => return crate::Charset::Ascii,
885 "unicode" => return crate::Charset::Unicode,
886 "nerd" | "nerdfont" | "nerd-font" => return crate::Charset::NerdFont,
887 _ => {},
888 }
889 }
890 if value(vars, "TERM").is_none_or(|term| term.eq_ignore_ascii_case("dumb")) {
891 return crate::Charset::Ascii;
892 }
893 match id {
894 TerminalId::Kitty | TerminalId::Ghostty | TerminalId::Wezterm | TerminalId::Warp => {
895 crate::Charset::NerdFont
896 },
897 TerminalId::Base
898 | TerminalId::TrueColor
899 | TerminalId::Iterm2
900 | TerminalId::Vscode
901 | TerminalId::Alacritty => crate::Charset::Unicode,
902 }
903}
904
905fn inside_multiplexer(vars: &impl Fn(&str) -> Option<String>) -> bool {
906 if ["TMUX", "STY", "ZELLIJ"]
907 .into_iter()
908 .any(|name| value(vars, name).is_some())
909 || value(vars, "HERDR_ENV").is_some_and(|value| value == "1")
910 || ["CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_REMOTE_TRANSPORT"]
911 .into_iter()
912 .any(|name| value(vars, name).is_some())
913 {
914 return true;
915 }
916 value(vars, "TERM").is_some_and(|term| {
917 let term = term.to_ascii_lowercase();
918 term.starts_with("tmux") || term.starts_with("screen")
919 })
920}
921
922enum ForcedProtocol {
923 Force(ImageProtocol),
924 Disable,
925}
926
927fn forced_protocol(vars: &impl Fn(&str) -> Option<String>) -> Option<ForcedProtocol> {
928 let raw = value(vars, FORCE_IMAGE_PROTOCOL)?;
929 let raw = raw.trim().to_ascii_lowercase();
930 if raw.is_empty() {
931 return None;
932 }
933 Some(match raw.as_str() {
934 "kitty" => ForcedProtocol::Force(ImageProtocol::Kitty),
935 "iterm2" | "iterm" => ForcedProtocol::Force(ImageProtocol::Iterm2),
936 "sixel" => ForcedProtocol::Force(ImageProtocol::Sixel),
937 _ => ForcedProtocol::Disable,
938 })
939}
940
941fn windows_terminal_sixel(
942 vars: &impl Fn(&str) -> Option<String>,
943 platform: TerminalPlatform,
944) -> bool {
945 if platform != TerminalPlatform::Windows || value(vars, "WT_SESSION").is_none() {
946 return false;
947 }
948 if value(vars, "TERM_PROGRAM")
949 .is_some_and(|program| !program.eq_ignore_ascii_case("windows_terminal"))
950 {
951 return false;
952 }
953 let Some(version) = value(vars, "TERM_PROGRAM_VERSION") else {
954 return false;
955 };
956 let mut parts = version.trim().split('.');
957 let major = parts
958 .next()
959 .filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
960 .and_then(|part| part.parse::<u32>().ok());
961 let minor = parts.next().and_then(|part| {
962 let digits = part.bytes().take_while(u8::is_ascii_digit).count();
963 (digits > 0)
964 .then(|| part[..digits].parse::<u32>().ok())
965 .flatten()
966 });
967 let (Some(major), Some(minor)) = (major, minor) else {
968 return false;
969 };
970 major > 1 || (major == 1 && minor >= 22)
971}
972
973fn warp_protocol(
974 vars: &impl Fn(&str) -> Option<String>,
975 platform: TerminalPlatform,
976) -> Option<ImageProtocol> {
977 let windows_host = platform == TerminalPlatform::Windows
978 || (platform == TerminalPlatform::Linux
979 && (value(vars, "WSL_DISTRO_NAME").is_some() || value(vars, "WSL_INTEROP").is_some()));
980 (!windows_host).then_some(ImageProtocol::Kitty)
981}
982
983fn fallback_protocol(
984 vars: &impl Fn(&str) -> Option<String>,
985 id: TerminalId,
986 tty: bool,
987) -> Option<ImageProtocol> {
988 if !tty || matches!(id, TerminalId::Vscode | TerminalId::Alacritty) {
989 return None;
990 }
991 let term = value(vars, "TERM")?.to_ascii_lowercase();
992 (term.contains("screen") || term.contains("tmux") || term.contains("ghostty"))
993 .then_some(ImageProtocol::Kitty)
994}
995
996fn default_protocol(
997 vars: &impl Fn(&str) -> Option<String>,
998 platform: TerminalPlatform,
999 id: TerminalId,
1000 tty: bool,
1001) -> Option<ImageProtocol> {
1002 let known = match id {
1003 TerminalId::Kitty | TerminalId::Ghostty | TerminalId::Wezterm => Some(ImageProtocol::Kitty),
1004 TerminalId::Iterm2 => Some(ImageProtocol::Iterm2),
1005 TerminalId::Warp => warp_protocol(vars, platform),
1006 TerminalId::Base | TerminalId::TrueColor | TerminalId::Vscode | TerminalId::Alacritty => None,
1007 };
1008 known.or_else(|| fallback_protocol(vars, id, tty))
1009}
1010
1011fn enabled(raw: &str) -> bool {
1012 matches!(raw.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on" | "yes" | "y")
1013}
1014
1015fn disabled(raw: &str) -> bool {
1016 matches!(raw.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no" | "n")
1017}
1018
1019fn kitty_placeholders(
1029 vars: &impl Fn(&str) -> Option<String>,
1030 id: TerminalId,
1031 inside_tmux: bool,
1032) -> bool {
1033 if value(vars, "OMP_NO_KITTY_PLACEHOLDERS").is_some_and(|raw| enabled(&raw)) {
1034 return false;
1035 }
1036 if let Some(raw) = value(vars, "OMP_KITTY_PLACEHOLDERS") {
1037 if enabled(&raw) {
1038 return true;
1039 }
1040 if disabled(&raw) {
1041 return false;
1042 }
1043 }
1044 if inside_tmux
1045 && value(vars, FORCE_IMAGE_PROTOCOL)
1046 .is_some_and(|raw| raw.trim().eq_ignore_ascii_case("kitty"))
1047 {
1048 return true;
1049 }
1050 matches!(id, TerminalId::Kitty | TerminalId::Ghostty)
1051}
1052
1053fn synchronized_output_override(vars: &impl Fn(&str) -> Option<String>) -> Option<bool> {
1054 if value(vars, "OMP_NO_SYNC_OUTPUT").is_some()
1055 || value(vars, "OMP_SYNC_OUTPUT").is_some_and(|raw| raw == "0")
1056 {
1057 return Some(false);
1058 }
1059 if value(vars, "OMP_FORCE_SYNC_OUTPUT").is_some_and(|raw| raw == "1")
1060 || value(vars, "OMP_SYNC_OUTPUT").is_some_and(|raw| raw == "1")
1061 {
1062 return Some(true);
1063 }
1064 None
1065}
1066
1067fn synchronized_output_default(
1068 vars: &impl Fn(&str) -> Option<String>,
1069 id: TerminalId,
1070 inside_multiplexer: bool,
1071) -> bool {
1072 if let Some(overridden) = synchronized_output_override(vars) {
1073 return overridden;
1074 }
1075 if value(vars, "TERM_FEATURES").is_some_and(|features| features.contains("Sy"))
1076 || value(vars, "WT_SESSION").is_some()
1077 {
1078 return true;
1079 }
1080 if inside_multiplexer {
1081 return false;
1082 }
1083 matches!(
1084 id,
1085 TerminalId::Kitty
1086 | TerminalId::Ghostty
1087 | TerminalId::Wezterm
1088 | TerminalId::Iterm2
1089 | TerminalId::Vscode
1090 | TerminalId::Alacritty
1091 )
1092}
1093
1094const fn terminal_feature_table(id: TerminalId) -> (bool, bool, bool, bool) {
1095 match id {
1096 TerminalId::Kitty => (true, true, true, true),
1097 TerminalId::Ghostty
1098 | TerminalId::Wezterm
1099 | TerminalId::Iterm2
1100 | TerminalId::Vscode
1101 | TerminalId::Alacritty => (true, false, false, false),
1102 TerminalId::Base | TerminalId::TrueColor | TerminalId::Warp => (false, false, false, false),
1103 }
1104}
1105
1106const fn margin_scrollback_default(id: TerminalId) -> bool {
1113 !matches!(id, TerminalId::Base | TerminalId::TrueColor | TerminalId::Warp)
1114}
1115
1116fn parse_major_minor(version: &str) -> Option<(u32, u32)> {
1117 let version = version.trim();
1118 let separator = version.find('.')?;
1119 let major = &version[..separator];
1120 let minor = version[separator + 1..]
1121 .bytes()
1122 .take_while(u8::is_ascii_digit)
1123 .count();
1124 if major.is_empty() || !major.bytes().all(|byte| byte.is_ascii_digit()) || minor == 0 {
1125 return None;
1126 }
1127 Some((major.parse().ok()?, version[separator + 1..separator + 1 + minor].parse().ok()?))
1128}
1129
1130fn hyperlinks_default(vars: &impl Fn(&str) -> Option<String>, static_capability: bool) -> bool {
1131 if value(vars, "OMP_NO_HYPERLINKS").is_some_and(|raw| raw == "1") {
1132 return false;
1133 }
1134 if value(vars, "OMP_FORCE_HYPERLINKS").is_some_and(|raw| raw == "1") {
1135 return true;
1136 }
1137 if !static_capability || value(vars, "STY").is_some() {
1138 return false;
1139 }
1140 if value(vars, "TMUX").is_some() {
1141 if !value(vars, "TERM_PROGRAM").is_some_and(|program| program.eq_ignore_ascii_case("tmux")) {
1142 return false;
1143 }
1144 return value(vars, "TERM_PROGRAM_VERSION")
1145 .as_deref()
1146 .and_then(parse_major_minor)
1147 .is_some_and(|(major, minor)| major > 3 || (major == 3 && minor >= 4));
1148 }
1149 !value(vars, "TERM").is_some_and(|term| {
1150 let term = term.to_ascii_lowercase();
1151 term.starts_with("screen") || term.starts_with("tmux")
1152 })
1153}
1154
1155const fn notification_protocol(id: TerminalId) -> NotifyProtocol {
1156 match id {
1157 TerminalId::Kitty => NotifyProtocol::Osc99,
1158 TerminalId::Ghostty | TerminalId::Wezterm | TerminalId::Iterm2 | TerminalId::Warp => {
1159 NotifyProtocol::Osc9
1160 },
1161 TerminalId::Base | TerminalId::TrueColor | TerminalId::Vscode | TerminalId::Alacritty => {
1162 NotifyProtocol::Bell
1163 },
1164 }
1165}
1166
1167const fn jamo_width(id: TerminalId) -> u8 {
1168 match id {
1169 TerminalId::Ghostty => 2,
1170 TerminalId::Warp => 1,
1171 _ => 0,
1172 }
1173}
1174
1175fn detect_with(
1176 vars: &impl Fn(&str) -> Option<String>,
1177 platform: TerminalPlatform,
1178 tty: bool,
1179) -> TerminalCaps {
1180 let id = detect_terminal_id(vars);
1181 let inside_tmux = value(vars, "TMUX").is_some()
1182 || value(vars, "TERM").is_some_and(|term| term.to_ascii_lowercase().starts_with("tmux"));
1183 let inside_multiplexer = inside_multiplexer(vars);
1184 let sync_output_override = synchronized_output_override(vars);
1185 let sync_output = synchronized_output_default(vars, id, inside_multiplexer);
1186 let (static_hyperlinks, screen_to_scrollback, text_sizing, deccara) = terminal_feature_table(id);
1187 let margin_scrollback = inside_tmux || (margin_scrollback_default(id) && !inside_multiplexer);
1188 let hyperlinks = hyperlinks_default(vars, static_hyperlinks);
1189 let notify = notification_protocol(id);
1190 let jamo_width = jamo_width(id);
1191 let forced = forced_protocol(vars);
1192 let protocol = match forced {
1193 Some(ForcedProtocol::Force(protocol)) => Some(protocol),
1194 Some(ForcedProtocol::Disable) => None,
1195 None => default_protocol(vars, platform, id, tty)
1196 .or_else(|| windows_terminal_sixel(vars, platform).then_some(ImageProtocol::Sixel)),
1197 };
1198 let placeholders = kitty_placeholders(vars, id, inside_tmux);
1199 let graphics = match protocol {
1200 Some(ImageProtocol::Kitty) if placeholders => Graphics::KittyPlaceholders,
1201 Some(ImageProtocol::Kitty) => Graphics::KittyDirect,
1202 Some(ImageProtocol::Sixel) => Graphics::Sixel,
1203 Some(ImageProtocol::Iterm2) => Graphics::Iterm2,
1204 None => Graphics::Cells,
1205 };
1206 let graphics = if forced.is_none() && inside_multiplexer && !inside_tmux {
1207 Graphics::Cells
1208 } else {
1209 graphics
1210 };
1211 TerminalCaps {
1212 id,
1213 charset: detect_charset(vars, id),
1214 graphics,
1215 kitty_placeholders: placeholders && graphics == Graphics::KittyPlaceholders,
1216 cell_px: None,
1217 sixel_color_registers: None,
1218 sync_output,
1219 kitty_keyboard: None,
1220 screen_to_scrollback,
1221 margin_scrollback,
1222 hyperlinks,
1223 text_sizing,
1224 deccara,
1225 notify,
1226 osc99_confirmed: false,
1227 background: None,
1228 appearance_notifications: false,
1229 in_band_resize: false,
1230 paste_events: false,
1231 xterm_scroll_to_bottom_on_output: false,
1232 xterm_scroll_to_bottom_on_key_press: false,
1233 jamo_width,
1234 inside_tmux,
1235 inside_multiplexer,
1236 sync_output_override,
1237 }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use std::{
1243 collections::HashMap,
1244 io::{self, Read, Write},
1245 time::Duration,
1246 };
1247
1248 use super::{
1249 NotifyProtocol, ProbeParser, ProbeResults, TerminalCaps, TerminalPlatform, detect_from,
1250 probe_terminal,
1251 };
1252 use crate::{Graphics, InputDecoder, InputEvent, Key};
1253
1254 fn detect(entries: &[(&str, &str)], platform: TerminalPlatform) -> TerminalCaps {
1255 let vars = entries.iter().copied().collect::<HashMap<_, _>>();
1256 detect_from(&|name| vars.get(name).map(|value| (*value).to_owned()), platform)
1257 }
1258
1259 fn parse(chunks: impl IntoIterator<Item = Vec<u8>>) -> (ProbeResults, Vec<u8>) {
1260 let mut parser = ProbeParser::new();
1261 let mut preserved = Vec::new();
1262 for chunk in chunks {
1263 parser.feed(&chunk, &mut preserved);
1264 }
1265 parser.finish(&mut preserved);
1266 (parser.results().clone(), preserved)
1267 }
1268
1269 #[test]
1270 fn charset_follows_terminal_identity_and_env_override() {
1271 use crate::Charset;
1272 let ghostty = detect(
1274 &[("TERM", "xterm-ghostty"), ("GHOSTTY_RESOURCES_DIR", "/r")],
1275 TerminalPlatform::Other,
1276 );
1277 assert_eq!(ghostty.charset, Charset::NerdFont);
1278 let base = detect(&[("TERM", "xterm-256color")], TerminalPlatform::Other);
1280 assert_eq!(base.charset, Charset::Unicode);
1281 assert_eq!(detect(&[], TerminalPlatform::Other).charset, Charset::Ascii);
1283 assert_eq!(detect(&[("TERM", "dumb")], TerminalPlatform::Other).charset, Charset::Ascii);
1284 let forced = detect(
1286 &[("TERM", "xterm-ghostty"), ("OMP_TUI_CHARSET", "ascii")],
1287 TerminalPlatform::Other,
1288 );
1289 assert_eq!(forced.charset, Charset::Ascii);
1290 let ctx = crate::UiContext::default().with_terminal_caps(&ghostty);
1292 assert_eq!(ctx.charset, Charset::NerdFont);
1293 }
1294
1295 #[test]
1296 fn parser_extracts_full_response_set() {
1297 let responses = concat!(
1298 "\x1b_Gi=31;OK\x1b\\\x1b[?2;1;256S\x1b[6;20;10t",
1299 "\x1b]11;rgb:1/345/abcd\x07",
1300 "\x1b]99;i=omp-tui:p=?;p=title,body;\x1b\\",
1301 "\x1b[4;1$y\x1b[20;2$y",
1302 "\x1b[?1010;1$y\x1b[?1011;2$y\x1b[?2026;2$y\x1b[?2031;1$y\x1b[?2048;2$y",
1303 "\x1b[?5522;2$y",
1304 "\x1b[?5u\x1b[?1;2;4c",
1305 )
1306 .as_bytes()
1307 .to_vec();
1308 let (results, preserved) = parse([responses]);
1309 assert_eq!(results.kitty_graphics, Some(true));
1310 assert_eq!(results.sixel_color_registers, Some(256));
1311 assert_eq!(results.sixel_status, Some(1));
1312 assert_eq!(results.cell_px, Some((10, 20)));
1313 assert_eq!(results.da1_attributes, Some(vec![1, 2, 4]));
1314 assert_eq!(results.sync_output, Some(true));
1315 assert_eq!(results.kitty_keyboard, Some(5));
1316 assert_eq!(results.background, Some((0x1111, 0x3453, 0xabcd)));
1317 assert!(results.osc99_confirmed);
1318 assert!(results.insert_mode_set);
1319 assert!(!results.newline_mode_set);
1320 assert!(results.appearance_notifications);
1321 assert!(results.appearance_notifications_set);
1322 assert!(results.in_band_resize);
1323 assert!(!results.in_band_resize_set);
1324 assert!(results.paste_events);
1325 assert!(results.xterm_scroll_to_bottom_on_output);
1326 assert!(!results.xterm_scroll_to_bottom_on_key_press);
1327 assert!(results.supports_sixel());
1328 assert_eq!(preserved, [] as [u8; 0]);
1329 }
1330
1331 #[test]
1332 fn parser_accepts_every_response_split_one_byte_at_a_time() {
1333 let responses = concat!(
1334 "\x1b_Gi=31;OK\x1b\\\x1b[?2;1;1024S\x1b[6;18;9t",
1335 "\x1b]11;rgba:11/22/33\x1b\\\x1b[4;2$y\x1b[20;1$y",
1336 "\x1b[?2031;2$y\x1b[?2048;1$y\x1b[?5522;2$y\x1b[?1;4c",
1337 )
1338 .as_bytes();
1339 let (results, preserved) = parse(responses.iter().map(|byte| vec![*byte]));
1340 assert_eq!(results.kitty_graphics, Some(true));
1341 assert_eq!(results.sixel_color_registers, Some(1024));
1342 assert_eq!(results.cell_px, Some((9, 18)));
1343 assert_eq!(results.background, Some((0x1111, 0x2222, 0x3333)));
1344 assert!(!results.insert_mode_set);
1345 assert!(results.newline_mode_set);
1346 assert!(results.appearance_notifications);
1347 assert!(!results.appearance_notifications_set);
1348 assert!(results.in_band_resize);
1349 assert!(results.in_band_resize_set);
1350 assert!(results.paste_events);
1351 assert!(results.supports_sixel());
1352 assert_eq!(preserved, [] as [u8; 0]);
1353 }
1354
1355 #[test]
1356 fn parser_preserves_interleaved_keystrokes_in_order() {
1357 let bytes = b"x\x1b_Gi=31;OK\x1b\\\x1b[A\x1b[6;18;9tyz\x1b[?1c";
1358 let (results, preserved) = parse(bytes.iter().map(|byte| vec![*byte]));
1359 assert_eq!(preserved, b"x\x1b[Ayz");
1360 let mut decoder = InputDecoder::new();
1361 let mut events = Vec::new();
1362 decoder.feed(&preserved, std::time::Instant::now(), &mut events);
1363 assert_eq!(events, [
1364 InputEvent::Key(Key::Char('x')),
1365 InputEvent::Key(Key::Up),
1366 InputEvent::Key(Key::Char('y')),
1367 InputEvent::Key(Key::Char('z')),
1368 ]);
1369 assert!(results.da1_attributes.is_some());
1370 }
1371
1372 #[test]
1373 fn da1_attribute_four_enables_sixel() {
1374 let (results, _) = parse([b"\x1b[?62;4;22c".to_vec()]);
1375 assert!(results.supports_sixel());
1376 }
1377
1378 #[test]
1379 fn synchronized_output_probe_refines_defaults_without_demoting_on_no_reply() {
1380 let kitty = detect(&[("KITTY_WINDOW_ID", "1")], TerminalPlatform::Linux);
1381 assert!(kitty.sync_output);
1382 assert!(
1383 TerminalCaps::resolve(kitty, Some(&ProbeResults::default()), None).sync_output,
1384 "an absent DECRPM reply must preserve the positive environment default"
1385 );
1386 let unsupported = ProbeResults { sync_output: Some(false), ..ProbeResults::default() };
1387 assert!(!TerminalCaps::resolve(kitty, Some(&unsupported), None).sync_output);
1388
1389 let base = detect(&[], TerminalPlatform::Linux);
1390 assert!(!base.sync_output);
1391 for status in [1, 2] {
1392 let response = format!("\x1b[?2026;{status}$y").into_bytes();
1393 let (probe, preserved) = parse([response]);
1394 assert_eq!(probe.sync_output, Some(true));
1395 assert_eq!(preserved, [] as [u8; 0]);
1396 assert!(TerminalCaps::resolve(base, Some(&probe), None).sync_output);
1397 }
1398 for status in [0, 3, 4] {
1399 let response = format!("\x1b[?2026;{status}$y").into_bytes();
1400 let (probe, preserved) = parse([response]);
1401 assert_eq!(probe.sync_output, Some(false));
1402 assert_eq!(preserved, [] as [u8; 0]);
1403 }
1404 }
1405
1406 #[test]
1407 fn ansi_mode_probes_only_record_set_changeable_modes() {
1408 for mode in [4, 20] {
1409 for status in 0..=4 {
1410 let response = format!("\x1b[{mode};{status}$y").into_bytes();
1411 let (probe, preserved) = parse([response]);
1412 assert_eq!(preserved, [] as [u8; 0]);
1413 assert_eq!(
1414 probe.insert_mode_set,
1415 mode == 4 && status == 1,
1416 "mode {mode}, status {status}"
1417 );
1418 assert_eq!(
1419 probe.newline_mode_set,
1420 mode == 20 && status == 1,
1421 "mode {mode}, status {status}"
1422 );
1423 }
1424 }
1425 }
1426
1427 #[test]
1428 fn notification_mode_probes_preserve_support_and_prior_state() {
1429 for mode in [2031, 2048] {
1430 for status in 0..=4 {
1431 let response = format!("\x1b[?{mode};{status}$y").into_bytes();
1432 let (probe, preserved) = parse([response]);
1433 let supported = matches!(status, 1..=3);
1434 let set = matches!(status, 1 | 3);
1435 assert_eq!(preserved, [] as [u8; 0]);
1436 assert_eq!(
1437 probe.appearance_notifications,
1438 mode == 2031 && supported,
1439 "mode {mode}, status {status}"
1440 );
1441 assert_eq!(
1442 probe.appearance_notifications_set,
1443 mode == 2031 && set,
1444 "mode {mode}, status {status}"
1445 );
1446 assert_eq!(
1447 probe.in_band_resize,
1448 mode == 2048 && supported,
1449 "mode {mode}, status {status}"
1450 );
1451 assert_eq!(
1452 probe.in_band_resize_set,
1453 mode == 2048 && set,
1454 "mode {mode}, status {status}"
1455 );
1456 }
1457 }
1458 }
1459
1460 #[test]
1461 fn xterm_scroll_to_bottom_probes_only_record_set_changeable_modes() {
1462 for mode in [1010, 1011] {
1463 for status in [1, 2, 3, 4] {
1464 let response = format!("\x1b[?{mode};{status}$y").into_bytes();
1465 let (probe, preserved) = parse([response]);
1466 assert_eq!(preserved, [] as [u8; 0]);
1467 assert_eq!(
1468 probe.xterm_scroll_to_bottom_on_output,
1469 mode == 1010 && status == 1,
1470 "mode {mode}, status {status}"
1471 );
1472 assert_eq!(
1473 probe.xterm_scroll_to_bottom_on_key_press,
1474 mode == 1011 && status == 1,
1475 "mode {mode}, status {status}"
1476 );
1477 }
1478 }
1479 }
1480
1481 #[test]
1482 fn kitty_keyboard_flags_parse_across_chunk_boundaries() {
1483 let (probe, preserved) = parse(b"\x1b[?13u".iter().map(|byte| vec![*byte]));
1484 assert_eq!(probe.kitty_keyboard, Some(13));
1485 assert_eq!(preserved, [] as [u8; 0]);
1486 let base = detect(&[], TerminalPlatform::Linux);
1487 assert_eq!(TerminalCaps::resolve(base, Some(&probe), None).kitty_keyboard, Some(13));
1488 }
1489
1490 #[test]
1491 fn synchronized_output_environment_override_precedes_probe() {
1492 let positive_probe = ProbeResults { sync_output: Some(true), ..ProbeResults::default() };
1493 let negative_probe = ProbeResults { sync_output: Some(false), ..ProbeResults::default() };
1494
1495 for variables in [
1496 &[("KITTY_WINDOW_ID", "1"), ("OMP_NO_SYNC_OUTPUT", "1")][..],
1497 &[("KITTY_WINDOW_ID", "1"), ("OMP_SYNC_OUTPUT", "0")][..],
1498 &[
1499 ("KITTY_WINDOW_ID", "1"),
1500 ("OMP_NO_SYNC_OUTPUT", "anything"),
1501 ("OMP_FORCE_SYNC_OUTPUT", "1"),
1502 ][..],
1503 ] {
1504 let caps = detect(variables, TerminalPlatform::Linux);
1505 assert!(!caps.sync_output);
1506 assert!(!TerminalCaps::resolve(caps, Some(&positive_probe), None).sync_output);
1507 }
1508 for variables in [&[("OMP_FORCE_SYNC_OUTPUT", "1")][..], &[("OMP_SYNC_OUTPUT", "1")][..]] {
1509 let caps = detect(variables, TerminalPlatform::Linux);
1510 assert!(caps.sync_output);
1511 assert!(TerminalCaps::resolve(caps, Some(&negative_probe), None).sync_output);
1512 }
1513 }
1514
1515 #[test]
1516 fn hyperlink_policy_matches_overrides_and_multiplexer_gates() {
1517 assert!(detect(&[("GHOSTTY_RESOURCES_DIR", "1")], TerminalPlatform::Linux).hyperlinks);
1518 assert!(
1519 !detect(
1520 &[
1521 ("GHOSTTY_RESOURCES_DIR", "1"),
1522 ("OMP_NO_HYPERLINKS", "1"),
1523 ("OMP_FORCE_HYPERLINKS", "1"),
1524 ],
1525 TerminalPlatform::Linux,
1526 )
1527 .hyperlinks
1528 );
1529 assert!(
1530 detect(&[("OMP_FORCE_HYPERLINKS", "1")], TerminalPlatform::Linux).hyperlinks,
1531 "force-on may upgrade the conservative base table"
1532 );
1533 assert!(
1534 !detect(&[("GHOSTTY_RESOURCES_DIR", "1"), ("STY", "screen")], TerminalPlatform::Linux,)
1535 .hyperlinks
1536 );
1537 for (version, expected) in [("3.3a", false), ("3.4", true), ("4.0", true)] {
1538 assert_eq!(
1539 detect(
1540 &[
1541 ("GHOSTTY_RESOURCES_DIR", "1"),
1542 ("TMUX", "/tmp/tmux"),
1543 ("TERM", "screen-256color"),
1544 ("TERM_PROGRAM", "tmux"),
1545 ("TERM_PROGRAM_VERSION", version),
1546 ],
1547 TerminalPlatform::Linux,
1548 )
1549 .hyperlinks,
1550 expected
1551 );
1552 }
1553 assert!(
1554 !detect(
1555 &[("GHOSTTY_RESOURCES_DIR", "1"), ("TERM", "tmux-256color")],
1556 TerminalPlatform::Linux,
1557 )
1558 .hyperlinks
1559 );
1560 }
1561
1562 #[test]
1563 fn terminal_capability_matrix_matches_pi() {
1564 let cases = [
1565 (
1566 &[("KITTY_WINDOW_ID", "1")][..],
1567 (true, None, true, true, true, true, true, NotifyProtocol::Osc99, 0),
1568 ),
1569 (
1570 &[("GHOSTTY_RESOURCES_DIR", "1")][..],
1571 (true, None, false, true, true, false, false, NotifyProtocol::Osc9, 2),
1572 ),
1573 (
1574 &[("WEZTERM_PANE", "1")][..],
1575 (true, None, false, true, true, false, false, NotifyProtocol::Osc9, 0),
1576 ),
1577 (
1578 &[("ITERM_SESSION_ID", "1")][..],
1579 (true, None, false, true, true, false, false, NotifyProtocol::Osc9, 0),
1580 ),
1581 (
1582 &[("ALACRITTY_WINDOW_ID", "1")][..],
1583 (true, None, false, true, true, false, false, NotifyProtocol::Bell, 0),
1584 ),
1585 (
1586 &[("VSCODE_PID", "1")][..],
1587 (true, None, false, true, true, false, false, NotifyProtocol::Bell, 0),
1588 ),
1589 (
1590 &[("TERM_PROGRAM", "WarpTerminal")][..],
1591 (false, None, false, false, false, false, false, NotifyProtocol::Osc9, 1),
1592 ),
1593 (&[][..], (false, None, false, false, false, false, false, NotifyProtocol::Bell, 0)),
1594 ];
1595 for (variables, expected) in cases {
1596 let caps = detect(variables, TerminalPlatform::MacOs);
1597 assert_eq!(
1598 (
1599 caps.sync_output,
1600 caps.kitty_keyboard,
1601 caps.screen_to_scrollback,
1602 caps.margin_scrollback,
1603 caps.hyperlinks,
1604 caps.text_sizing,
1605 caps.deccara,
1606 caps.notify,
1607 caps.jamo_width,
1608 ),
1609 expected,
1610 "capabilities for {:?}",
1611 caps.id
1612 );
1613 assert!(!caps.osc99_confirmed);
1614 assert_eq!(caps.background, None);
1615 assert!(!caps.appearance_notifications);
1616 assert!(!caps.in_band_resize);
1617 }
1618 assert_eq!(
1619 detect(&[("TERM_PROGRAM", "WarpTerminal")], TerminalPlatform::MacOs).graphics,
1620 Graphics::KittyDirect
1621 );
1622 assert_eq!(
1623 detect(&[("TERM_PROGRAM", "WarpTerminal")], TerminalPlatform::Windows).graphics,
1624 Graphics::Cells
1625 );
1626 assert_eq!(
1627 detect(&[("ITERM_SESSION_ID", "1")], TerminalPlatform::MacOs).graphics,
1628 Graphics::Iterm2
1629 );
1630 }
1631
1632 #[test]
1633 fn margin_scrollback_prefers_tmux_and_gates_other_multiplexers() {
1634 assert!(detect(&[("TMUX", "/tmp/sock,1,0")], TerminalPlatform::MacOs).margin_scrollback);
1635 assert!(
1636 detect(
1637 &[("TMUX", "/tmp/sock,1,0"), ("TERM_PROGRAM", "WarpTerminal")],
1638 TerminalPlatform::MacOs
1639 )
1640 .margin_scrollback
1641 );
1642 assert!(
1643 !detect(&[("ZELLIJ", "1"), ("GHOSTTY_RESOURCES_DIR", "1")], TerminalPlatform::MacOs)
1644 .margin_scrollback
1645 );
1646 }
1647
1648 #[test]
1649 fn additional_probe_results_refine_terminal_caps() {
1650 let probe = ProbeResults {
1651 background: Some((0x1111, 0x2222, 0x3333)),
1652 osc99_confirmed: true,
1653 appearance_notifications: true,
1654 in_band_resize: true,
1655 paste_events: true,
1656 xterm_scroll_to_bottom_on_output: true,
1657 xterm_scroll_to_bottom_on_key_press: true,
1658 ..ProbeResults::default()
1659 };
1660 let caps = TerminalCaps::resolve(
1661 detect(&[("KITTY_WINDOW_ID", "1")], TerminalPlatform::Linux),
1662 Some(&probe),
1663 None,
1664 );
1665 assert_eq!(caps.background, probe.background);
1666 assert!(caps.osc99_confirmed);
1667 assert!(caps.appearance_notifications);
1668 assert!(caps.in_band_resize);
1669 assert!(caps.paste_events);
1670 assert!(caps.xterm_scroll_to_bottom_on_output);
1671 assert!(caps.xterm_scroll_to_bottom_on_key_press);
1672 }
1673
1674 #[test]
1675 fn osc99_probe_is_omitted_inside_multiplexers() {
1676 let direct = super::materialize_probe_batch(false, true);
1677 assert!(
1678 direct
1679 .windows(b"]99;".len())
1680 .any(|window| window == b"]99;")
1681 );
1682 assert!(
1683 direct
1684 .windows(b"\x1b[?5522$p".len())
1685 .any(|window| window == b"\x1b[?5522$p")
1686 );
1687 for batch in
1688 [super::materialize_probe_batch(true, false), super::materialize_probe_batch(false, false)]
1689 {
1690 assert!(!batch.windows(b"]99;".len()).any(|window| window == b"]99;"));
1691 }
1692 }
1693
1694 #[test]
1695 fn runtime_precedence_is_forced_then_probe_then_environment() {
1696 let env = detect(&[("KITTY_WINDOW_ID", "1")], TerminalPlatform::Linux);
1697 assert_eq!(env.graphics, Graphics::KittyPlaceholders);
1698 let no_sixel = ProbeResults { da1_attributes: Some(vec![1, 2]), ..ProbeResults::default() };
1699 assert_eq!(TerminalCaps::resolve(env, Some(&no_sixel), None).graphics, Graphics::Cells);
1700 assert_eq!(
1701 TerminalCaps::resolve(env, Some(&no_sixel), Some(Graphics::Sixel)).graphics,
1702 Graphics::Sixel
1703 );
1704 }
1705
1706 #[test]
1707 fn inconclusive_probe_keeps_fallback_but_conclusive_sixel_results_override() {
1708 let ghostty = detect(&[("GHOSTTY_RESOURCES_DIR", "1")], TerminalPlatform::Linux);
1709 let cell_only = ProbeResults { cell_px: Some((9, 18)), ..ProbeResults::default() };
1710 let resolved = TerminalCaps::resolve(ghostty, Some(&cell_only), None);
1711 assert_eq!(resolved.graphics, Graphics::KittyPlaceholders);
1712 assert_eq!(resolved.cell_px, Some((9, 18)));
1713
1714 let windows_terminal = detect(
1715 &[
1716 ("WT_SESSION", "id"),
1717 ("TERM_PROGRAM", "Windows_Terminal"),
1718 ("TERM_PROGRAM_VERSION", "1.22.0"),
1719 ],
1720 TerminalPlatform::Windows,
1721 );
1722 assert_eq!(windows_terminal.graphics, Graphics::Sixel);
1723 let da1_negative =
1724 ProbeResults { da1_attributes: Some(vec![1, 2]), ..ProbeResults::default() };
1725 assert_eq!(
1726 TerminalCaps::resolve(windows_terminal, Some(&da1_negative), None).graphics,
1727 Graphics::Cells
1728 );
1729
1730 let xtsm_positive = ProbeResults {
1731 sixel_status: Some(0),
1732 sixel_color_registers: Some(256),
1733 ..ProbeResults::default()
1734 };
1735 let base = detect(&[], TerminalPlatform::Linux);
1736 assert_eq!(TerminalCaps::resolve(base, Some(&xtsm_positive), None).graphics, Graphics::Sixel);
1737 }
1738
1739 #[test]
1740 fn tmux_carries_probed_kitty_but_zellij_degrades_it() {
1741 let kitty = ProbeResults { kitty_graphics: Some(true), ..ProbeResults::default() };
1742 let tmux =
1743 detect(&[("TMUX", "/tmp/tmux"), ("TERM", "tmux-256color")], TerminalPlatform::Linux);
1744 let resolved = TerminalCaps::resolve(tmux, Some(&kitty), None);
1745 assert!(resolved.inside_tmux);
1746 assert_eq!(resolved.graphics, Graphics::KittyDirect);
1747
1748 let zellij = detect(&[("ZELLIJ", "1"), ("WEZTERM_PANE", "1")], TerminalPlatform::Linux);
1749 assert_eq!(TerminalCaps::resolve(zellij, Some(&kitty), None).graphics, Graphics::Cells);
1750 assert_eq!(
1751 TerminalCaps::resolve(zellij, Some(&kitty), Some(Graphics::KittyDirect)).graphics,
1752 Graphics::KittyDirect
1753 );
1754 let env_forced =
1755 detect(&[("ZELLIJ", "1"), ("OMP_FORCE_IMAGE_PROTOCOL", "kitty")], TerminalPlatform::Linux);
1756 assert_eq!(TerminalCaps::resolve(env_forced, None, None).graphics, Graphics::KittyDirect);
1757 }
1758
1759 #[test]
1760 fn zero_response_timeout_keeps_environment_fallback_and_emits_one_batch() {
1761 #[derive(Default)]
1762 struct EmptyTty(Vec<u8>);
1763 impl Read for EmptyTty {
1764 fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
1765 Err(io::ErrorKind::WouldBlock.into())
1766 }
1767 }
1768 impl Write for EmptyTty {
1769 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1770 self.0.extend_from_slice(bytes);
1771 Ok(bytes.len())
1772 }
1773
1774 fn flush(&mut self) -> io::Result<()> {
1775 Ok(())
1776 }
1777 }
1778
1779 let mut tty = EmptyTty::default();
1780 let probe = probe_terminal(&mut tty, Duration::from_millis(2));
1781 assert!(probe.timed_out);
1782 assert_eq!(probe.preserved_input, [] as [u8; 0]);
1783 let detected = super::detect();
1784 assert_eq!(
1785 tty.0,
1786 super::materialize_probe_batch(
1787 detected.inside_tmux,
1788 detected.notify == NotifyProtocol::Osc99 && !detected.inside_multiplexer,
1789 )
1790 .as_ref()
1791 );
1792 let env = detect(&[("GHOSTTY_RESOURCES_DIR", "1")], TerminalPlatform::Linux);
1793 assert_eq!(TerminalCaps::resolve(env, Some(&probe), None).graphics, env.graphics);
1794 }
1795
1796 #[test]
1797 fn direct_and_placeholder_environment_table_matches_pi() {
1798 for marker in ["GHOSTTY_RESOURCES_DIR", "KITTY_WINDOW_ID"] {
1799 assert_eq!(
1800 detect(&[(marker, "1")], TerminalPlatform::Linux).graphics,
1801 Graphics::KittyPlaceholders
1802 );
1803 }
1804 assert_eq!(
1805 detect(&[("WEZTERM_PANE", "1")], TerminalPlatform::Linux).graphics,
1806 Graphics::KittyDirect
1807 );
1808 assert_eq!(
1809 detect(&[("ITERM_SESSION_ID", "1")], TerminalPlatform::Linux).graphics,
1810 Graphics::Iterm2
1811 );
1812 assert_eq!(
1813 detect(&[("TERM_PROGRAM", "WarpTerminal")], TerminalPlatform::MacOs).graphics,
1814 Graphics::KittyDirect
1815 );
1816 }
1817
1818 #[test]
1819 fn tmux_placeholder_matrix_matches_pi() {
1820 let ghostty_tmux: &[(&str, &str)] =
1823 &[("GHOSTTY_RESOURCES_DIR", "1"), ("TMUX", "/tmp/tmux"), ("TERM", "tmux-256color")];
1824 let caps = detect(ghostty_tmux, TerminalPlatform::Linux);
1825 assert!(caps.inside_tmux);
1826 assert_eq!(caps.graphics, Graphics::KittyPlaceholders);
1827
1828 let forced_unknown: &[(&str, &str)] =
1831 &[("TMUX", "/tmp/tmux"), ("TERM", "tmux-256color"), ("OMP_FORCE_IMAGE_PROTOCOL", "kitty")];
1832 let caps = detect(forced_unknown, TerminalPlatform::Linux);
1833 assert_eq!(caps.graphics, Graphics::KittyPlaceholders);
1834
1835 let opted_out: &[(&str, &str)] = &[
1837 ("GHOSTTY_RESOURCES_DIR", "1"),
1838 ("TMUX", "/tmp/tmux"),
1839 ("TERM", "tmux-256color"),
1840 ("OMP_NO_KITTY_PLACEHOLDERS", "1"),
1841 ];
1842 let caps = detect(opted_out, TerminalPlatform::Linux);
1843 assert_ne!(caps.graphics, Graphics::KittyPlaceholders);
1844 }
1845}