Skip to main content

omp_tui/
graphics.rs

1//! Terminal identity, graphics capability detection, and runtime probing.
2//!
3//! Environment detection supplies a conservative fallback. [`probe_terminal`]
4//! can refine it from replies emitted by the terminal itself without losing
5//! application input that arrives during negotiation.
6
7#[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";
25/// Explicit glyph-tier override: `ascii`, `unicode`, or `nerd`.
26const FORCE_CHARSET: &str = "OMP_TUI_CHARSET";
27
28/// Terminal emulator identity inferred from environment markers.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum TerminalId {
31	/// No recognized emulator and no true-color advertisement.
32	Base,
33	/// An unknown emulator advertising 24-bit color.
34	TrueColor,
35	/// Kitty.
36	Kitty,
37	/// Ghostty.
38	Ghostty,
39	/// `WezTerm`.
40	Wezterm,
41	/// iTerm2.
42	Iterm2,
43	/// Visual Studio Code's integrated terminal.
44	Vscode,
45	/// Alacritty.
46	Alacritty,
47	/// Warp.
48	Warp,
49}
50
51impl TerminalId {
52	/// Stable identifier used by pi-tui's terminal capability table.
53	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/// Host platform distinctions that affect terminal graphics support.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum TerminalPlatform {
77	/// Microsoft Windows.
78	Windows,
79	/// Linux, including WSL when its environment markers are absent.
80	Linux,
81	/// macOS.
82	MacOs,
83	/// Any other host platform.
84	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/// Terminal notification protocol selected from emulator identity.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum NotifyProtocol {
103	/// Audible or visual terminal bell.
104	Bell,
105	/// OSC 9 desktop notification.
106	Osc9,
107	/// Kitty OSC 99 desktop notification.
108	Osc99,
109}
110
111/// Graphics-related terminal capabilities resolved for the current process.
112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub struct TerminalCaps {
114	/// Detected terminal emulator.
115	pub id: TerminalId,
116	/// Glyph capability tier inferred from the emulator (`OMP_TUI_CHARSET`
117	/// overrides).
118	pub charset: crate::Charset,
119	/// Safe image rendering mode.
120	pub graphics: Graphics,
121	/// Whether Kitty Unicode placeholders are trusted for this terminal.
122	pub kitty_placeholders: bool,
123	/// Pixel width and height of one terminal cell, when reported.
124	pub cell_px: Option<(u16, u16)>,
125	/// Number of SIXEL color registers reported by XTSMGRAPHICS.
126	pub sixel_color_registers: Option<u16>,
127	/// Whether DEC synchronized output mode 2026 is supported.
128	pub sync_output: bool,
129	/// Kitty keyboard protocol flags reported by the terminal.
130	pub kitty_keyboard: Option<u8>,
131	/// Whether cleared viewport content can be moved to native scrollback.
132	pub screen_to_scrollback: bool,
133	/// Whether rows scrolled out of a top-anchored DECSTBM region enter
134	/// native scrollback.
135	pub margin_scrollback: bool,
136	/// Whether OSC 8 hyperlinks are supported.
137	pub hyperlinks: bool,
138	/// Whether Kitty OSC 66 text sizing is supported.
139	pub text_sizing: bool,
140	/// Whether Kitty-style DECCARA rectangular SGR is supported.
141	pub deccara: bool,
142	/// Notification protocol selected for this terminal.
143	pub notify: NotifyProtocol,
144	/// Whether a Kitty OSC 99 capability reply confirmed rich notifications.
145	pub osc99_confirmed: bool,
146	/// Terminal background color normalized to 16-bit RGB components.
147	pub background: Option<(u16, u16, u16)>,
148	/// Whether DEC mode 2031 appearance notifications are supported.
149	pub appearance_notifications: bool,
150	/// Whether DEC mode 2048 in-band resize notifications are supported.
151	pub in_band_resize: bool,
152	/// Whether DEC mode 5522 enhanced paste offers are supported.
153	pub paste_events: bool,
154	/// Whether xterm mode 1010 was set and may be temporarily disabled.
155	pub xterm_scroll_to_bottom_on_output: bool,
156	/// Whether xterm mode 1011 was set and may be temporarily disabled.
157	pub xterm_scroll_to_bottom_on_key_press: bool,
158	/// Hangul Compatibility Jamo width, or zero for the platform default.
159	pub jamo_width: u8,
160	/// Whether the process is inside tmux.
161	pub inside_tmux: bool,
162	/// Whether a tmux, screen, Zellij, Herdr, or cmux marker was detected.
163	pub inside_multiplexer: bool,
164	sync_output_override: Option<bool>,
165}
166
167impl TerminalCaps {
168	/// Resolves an explicit override, runtime replies, and environment fallback.
169	///
170	/// Explicit overrides win unchanged. A probe that received at least one
171	/// recognizable reply supersedes environment protocol guesses. tmux can
172	/// carry Kitty APC and SIXEL DCS through passthrough; other multiplexers
173	/// conservatively disable non-forced graphics.
174	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/// Replies collected by a runtime graphics capability probe.
231#[derive(Clone, Debug, Default, Eq, PartialEq)]
232pub struct ProbeResults {
233	/// Whether a Kitty graphics reply was received and reported success.
234	pub kitty_graphics: Option<bool>,
235	/// Pixel width and height of one terminal cell.
236	pub cell_px: Option<(u16, u16)>,
237	/// Number of SIXEL color registers reported by XTSMGRAPHICS.
238	pub sixel_color_registers: Option<u16>,
239	/// XTSMGRAPHICS status for the color-register query (`0` means success).
240	pub sixel_status: Option<u16>,
241	/// Primary Device Attributes reported by DA1.
242	pub da1_attributes: Option<Vec<u16>>,
243	/// Whether DECRQM reported synchronized output mode 2026 as supported.
244	pub sync_output: Option<bool>,
245	/// Kitty keyboard protocol flags reported by the terminal.
246	pub kitty_keyboard: Option<u8>,
247	/// Terminal background color normalized to 16-bit RGB components.
248	pub background: Option<(u16, u16, u16)>,
249	/// Whether Kitty OSC 99 capability probing confirmed rich notifications.
250	pub osc99_confirmed: bool,
251	/// Whether DEC mode 2031 was already set before the session.
252	pub appearance_notifications_set: bool,
253	/// Whether DECRQM reported DEC mode 2031 as supported.
254	pub appearance_notifications: bool,
255	/// Whether DEC mode 2048 was already set before the session.
256	pub in_band_resize_set: bool,
257	/// Whether DECRQM reported DEC mode 2048 as supported.
258	pub in_band_resize: bool,
259	/// Whether DECRQM reported DEC mode 5522 (kitty enhanced paste) as settable.
260	pub paste_events: bool,
261	/// Whether DECRQM reported ANSI insert mode 4 as set and changeable.
262	pub insert_mode_set: bool,
263	/// Whether DECRQM reported ANSI new-line mode 20 as set and changeable.
264	pub newline_mode_set: bool,
265	/// Whether DECRQM reported xterm mode 1010 as set and changeable.
266	///
267	/// Status 3 is permanently set, so it is deliberately not changed.
268	pub xterm_scroll_to_bottom_on_output: bool,
269	/// Whether DECRQM reported xterm mode 1011 as set and changeable.
270	///
271	/// Status 3 is permanently set, so it is deliberately not changed.
272	pub xterm_scroll_to_bottom_on_key_press: bool,
273	/// Non-probe bytes read while negotiation was active, in original order.
274	pub preserved_input: Vec<u8>,
275	/// Whether negotiation ended at the deadline rather than at the DA1 fence.
276	pub timed_out: bool,
277}
278
279impl ProbeResults {
280	/// Whether DA1 or XTSMGRAPHICS advertised SIXEL support.
281	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/// Incremental demultiplexer for startup probe replies and application input.
294#[derive(Debug, Default)]
295pub struct ProbeParser {
296	pending:  Vec<u8>,
297	results:  ProbeResults,
298	complete: bool,
299}
300
301impl ProbeParser {
302	/// Creates an empty parser.
303	pub fn new() -> Self {
304		Self::default()
305	}
306
307	/// Feeds raw terminal bytes into the parser.
308	///
309	/// Complete probe replies are consumed. All other complete byte sequences
310	/// are appended to `preserved` in their original order.
311	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	/// Returns whether the DA1 fence has been consumed.
393	pub const fn is_complete(&self) -> bool {
394		self.complete
395	}
396
397	/// Returns probe results accumulated so far.
398	pub const fn results(&self) -> &ProbeResults {
399		&self.results
400	}
401
402	/// Flushes an incomplete or unrecognized suffix as application input.
403	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
658/// Performs a blocking startup capability probe.
659///
660/// The terminal must already be in raw mode. To make the deadline effective,
661/// `tty` must return [`io::ErrorKind::WouldBlock`] or
662/// [`io::ErrorKind::TimedOut`] while no input is available. Returned
663/// [`ProbeResults::preserved_input`] must be delivered to the application's
664/// normal input pipeline before newly read input. Terminal I/O errors end the
665/// probe conservatively and leave environment fallback resolution available.
666pub 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}
696/// Negotiates terminal graphics capabilities while preserving every
697/// non-probe byte received during the probe window.
698///
699/// On Unix this opens `/dev/tty`, temporarily applies raw non-blocking mode,
700/// and restores both termios and descriptor flags before returning. If the
701/// controlling terminal is unavailable, environment capabilities and an empty
702/// probe result are returned.
703pub 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}
719/// [`negotiate`] on the blocking pool, for tokio hosts.
720///
721/// # Panics
722/// Panics outside a tokio runtime.
723pub 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
806/// Detects terminal graphics capabilities from the process environment.
807pub 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
815/// Detects terminal graphics capabilities from an injectable environment.
816///
817/// This testable core models an interactive standard output; [`detect`] uses
818/// the real stdout TTY state for pi-tui's fallback-protocol gate. Empty strings
819/// have the same meaning as absent variables, matching JavaScript truthiness.
820pub 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
877/// Infers the glyph tier: an explicit `OMP_TUI_CHARSET` wins, a dumb or
878/// absent `TERM` degrades to ASCII, emulators that bundle a Nerd Font
879/// symbol fallback (glyphs render regardless of the user's configured
880/// font) get the Nerd tier, and everything else keeps plain Unicode.
881fn 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
1019/// Whether Kitty Unicode placeholders are trusted for this environment.
1020///
1021/// Kitty and Ghostty render `U=1` placements, tmux included — inside tmux
1022/// placeholders are in fact the only mode that works, because the outer
1023/// terminal cannot track pane scroll state for cursor-positioned
1024/// placements. A forced kitty protocol opts unknown terminals in there
1025/// (matching `timg -pk`); `OMP_KITTY_PLACEHOLDERS=1` opts in anywhere and
1026/// `OMP_NO_KITTY_PLACEHOLDERS=1` is a hard opt-out. Mirrors pi's
1027/// `detectKittyUnicodePlaceholdersSupport`.
1028fn 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
1106/// Terminals whose source verifiably moves rows scrolled out of a
1107/// top-anchored DECSTBM region into native scrollback: kitty (`INDEX_UP`,
1108/// `margin_top == 0`), ghostty (`Terminal.index`, `top == 0`), `WezTerm`
1109/// (`scroll_region.start == 0`), iTerm2 (`scrollTop == 0` with the default
1110/// scrollback-with-region profile), Alacritty (`region.start == 0`), and
1111/// xterm.js/VS Code (`scrollTop === 0`).
1112const 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		// Emulators bundling Nerd Font symbol fallbacks get the nerd tier.
1273		let ghostty = detect(
1274			&[("TERM", "xterm-ghostty"), ("GHOSTTY_RESOURCES_DIR", "/r")],
1275			TerminalPlatform::Other,
1276		);
1277		assert_eq!(ghostty.charset, Charset::NerdFont);
1278		// Unknown emulators stay at plain Unicode.
1279		let base = detect(&[("TERM", "xterm-256color")], TerminalPlatform::Other);
1280		assert_eq!(base.charset, Charset::Unicode);
1281		// A dumb or absent TERM degrades to ASCII.
1282		assert_eq!(detect(&[], TerminalPlatform::Other).charset, Charset::Ascii);
1283		assert_eq!(detect(&[("TERM", "dumb")], TerminalPlatform::Other).charset, Charset::Ascii);
1284		// The explicit override beats every heuristic.
1285		let forced = detect(
1286			&[("TERM", "xterm-ghostty"), ("OMP_TUI_CHARSET", "ascii")],
1287			TerminalPlatform::Other,
1288		);
1289		assert_eq!(forced.charset, Charset::Ascii);
1290		// Context threading: apply_terminal_caps adopts the tier.
1291		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		// Kitty/Ghostty render placeholders through tmux passthrough; direct
1821		// cursor-positioned placements are what tmux cannot carry.
1822		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		// Unknown terminal in tmux: a forced kitty protocol opts into
1829		// placeholders (pi's `PI_FORCE_IMAGE_PROTOCOL=kitty` / `timg -pk`).
1830		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		// The hard opt-out still wins for known-broken builds.
1836		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}