Skip to main content

omp_tui/
terminal.rs

1//! Process-wide terminal lifecycle, raw-mode ownership, and emergency restore.
2
3use std::{
4	fs::File,
5	io::{self, Write as _},
6	panic,
7	sync::{
8		Arc, LazyLock, Once,
9		atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU64, Ordering},
10	},
11	thread,
12	time::{Duration, Instant},
13};
14
15use omp_core::{Str, base64, fmts};
16use smallvec::SmallVec;
17#[cfg(windows)]
18use windows_sys::Win32::System::Console::{GetConsoleOutputCP, SetConsoleOutputCP};
19
20const STDERR_CAPTURE_CAPACITY: usize = 64 * 1024;
21
22#[derive(Default)]
23struct CapturedStderr {
24	bytes: Vec<u8>,
25}
26
27impl CapturedStderr {
28	fn new() -> Self {
29		Self { bytes: Vec::with_capacity(STDERR_CAPTURE_CAPACITY) }
30	}
31
32	fn push(&mut self, bytes: &[u8]) {
33		if bytes.len() >= STDERR_CAPTURE_CAPACITY {
34			self.bytes.clear();
35			self
36				.bytes
37				.extend_from_slice(&bytes[bytes.len() - STDERR_CAPTURE_CAPACITY..]);
38			return;
39		}
40		let overflow = self
41			.bytes
42			.len()
43			.saturating_add(bytes.len())
44			.saturating_sub(STDERR_CAPTURE_CAPACITY);
45		if overflow != 0 {
46			self.bytes.copy_within(overflow.., 0);
47			self.bytes.truncate(self.bytes.len() - overflow);
48		}
49		self.bytes.extend_from_slice(bytes);
50	}
51
52	fn as_slice(&self) -> &[u8] {
53		&self.bytes
54	}
55}
56
57#[cfg(unix)]
58mod platform {
59	use std::{
60		cell::UnsafeCell,
61		fs::{File, OpenOptions},
62		io,
63		mem::MaybeUninit,
64		os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd},
65		sync::atomic::{AtomicBool, AtomicI32, Ordering},
66		time::{Duration, Instant},
67	};
68
69	use nix::{
70		libc,
71		sys::{
72			signal::{self, SaFlags, SigAction, SigHandler, SigSet, Signal},
73			termios::{SetArg, Termios, cfmakeraw, tcgetattr, tcsetattr},
74		},
75	};
76
77	use super::{CapturedStderr, RESIZE_GENERATION, emergency_restore_inner};
78	use crate::Size;
79
80	static TTY_FD: AtomicI32 = AtomicI32::new(-1);
81	static RAW_VALID: AtomicBool = AtomicBool::new(false);
82	static SAVED_STDERR_FD: AtomicI32 = AtomicI32::new(-1);
83
84	static RESIZE_PIPE_FDS: (AtomicI32, AtomicI32) = (AtomicI32::new(-1), AtomicI32::new(-1));
85	struct SavedTermios(UnsafeCell<MaybeUninit<libc::termios>>);
86
87	// Only the active terminal writes this slot before publishing RAW_VALID.
88	// Signal and panic handlers read it only after an acquire operation.
89	// SAFETY: synchronization through RAW_VALID prevents concurrent access.
90	unsafe impl Sync for SavedTermios {}
91
92	static SIGNAL_TERMIOS: SavedTermios = SavedTermios(UnsafeCell::new(MaybeUninit::uninit()));
93
94	pub(super) struct State {
95		original: Option<Termios>,
96	}
97	pub(super) struct StderrGuard {
98		reader:   Option<File>,
99		captured: CapturedStderr,
100		active:   bool,
101	}
102
103	impl StderrGuard {
104		pub(super) fn new(capture: bool) -> io::Result<Self> {
105			if !capture {
106				return Ok(Self {
107					reader:   None,
108					captured: CapturedStderr::default(),
109					active:   false,
110				});
111			}
112
113			let mut descriptors = [-1; 2];
114			// SAFETY: `descriptors` is a writable two-element buffer for `pipe`.
115			if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
116				return Err(io::Error::last_os_error());
117			}
118			let (reader, writer) = (descriptors[0], descriptors[1]);
119			if let Err(error) = configure_pipe(reader, writer) {
120				// SAFETY: both descriptors were returned by `pipe` and remain owned here.
121				unsafe {
122					libc::close(reader);
123					libc::close(writer);
124				}
125				return Err(error);
126			}
127			// SAFETY: duplicating the process stderr descriptor requires no Rust aliasing.
128			let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
129			if saved < 0 {
130				let error = io::Error::last_os_error();
131				// SAFETY: both descriptors were returned by `pipe` and remain owned here.
132				unsafe {
133					libc::close(reader);
134					libc::close(writer);
135				}
136				return Err(error);
137			}
138			// SAFETY: `saved` is a valid descriptor returned by `dup`.
139			let entry_backup = unsafe { libc::dup(saved) };
140			if entry_backup < 0 {
141				let error = io::Error::last_os_error();
142				// SAFETY: all descriptors were acquired by this function and remain owned here.
143				unsafe {
144					libc::close(reader);
145					libc::close(writer);
146					libc::close(saved);
147				}
148				return Err(error);
149			}
150			for descriptor in [saved, entry_backup] {
151				// SAFETY: `descriptor` is a valid descriptor acquired above.
152				if unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
153					let error = io::Error::last_os_error();
154					// SAFETY: all descriptors were acquired by this function and remain owned here.
155					unsafe {
156						libc::close(reader);
157						libc::close(writer);
158						libc::close(saved);
159						libc::close(entry_backup);
160					}
161					return Err(error);
162				}
163			}
164
165			// Publish the pre-resolved descriptor before redirecting fd 2 so a
166			// fatal signal can restore it with only dup2/close.
167			SAVED_STDERR_FD.store(saved, Ordering::Release);
168			// SAFETY: `writer` is a valid pipe descriptor and stderr is process-owned.
169			if unsafe { libc::dup2(writer, libc::STDERR_FILENO) } < 0 {
170				let error = io::Error::last_os_error();
171				if SAVED_STDERR_FD
172					.compare_exchange(saved, -1, Ordering::AcqRel, Ordering::Acquire)
173					.is_ok()
174				{
175					// SAFETY: `saved` is owned by this function until the atomic exchange succeeds.
176					unsafe {
177						libc::close(saved);
178					}
179				}
180				// SAFETY: all descriptors were acquired by this function and remain owned here.
181				unsafe {
182					libc::close(reader);
183					libc::close(writer);
184					libc::close(entry_backup);
185				}
186				return Err(error);
187			}
188			if SAVED_STDERR_FD.load(Ordering::Acquire) != saved {
189				// A crash restore raced terminal entry. It ran before the
190				// redirect, so undo that redirect from our private backup.
191				// SAFETY: `entry_backup`, reader, and writer remain owned by this function.
192				unsafe {
193					libc::dup2(entry_backup, libc::STDERR_FILENO);
194					libc::close(reader);
195					libc::close(writer);
196					libc::close(entry_backup);
197				}
198				return Err(io::Error::new(
199					io::ErrorKind::Interrupted,
200					"stderr capture interrupted by emergency restore",
201				));
202			}
203			// SAFETY: these descriptors remain owned by this function after redirection.
204			unsafe {
205				libc::close(writer);
206				libc::close(entry_backup);
207			}
208			Ok(Self {
209				// SAFETY: `reader` is the uniquely owned pipe descriptor.
210				reader:   Some(unsafe { File::from_raw_fd(reader) }),
211				captured: CapturedStderr::new(),
212				active:   true,
213			})
214		}
215
216		pub(super) fn drain(&mut self) {
217			let Some(reader) = &self.reader else {
218				return;
219			};
220			let mut chunk = [0_u8; 4096];
221			loop {
222				// SAFETY: `chunk` is writable for its stated length and `reader` is valid.
223				let count =
224					unsafe { libc::read(reader.as_raw_fd(), chunk.as_mut_ptr().cast(), chunk.len()) };
225				if count > 0 {
226					self.captured.push(&chunk[..count as usize]);
227					continue;
228				}
229				if count == 0 {
230					break;
231				}
232				let error = io::Error::last_os_error();
233				if error.kind() == io::ErrorKind::Interrupted {
234					continue;
235				}
236				break;
237			}
238		}
239
240		pub(super) fn restore(&mut self) -> io::Result<()> {
241			let result = if self.active {
242				restore_stderr()
243			} else {
244				Ok(())
245			};
246			if result.is_ok() {
247				self.active = false;
248			}
249			self.drain();
250			result
251		}
252
253		pub(super) fn captured(&self) -> &[u8] {
254			self.captured.as_slice()
255		}
256	}
257
258	impl Drop for StderrGuard {
259		fn drop(&mut self) {
260			let _ = self.restore();
261		}
262	}
263
264	fn configure_pipe(reader: RawFd, writer: RawFd) -> io::Result<()> {
265		for descriptor in [reader, writer] {
266			// SAFETY: `descriptor` is a valid pipe descriptor.
267			if unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
268				return Err(io::Error::last_os_error());
269			}
270			// SAFETY: `descriptor` is a valid pipe descriptor.
271			let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) };
272			if flags < 0
273				// SAFETY: `descriptor` is a valid pipe descriptor and `flags` came from it.
274				|| unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0
275			{
276				return Err(io::Error::last_os_error());
277			}
278		}
279		Ok(())
280	}
281	pub(super) fn activate_resize_pipe() -> io::Result<()> {
282		let mut descriptors = [-1; 2];
283		// SAFETY: `descriptors` is a writable two-element buffer for `pipe`.
284		if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
285			return Err(io::Error::last_os_error());
286		}
287		if let Err(error) = configure_pipe(descriptors[0], descriptors[1]) {
288			// SAFETY: both descriptors were returned by `pipe` and remain owned here.
289			unsafe {
290				libc::close(descriptors[0]);
291				libc::close(descriptors[1]);
292			}
293			return Err(error);
294		}
295		RESIZE_PIPE_FDS.0.store(descriptors[0], Ordering::Release);
296		RESIZE_PIPE_FDS.1.store(descriptors[1], Ordering::Release);
297		Ok(())
298	}
299	pub(super) fn resize_pipe_reader() -> io::Result<OwnedFd> {
300		let reader = RESIZE_PIPE_FDS.0.load(Ordering::Acquire);
301		if reader < 0 {
302			return Err(io::Error::new(io::ErrorKind::NotConnected, "resize pipe is inactive"));
303		}
304		// SAFETY: `reader` is live while the resize pipe is active.
305		let duplicate = unsafe { libc::fcntl(reader, libc::F_DUPFD_CLOEXEC, 0) };
306		if duplicate < 0 {
307			return Err(io::Error::last_os_error());
308		}
309		// SAFETY: `F_DUPFD_CLOEXEC` returned a fresh descriptor owned by the caller.
310		Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
311	}
312
313	fn close_resize_pipe() {
314		let writer = RESIZE_PIPE_FDS.1.swap(-1, Ordering::AcqRel);
315		let reader = RESIZE_PIPE_FDS.0.swap(-1, Ordering::AcqRel);
316		// SAFETY: swapped descriptors are no longer published and are owned for
317		// closure.
318		unsafe {
319			if writer >= 0 {
320				libc::close(writer);
321			}
322			if reader >= 0 {
323				libc::close(reader);
324			}
325		}
326	}
327
328	fn restore_stderr() -> io::Result<()> {
329		loop {
330			let saved = SAVED_STDERR_FD.load(Ordering::Acquire);
331			if saved < 0 {
332				return Ok(());
333			}
334			// SAFETY: `saved` remains valid until the successful atomic exchange below.
335			if unsafe { libc::dup2(saved, libc::STDERR_FILENO) } < 0 {
336				let error = io::Error::last_os_error();
337				if error.kind() == io::ErrorKind::Interrupted {
338					continue;
339				}
340				// A concurrent emergency restore closes `saved` only after it
341				// has already restored fd 2.
342				if SAVED_STDERR_FD.load(Ordering::Acquire) < 0 {
343					return Ok(());
344				}
345				return Err(error);
346			}
347			if SAVED_STDERR_FD
348				.compare_exchange(saved, -1, Ordering::AcqRel, Ordering::Acquire)
349				.is_ok()
350			{
351				// SAFETY: the compare-exchange transfers ownership of `saved` here.
352				unsafe {
353					libc::close(saved);
354				}
355			}
356			return Ok(());
357		}
358	}
359
360	pub(super) fn emergency_restore_stderr() {
361		let saved = SAVED_STDERR_FD.swap(-1, Ordering::AcqRel);
362		if saved < 0 {
363			return;
364		}
365		loop {
366			// SAFETY: `saved` is the descriptor atomically claimed by this handler.
367			if unsafe { libc::dup2(saved, libc::STDERR_FILENO) } >= 0 || errno() != libc::EINTR {
368				break;
369			}
370		}
371		// SAFETY: `saved` was atomically claimed by this handler.
372		unsafe {
373			libc::close(saved);
374		}
375	}
376
377	pub(super) fn prepare() -> io::Result<(File, State)> {
378		let tty = crate::tty::open(OpenOptions::new().read(true).write(true))?;
379		let original = tcgetattr(&tty).map_err(errno_to_io)?;
380		Ok((tty, State { original: Some(original) }))
381	}
382
383	pub(super) fn enable_raw(tty: &File, state: &State) -> io::Result<()> {
384		let original = state
385			.original
386			.as_ref()
387			.expect("prepared terminal has original mode");
388		let mut signal_termios = MaybeUninit::<libc::termios>::uninit();
389		// SAFETY: `signal_termios` is writable and `tty` is a valid terminal
390		// descriptor.
391		if unsafe { libc::tcgetattr(tty.as_raw_fd(), signal_termios.as_mut_ptr()) } != 0 {
392			return Err(io::Error::last_os_error());
393		}
394		// SAFETY: terminal activation is exclusive and publication follows this write.
395		unsafe { (*SIGNAL_TERMIOS.0.get()).write(signal_termios.assume_init()) };
396		TTY_FD.store(tty.as_raw_fd(), Ordering::Release);
397		RAW_VALID.store(true, Ordering::Release);
398
399		let mut raw = original.clone();
400		cfmakeraw(&mut raw);
401		if let Err(error) = tcsetattr(tty, SetArg::TCSANOW, &raw) {
402			let _ = tcsetattr(tty, SetArg::TCSANOW, original);
403			deactivate();
404			return Err(errno_to_io(error));
405		}
406		Ok(())
407	}
408
409	pub(super) fn restore_raw(tty: &File, state: &mut State) -> io::Result<()> {
410		let Some(original) = &state.original else {
411			return Ok(());
412		};
413		// TCSAFLUSH: mouse-motion reports queued after the last input read
414		// would otherwise echo into the shell once cooked mode returns.
415		tcsetattr(tty, SetArg::TCSAFLUSH, original).map_err(errno_to_io)?;
416		state.original = None;
417		Ok(())
418	}
419
420	pub(super) fn size(tty: &File, _: &State) -> io::Result<Size> {
421		let mut window = MaybeUninit::<libc::winsize>::zeroed();
422		// SAFETY: `window` is writable and `tty` is a valid terminal descriptor.
423		if unsafe { libc::ioctl(tty.as_raw_fd(), libc::TIOCGWINSZ, window.as_mut_ptr()) } != 0 {
424			return Err(io::Error::last_os_error());
425		}
426		// SAFETY: successful TIOCGWINSZ initializes every field of `window`.
427		let window = unsafe { window.assume_init() };
428		if window.ws_col == 0 || window.ws_row == 0 {
429			return Err(io::Error::new(io::ErrorKind::InvalidData, "terminal reported a zero size"));
430		}
431		Ok(Size::new(window.ws_col, window.ws_row))
432	}
433
434	/// Input descriptor for synchronous polling: stdin in production (the
435	/// terminal in normal operation), the tty handle under an `OMP_TTY`
436	/// override or in tests, where stdin is never the terminal.
437	fn input_fd(tty: &File) -> RawFd {
438		#[cfg(not(test))]
439		if !crate::tty::overridden() {
440			return libc::STDIN_FILENO;
441		}
442		tty.as_raw_fd()
443	}
444
445	pub(super) fn drain(tty: &File, _: &State, maximum: Duration, idle: Duration) -> io::Result<()> {
446		drain_fd(input_fd(tty), maximum, idle)
447	}
448
449	fn drain_fd(fd: RawFd, maximum: Duration, idle: Duration) -> io::Result<()> {
450		let started = Instant::now();
451		let mut last_data = started;
452		let mut buffer = [0; 256];
453		loop {
454			let now = Instant::now();
455			let wait = maximum
456				.saturating_sub(now.duration_since(started))
457				.min(idle.saturating_sub(now.duration_since(last_data)));
458			if wait.is_zero() {
459				return Ok(());
460			}
461			let timeout = wait.as_millis().clamp(1, i32::MAX as u128) as i32;
462			let mut descriptor = libc::pollfd { fd, events: libc::POLLIN, revents: 0 };
463			// SAFETY: `descriptor` is a valid writable pollfd.
464			let result = unsafe { libc::poll(&mut descriptor, 1, timeout) };
465			if result == 0 {
466				continue;
467			}
468			if result < 0 {
469				let error = io::Error::last_os_error();
470				if error.kind() == io::ErrorKind::Interrupted {
471					continue;
472				}
473				return Err(error);
474			}
475			if descriptor.revents & (libc::POLLIN | libc::POLLHUP) == 0 {
476				continue;
477			}
478			// SAFETY: `buffer` is writable for its stated length and `fd` is polled
479			// readable.
480			let read = unsafe { libc::read(fd, buffer.as_mut_ptr().cast(), buffer.len()) };
481			if read > 0 {
482				last_data = Instant::now();
483				continue;
484			}
485			if read == 0 {
486				return Ok(());
487			}
488			let error = io::Error::last_os_error();
489			if !matches!(error.kind(), io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock) {
490				return Err(error);
491			}
492		}
493	}
494
495	pub(super) fn install_handlers() -> Result<(), i32> {
496		let fatal = SigAction::new(
497			SigHandler::Handler(fatal_signal_handler),
498			SaFlags::SA_RESTART,
499			SigSet::empty(),
500		);
501		let resize = SigAction::new(
502			SigHandler::Handler(resize_signal_handler),
503			SaFlags::SA_RESTART,
504			SigSet::empty(),
505		);
506		// SAFETY: handlers and actions are fully initialized and installed process-wide
507		// once.
508		unsafe {
509			signal::sigaction(Signal::SIGINT, &fatal).map_err(|error| error as i32)?;
510			signal::sigaction(Signal::SIGTERM, &fatal).map_err(|error| error as i32)?;
511			signal::sigaction(Signal::SIGHUP, &fatal).map_err(|error| error as i32)?;
512			signal::sigaction(Signal::SIGWINCH, &resize).map_err(|error| error as i32)?;
513		}
514		Ok(())
515	}
516
517	/// Wakes resize listeners over the self-pipe; async-signal-safe.
518	pub(super) fn notify_resize_pipe() {
519		let fd = RESIZE_PIPE_FDS.1.load(Ordering::Acquire);
520		if fd >= 0 {
521			// SAFETY: the self-pipe writer is nonblocking and signal-handler safe.
522			unsafe {
523				libc::write(fd, b"r".as_ptr().cast(), 1);
524			}
525		}
526	}
527
528	extern "C" fn resize_signal_handler(_: libc::c_int) {
529		RESIZE_GENERATION.fetch_add(1, Ordering::Relaxed);
530		notify_resize_pipe();
531	}
532
533	extern "C" fn fatal_signal_handler(signal_number: libc::c_int) {
534		emergency_restore_inner();
535		// SAFETY: restoring the default disposition then re-raising is signal-handler
536		// safe.
537		unsafe {
538			libc::signal(signal_number, libc::SIG_DFL);
539			libc::raise(signal_number);
540		}
541	}
542
543	pub(super) fn emergency_restore(payloads: [&[u8]; 3]) {
544		close_resize_pipe();
545		let fd = TTY_FD.load(Ordering::Acquire);
546		if fd < 0 {
547			return;
548		}
549		for payload in payloads {
550			raw_write_all(fd, payload);
551		}
552		if RAW_VALID.swap(false, Ordering::AcqRel) {
553			// SAFETY: RAW_VALID publishes initialized termios while this emergency path
554			// owns restore.
555			let termios = unsafe { (*SIGNAL_TERMIOS.0.get()).assume_init_ref() };
556			// SAFETY: `fd` is the published active terminal descriptor.
557			unsafe {
558				libc::tcsetattr(fd, libc::TCSANOW, termios);
559				// Unread mouse reports would echo into whatever reads the
560				// terminal next; TCSANOW above keeps the crash path from
561				// blocking on output drain, so flush input separately.
562				libc::tcflush(fd, libc::TCIFLUSH);
563			}
564		}
565	}
566
567	fn raw_write_all(fd: RawFd, mut bytes: &[u8]) {
568		while !bytes.is_empty() {
569			// SAFETY: `bytes` is readable for its stated length and `fd` is active.
570			let written = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
571			if written > 0 {
572				bytes = &bytes[written as usize..];
573				continue;
574			}
575			if written < 0 && errno() == libc::EINTR {
576				continue;
577			}
578			break;
579		}
580	}
581
582	#[cfg(any(
583		target_os = "macos",
584		target_os = "ios",
585		target_os = "freebsd",
586		target_os = "openbsd",
587		target_os = "netbsd",
588		target_os = "dragonfly"
589	))]
590	fn errno() -> libc::c_int {
591		// SAFETY: libc supplies a valid thread-local errno pointer.
592		unsafe { *libc::__error() }
593	}
594
595	#[cfg(any(target_os = "linux", target_os = "android"))]
596	fn errno() -> libc::c_int {
597		// SAFETY: libc supplies a valid thread-local errno pointer.
598		unsafe { *libc::__errno_location() }
599	}
600
601	pub(super) fn deactivate() {
602		close_resize_pipe();
603		RAW_VALID.store(false, Ordering::Release);
604		TTY_FD.store(-1, Ordering::Release);
605	}
606
607	fn errno_to_io(error: nix::errno::Errno) -> io::Error {
608		io::Error::from_raw_os_error(error as i32)
609	}
610
611	#[cfg(test)]
612	pub(super) fn drain_for_test(fd: RawFd, maximum: Duration, idle: Duration) -> io::Result<()> {
613		drain_fd(fd, maximum, idle)
614	}
615
616	#[cfg(test)]
617	pub(super) const fn state_for_test() -> State {
618		State { original: None }
619	}
620}
621
622#[cfg(windows)]
623mod platform {
624	use std::{
625		ffi::c_void,
626		fs::{File, OpenOptions, remove_file},
627		io::{self, Read as _},
628		os::windows::io::AsRawHandle as _,
629		path::PathBuf,
630		ptr,
631		sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, Ordering},
632		thread,
633		time::{Duration, Instant},
634	};
635
636	use windows_sys::Win32::{
637		Foundation::{FALSE, HANDLE, TRUE},
638		System::Console::{
639			CONSOLE_SCREEN_BUFFER_INFO, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT,
640			ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode,
641			GetConsoleScreenBufferInfo, GetNumberOfConsoleInputEvents, GetStdHandle, INPUT_RECORD,
642			ReadConsoleInputW, STD_ERROR_HANDLE, SetConsoleCtrlHandler, SetConsoleMode, SetStdHandle,
643			WriteConsoleA,
644		},
645	};
646
647	use super::{CapturedStderr, emergency_restore_inner};
648	use crate::Size;
649	static INPUT_HANDLE: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());
650	static OUTPUT_HANDLE: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());
651	static INPUT_MODE: AtomicU32 = AtomicU32::new(0);
652	static OUTPUT_MODE: AtomicU32 = AtomicU32::new(0);
653	static MODES_VALID: AtomicBool = AtomicBool::new(false);
654	static SAVED_STDERR_HANDLE: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());
655	static STDERR_HANDLE_VALID: AtomicBool = AtomicBool::new(false);
656	static STDERR_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
657
658	pub(super) struct State {
659		_input_file:     File,
660		input:           HANDLE,
661		output:          HANDLE,
662		original_input:  u32,
663		original_output: u32,
664		raw:             bool,
665	}
666	pub(super) struct StderrGuard {
667		writer:   Option<File>,
668		reader:   Option<File>,
669		path:     Option<PathBuf>,
670		captured: CapturedStderr,
671		active:   bool,
672	}
673
674	impl StderrGuard {
675		pub(super) fn new(capture: bool) -> io::Result<Self> {
676			if !capture {
677				return Ok(Self {
678					writer:   None,
679					reader:   None,
680					path:     None,
681					captured: CapturedStderr::default(),
682					active:   false,
683				});
684			}
685			let sequence = STDERR_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
686			let path = std::env::temp_dir()
687				.join(format!("omp-tui-stderr-{}-{sequence}.tmp", std::process::id()));
688			let writer = OpenOptions::new()
689				.write(true)
690				.create_new(true)
691				.open(&path)?;
692			let reader = match OpenOptions::new().read(true).open(&path) {
693				Ok(reader) => reader,
694				Err(error) => {
695					drop(writer);
696					let _ = remove_file(&path);
697					return Err(error);
698				},
699			};
700			let original = unsafe { GetStdHandle(STD_ERROR_HANDLE) };
701			SAVED_STDERR_HANDLE.store(original, Ordering::Release);
702			STDERR_HANDLE_VALID.store(true, Ordering::Release);
703			if unsafe { SetStdHandle(STD_ERROR_HANDLE, writer.as_raw_handle()) } == 0 {
704				let error = io::Error::last_os_error();
705				STDERR_HANDLE_VALID.store(false, Ordering::Release);
706				SAVED_STDERR_HANDLE.store(ptr::null_mut(), Ordering::Release);
707				drop(reader);
708				drop(writer);
709				let _ = remove_file(&path);
710				return Err(error);
711			}
712			Ok(Self {
713				writer:   Some(writer),
714				reader:   Some(reader),
715				path:     Some(path),
716				captured: CapturedStderr::new(),
717				active:   true,
718			})
719		}
720
721		pub(super) fn drain(&mut self) {
722			let Some(reader) = &mut self.reader else {
723				return;
724			};
725			let mut chunk = [0_u8; 4096];
726			loop {
727				match reader.read(&mut chunk) {
728					Ok(0) | Err(_) => break,
729					Ok(count) => self.captured.push(&chunk[..count]),
730				}
731			}
732		}
733
734		pub(super) fn restore(&mut self) -> io::Result<()> {
735			let result = if self.active {
736				restore_stderr()
737			} else {
738				Ok(())
739			};
740			if result.is_ok() {
741				self.active = false;
742				self.writer.take();
743			}
744			self.drain();
745			result
746		}
747
748		pub(super) fn captured(&self) -> &[u8] {
749			self.captured.as_slice()
750		}
751	}
752
753	impl Drop for StderrGuard {
754		fn drop(&mut self) {
755			let _ = self.restore();
756			self.reader.take();
757			if let Some(path) = self.path.take() {
758				let _ = remove_file(path);
759			}
760		}
761	}
762
763	fn restore_stderr() -> io::Result<()> {
764		if !STDERR_HANDLE_VALID.load(Ordering::Acquire) {
765			return Ok(());
766		}
767		let original = SAVED_STDERR_HANDLE.load(Ordering::Acquire);
768		if unsafe { SetStdHandle(STD_ERROR_HANDLE, original) } == 0 {
769			return Err(io::Error::last_os_error());
770		}
771		STDERR_HANDLE_VALID.store(false, Ordering::Release);
772		SAVED_STDERR_HANDLE.store(ptr::null_mut(), Ordering::Release);
773		Ok(())
774	}
775
776	pub(super) fn emergency_restore_stderr() {
777		if !STDERR_HANDLE_VALID.swap(false, Ordering::AcqRel) {
778			return;
779		}
780		let original = SAVED_STDERR_HANDLE.swap(ptr::null_mut(), Ordering::AcqRel);
781		unsafe {
782			SetStdHandle(STD_ERROR_HANDLE, original);
783		}
784	}
785
786	pub(super) fn prepare() -> io::Result<(File, State)> {
787		let tty = OpenOptions::new().write(true).open("CONOUT$")?;
788		let input_file = OpenOptions::new().read(true).open("CONIN$")?;
789		let input = input_file.as_raw_handle();
790		let output = tty.as_raw_handle();
791		let mut original_input = 0;
792		let mut original_output = 0;
793		if unsafe { GetConsoleMode(input, &mut original_input) } == 0
794			|| unsafe { GetConsoleMode(output, &mut original_output) } == 0
795		{
796			return Err(io::Error::last_os_error());
797		}
798		Ok((tty, State {
799			_input_file: input_file,
800			input,
801			output,
802			original_input,
803			original_output,
804			raw: false,
805		}))
806	}
807
808	pub(super) fn enable_raw(_: &File, state: &mut State) -> io::Result<()> {
809		let input_mode = (state.original_input | ENABLE_VIRTUAL_TERMINAL_INPUT)
810			& !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT);
811		if unsafe { SetConsoleMode(state.input, input_mode) } == 0 {
812			return Err(io::Error::last_os_error());
813		}
814		let output_mode = state.original_output | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
815		if unsafe { SetConsoleMode(state.output, output_mode) } == 0 {
816			let error = io::Error::last_os_error();
817			let _ = unsafe { SetConsoleMode(state.input, state.original_input) };
818			return Err(error);
819		}
820		INPUT_HANDLE.store(state.input, Ordering::Release);
821		OUTPUT_HANDLE.store(state.output, Ordering::Release);
822		INPUT_MODE.store(state.original_input, Ordering::Release);
823		OUTPUT_MODE.store(state.original_output, Ordering::Release);
824		MODES_VALID.store(true, Ordering::Release);
825		state.raw = true;
826		Ok(())
827	}
828
829	pub(super) fn restore_raw(_: &File, state: &mut State) -> io::Result<()> {
830		if !state.raw {
831			return Ok(());
832		}
833		let mut first = None;
834		if unsafe { SetConsoleMode(state.input, state.original_input) } == 0 {
835			first = Some(io::Error::last_os_error());
836		}
837		if unsafe { SetConsoleMode(state.output, state.original_output) } == 0 && first.is_none() {
838			first = Some(io::Error::last_os_error());
839		}
840		if first.is_none() {
841			state.raw = false;
842			MODES_VALID.store(false, Ordering::Release);
843		}
844		first.map_or(Ok(()), Err)
845	}
846
847	pub(super) fn size(_: &File, state: &State) -> io::Result<Size> {
848		let mut info = unsafe { std::mem::zeroed::<CONSOLE_SCREEN_BUFFER_INFO>() };
849		if unsafe { GetConsoleScreenBufferInfo(state.output, &mut info) } == 0 {
850			return Err(io::Error::last_os_error());
851		}
852		let columns = i32::from(info.srWindow.Right) - i32::from(info.srWindow.Left) + 1;
853		let rows = i32::from(info.srWindow.Bottom) - i32::from(info.srWindow.Top) + 1;
854		let columns = u16::try_from(columns).map_err(|_| {
855			io::Error::new(io::ErrorKind::InvalidData, "console reported an invalid width")
856		})?;
857		let rows = u16::try_from(rows).map_err(|_| {
858			io::Error::new(io::ErrorKind::InvalidData, "console reported an invalid height")
859		})?;
860		if columns == 0 || rows == 0 {
861			return Err(io::Error::new(io::ErrorKind::InvalidData, "console reported a zero size"));
862		}
863		Ok(Size::new(columns, rows))
864	}
865
866	pub(super) const fn activate_resize_pipe() -> io::Result<()> {
867		Ok(())
868	}
869
870	pub(super) fn drain(
871		_: &File,
872		state: &State,
873		maximum: Duration,
874		idle: Duration,
875	) -> io::Result<()> {
876		let started = Instant::now();
877		let mut last_data = started;
878		let mut records = [unsafe { std::mem::zeroed::<INPUT_RECORD>() }; 64];
879		loop {
880			let now = Instant::now();
881			let wait = maximum
882				.saturating_sub(now.duration_since(started))
883				.min(idle.saturating_sub(now.duration_since(last_data)));
884			if wait.is_zero() {
885				return Ok(());
886			}
887			let mut available = 0;
888			if unsafe { GetNumberOfConsoleInputEvents(state.input, &mut available) } == 0 {
889				return Err(io::Error::last_os_error());
890			}
891			if available == 0 {
892				thread::sleep(wait.min(Duration::from_millis(1)));
893				continue;
894			}
895			let mut read = 0;
896			let count = available.min(records.len() as u32);
897			if unsafe { ReadConsoleInputW(state.input, records.as_mut_ptr(), count, &mut read) } == 0 {
898				return Err(io::Error::last_os_error());
899			}
900			if read != 0 {
901				last_data = Instant::now();
902			}
903		}
904	}
905
906	pub(super) fn install_handlers() -> Result<(), i32> {
907		if unsafe { SetConsoleCtrlHandler(Some(console_ctrl_handler), TRUE) } == 0 {
908			return Err(io::Error::last_os_error().raw_os_error().unwrap_or(1));
909		}
910		Ok(())
911	}
912
913	unsafe extern "system" fn console_ctrl_handler(_: u32) -> i32 {
914		emergency_restore_inner();
915		FALSE
916	}
917
918	pub(super) fn emergency_restore(payloads: [&[u8]; 3]) {
919		if !MODES_VALID.swap(false, Ordering::AcqRel) {
920			return;
921		}
922		let output = OUTPUT_HANDLE.load(Ordering::Acquire);
923		for mut remaining in payloads {
924			while !remaining.is_empty() {
925				let mut written = 0;
926				if unsafe {
927					WriteConsoleA(
928						output,
929						remaining.as_ptr(),
930						remaining.len().min(u32::MAX as usize) as u32,
931						&mut written,
932						ptr::null(),
933					)
934				} == 0 || written == 0
935				{
936					break;
937				}
938				remaining = &remaining[written as usize..];
939			}
940		}
941		let input = INPUT_HANDLE.load(Ordering::Acquire);
942		let _ = unsafe { SetConsoleMode(input, INPUT_MODE.load(Ordering::Acquire)) };
943		let _ = unsafe { SetConsoleMode(output, OUTPUT_MODE.load(Ordering::Acquire)) };
944	}
945
946	pub(super) fn deactivate() {
947		MODES_VALID.store(false, Ordering::Release);
948	}
949}
950
951/// Whether any live [`Terminal`] currently holds the alternate screen.
952///
953/// Serves the `OMP_TUI_DEBUG` `text`/`info` ops on stream-served hosts,
954/// which have no [`Terminal`] in reach, and is read by the renderer to
955/// invalidate terminal-side graphics caches across buffer switches:
956/// terminals with per-screen Kitty image storage (ghostty) lose
957/// transmissions and placements made on the other buffer.
958pub fn alt_screen_active() -> bool {
959	ALT_SCREEN_ACTIVE.load(Ordering::Acquire)
960}
961
962/// Emulates a SIGWINCH delivery for the `OMP_TUI_DEBUG` `resize` op.
963///
964/// A harness resizing an `OMP_TTY` override device cannot reach the process
965/// with a real signal; bumping the resize generation and waking the pipe
966/// fires every host's normal geometry recheck instead.
967pub fn simulate_resize_signal() {
968	RESIZE_GENERATION.fetch_add(1, Ordering::Relaxed);
969	#[cfg(unix)]
970	platform::notify_resize_pipe();
971}
972
973use crate::{
974	InputDecoder, InputEvent, Keymap, ProbeResults, Renderer, Size, TerminalCaps, TerminalResponse,
975	context::Appearance,
976	escape::esc,
977	graphics::negotiate,
978	paste::{PasteEvents, PasteProgress, Pasted},
979};
980
981const RESIZE_DEBOUNCE: Duration = Duration::from_millis(50);
982const APPEARANCE_DEBOUNCE: Duration = Duration::from_millis(100);
983const OSC11_QUERY: &[u8] = esc!(background_color_query).as_bytes();
984const DRAIN_IDLE: Duration = Duration::from_millis(50);
985const DRAIN_MAX: Duration = Duration::from_millis(1_000);
986const PROGRESS_KEEPALIVE: Duration = Duration::from_millis(1_000);
987const PROGRESS_CLEAR: &[u8] = esc!(progress_clear).as_bytes();
988const TITLE_PUSH: &[u8] = esc!(title_push).as_bytes();
989const TITLE_POP: &[u8] = esc!(title_pop).as_bytes();
990/// Fixed modes that make the terminal *send* input. Capability-gated
991/// appearance and resize notification resets are appended by
992/// [`compose_input_reports_off`]. Written before the teardown drain so
993/// in-flight reports die there instead of echoing into the shell.
994const INPUT_REPORTS_OFF: &[u8] = esc!(
995	!mouse_sgr,
996	!mouse_any_event,
997	!mouse_button_event,
998	!mouse_vt200,
999	!bracketed_paste,
1000	!paste_events,
1001)
1002.as_bytes();
1003/// Click and all-motion tracking with SGR encoding — the set scoped to
1004/// sessions that opt into pointer interaction and to the alternate screen.
1005/// Matches the coding agent: `1002` (button-motion) is omitted because
1006/// `1003` already reports every motion, and the emergency-restore payloads
1007/// only reset the modes this set can leave enabled.
1008const MOUSE_TRACKING_ON: &[u8] = esc!(mouse_vt200, mouse_any_event, mouse_sgr).as_bytes();
1009const MOUSE_TRACKING_OFF: &[u8] = esc!(!mouse_sgr, !mouse_any_event, !mouse_vt200).as_bytes();
1010/// Composes a blind restore payload for the panic and fatal-signal handlers:
1011/// one shared mode-reset sequence with each variant's deltas spliced in.
1012/// `main` re-parks the cursor at the viewport bottom because resetting the
1013/// scroll margins homes it; `alt` instead leaves the alternate screen —
1014/// `?1049l` restores the saved main-screen cursor — and re-resets the modes
1015/// entering it enabled. Trailing idents name the xterm scroll-to-bottom
1016/// modes the session disabled and the payload must restore.
1017macro_rules! emergency_restore {
1018	(main $(, $scroll:ident)*) => {
1019		emergency_restore!(@compose [viewport_bottom,] [] $($scroll),*)
1020	};
1021	(alt $(, $scroll:ident)*) => {
1022		emergency_restore!(
1023			@compose [] [!alt_screen, !app_cursor_keys, !app_keypad, kitty_keyboard_pop,]
1024			$($scroll),*
1025		)
1026	};
1027	(@compose [$($park:tt)*] [$($alt_teardown:tt)*] $($scroll:ident),*) => {
1028		esc!(
1029			progress_clear,
1030			!sync_output,
1031			margins_reset,
1032			$($park)*
1033			autowrap,
1034			!app_cursor_keys,
1035			!app_keypad,
1036			!bracketed_paste,
1037			$($scroll,)*
1038			!paste_events,
1039			kitty_keyboard_pop,
1040			!modify_other_keys,
1041			!mouse_sgr,
1042			!mouse_any_event,
1043			!mouse_vt200,
1044			$($alt_teardown)*
1045			title_pop,
1046			cursor_visible,
1047		)
1048		.as_bytes()
1049	};
1050}
1051const XTERM_SCROLL_ON_OUTPUT: u8 = 1;
1052const XTERM_SCROLL_ON_KEY_PRESS: u8 = 2;
1053const ANSI_INSERT_MODE: u8 = 1;
1054const ANSI_NEWLINE_MODE: u8 = 2;
1055const APPEARANCE_NOTIFICATIONS_MODE: u8 = 1;
1056const IN_BAND_RESIZE_MODE: u8 = 2;
1057#[cfg(any(windows, test))]
1058const UTF8_CODEPAGE: u32 = 65001;
1059
1060#[cfg(any(windows, test))]
1061trait ConsoleCodepage {
1062	fn output_codepage(&mut self) -> u32;
1063	fn set_output_codepage(&mut self, codepage: u32) -> bool;
1064}
1065
1066#[cfg(any(windows, test))]
1067fn ensure_console_utf8(console: &mut impl ConsoleCodepage) {
1068	let codepage = console.output_codepage();
1069	if codepage != 0 && codepage != UTF8_CODEPAGE {
1070		let _ = console.set_output_codepage(UTF8_CODEPAGE);
1071	}
1072}
1073
1074#[cfg(windows)]
1075struct SystemConsoleCodepage;
1076
1077#[cfg(windows)]
1078impl ConsoleCodepage for SystemConsoleCodepage {
1079	fn output_codepage(&mut self) -> u32 {
1080		// SAFETY: GetConsoleOutputCP has no pointer arguments or preconditions.
1081		unsafe { GetConsoleOutputCP() }
1082	}
1083
1084	fn set_output_codepage(&mut self, codepage: u32) -> bool {
1085		// SAFETY: SetConsoleOutputCP accepts any codepage identifier; 65001 is
1086		// the documented UTF-8 identifier.
1087		unsafe { SetConsoleOutputCP(codepage) != 0 }
1088	}
1089}
1090
1091pub fn terminal_write_all<W: io::Write>(writer: &mut W, bytes: &[u8]) -> io::Result<()> {
1092	#[cfg(windows)]
1093	ensure_console_utf8(&mut SystemConsoleCodepage);
1094	writer.write_all(bytes)
1095}
1096
1097static ACTIVE: AtomicBool = AtomicBool::new(false);
1098static ALT_SCREEN_ACTIVE: AtomicBool = AtomicBool::new(false);
1099static XTERM_SCROLL_RESTORE_MODES: AtomicU8 = AtomicU8::new(0);
1100static ANSI_MODE_RESTORE_MODES: AtomicU8 = AtomicU8::new(0);
1101static OWNED_NOTIFICATION_MODES: AtomicU8 = AtomicU8::new(0);
1102static RESIZE_GENERATION: AtomicU64 = AtomicU64::new(0);
1103static HOOKS: LazyLock<Result<(), i32>> = LazyLock::new(platform::install_handlers);
1104static PANIC_HOOK: Once = Once::new();
1105
1106/// Cursor shape requested while the terminal is owned by the application.
1107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1108pub enum CursorStyle {
1109	/// Blinking block cursor.
1110	BlinkingBlock,
1111	/// Steady block cursor.
1112	SteadyBlock,
1113	/// Blinking underline cursor.
1114	BlinkingUnderline,
1115	/// Steady underline cursor.
1116	SteadyUnderline,
1117	/// Blinking bar cursor.
1118	BlinkingBar,
1119	/// Steady bar cursor.
1120	SteadyBar,
1121}
1122
1123impl CursorStyle {
1124	const fn sequence(self) -> &'static [u8] {
1125		match self {
1126			Self::BlinkingBlock => esc!(cursor_style_blinking_block).as_bytes(),
1127			Self::SteadyBlock => esc!(cursor_style_steady_block).as_bytes(),
1128			Self::BlinkingUnderline => esc!(cursor_style_blinking_underline).as_bytes(),
1129			Self::SteadyUnderline => esc!(cursor_style_steady_underline).as_bytes(),
1130			Self::BlinkingBar => esc!(cursor_style_blinking_bar).as_bytes(),
1131			Self::SteadyBar => esc!(cursor_style_steady_bar).as_bytes(),
1132		}
1133	}
1134}
1135/// Why staged alternate-screen ownership is being taken
1136/// ([`Terminal::stage_alt_enter`]).
1137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1138pub enum AltScreenUse {
1139	/// An interactive surface — a fullscreen scene or modal overlay — that
1140	/// captures the mouse while it is held.
1141	Interactive,
1142	/// A passive borrow for throwaway resize drag frames; input modes stay
1143	/// untouched so motion reports cannot flood the gesture.
1144	Resize,
1145}
1146
1147/// OSC 9;4 taskbar progress state.
1148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1149pub enum Progress {
1150	/// Clears the terminal progress indicator.
1151	Clear,
1152	/// Reports ordinary determinate progress.
1153	Value(u8),
1154	/// Reports determinate progress in an error state.
1155	Error(u8),
1156	/// Reports progress whose completion percentage is unknown.
1157	Indeterminate,
1158	/// Reports paused determinate progress.
1159	Paused(u8),
1160}
1161
1162/// Options controlling terminal entry.
1163#[derive(Clone, Debug, Eq, PartialEq)]
1164pub struct TerminalOptions {
1165	/// Capabilities resolved for the controlling terminal.
1166	///
1167	/// `None` asks [`Terminal::enter`] to negotiate capabilities while using
1168	/// the same decoder that the live input pump will own.
1169	pub caps:           Option<TerminalCaps>,
1170	/// Whether fd 2 is captured while the terminal owns the viewport.
1171	///
1172	/// Capturing is enabled by default. Disable it when stderr already targets
1173	/// an application-managed sink that must remain live during the TUI session.
1174	pub capture_stderr: bool,
1175	/// Cursor shape to use while the application owns the terminal.
1176	pub cursor_style:   Option<CursorStyle>,
1177	/// Whether inline mouse reporting is enabled for the whole session.
1178	///
1179	/// Off by default so the terminal's native text selection keeps working;
1180	/// the alternate screen always enables reporting while it is active.
1181	pub mouse:          bool,
1182	probe:              ProbeResults,
1183	probe_timeout:      Duration,
1184}
1185
1186impl TerminalOptions {
1187	/// Creates options for already-resolved terminal capabilities.
1188	pub fn new(caps: TerminalCaps) -> Self {
1189		Self {
1190			caps:           Some(caps),
1191			capture_stderr: true,
1192			cursor_style:   None,
1193			mouse:          false,
1194			probe:          ProbeResults::default(),
1195			probe_timeout:  Duration::from_millis(150),
1196		}
1197	}
1198
1199	/// Carries replies and preserved input from an earlier [`crate::negotiate`]
1200	/// call into terminal mode restoration and the live decoder.
1201	pub fn probe_results(mut self, probe: ProbeResults) -> Self {
1202		self.probe = probe;
1203		self
1204	}
1205
1206	/// Sets the capability-probe deadline used when capabilities were not
1207	/// supplied.
1208	pub const fn probe_timeout(mut self, timeout: Duration) -> Self {
1209		self.probe_timeout = timeout;
1210		self
1211	}
1212
1213	/// Requests a cursor style for the terminal session.
1214	pub const fn cursor_style(mut self, cursor_style: CursorStyle) -> Self {
1215		self.cursor_style = Some(cursor_style);
1216		self
1217	}
1218
1219	/// Enables inline mouse reporting (click, drag, motion) for the session.
1220	///
1221	/// This trades native text selection for pointer interaction, so leave it
1222	/// off unless the application is genuinely pointer-driven.
1223	pub const fn mouse(mut self, mouse: bool) -> Self {
1224		self.mouse = mouse;
1225		self
1226	}
1227
1228	/// Enables or disables capture of unmanaged stderr writes.
1229	pub const fn capture_stderr(mut self, capture: bool) -> Self {
1230		self.capture_stderr = capture;
1231		self
1232	}
1233}
1234
1235impl Default for TerminalOptions {
1236	fn default() -> Self {
1237		Self {
1238			caps:           None,
1239			capture_stderr: true,
1240			cursor_style:   None,
1241			mouse:          false,
1242			probe:          ProbeResults::default(),
1243			probe_timeout:  Duration::from_millis(150),
1244		}
1245	}
1246}
1247
1248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1249enum KeyboardMode {
1250	Kitty(&'static str),
1251	ModifyOtherKeys,
1252}
1253
1254impl KeyboardMode {
1255	const fn enter(self) -> &'static [u8] {
1256		match self {
1257			Self::Kitty(sequence) => sequence.as_bytes(),
1258			Self::ModifyOtherKeys => esc!(modify_other_keys).as_bytes(),
1259		}
1260	}
1261
1262	const fn leave(self) -> &'static [u8] {
1263		match self {
1264			Self::Kitty(_) => esc!(kitty_keyboard_pop).as_bytes(),
1265			Self::ModifyOtherKeys => esc!(!modify_other_keys).as_bytes(),
1266		}
1267	}
1268}
1269
1270struct ProgressWorker {
1271	state:  Arc<AtomicU16>,
1272	thread: thread::JoinHandle<()>,
1273}
1274
1275/// Owns raw mode and every terminal mode enabled for an interactive session.
1276///
1277/// Only one `Terminal` may be active in a process. Normal teardown is
1278/// idempotent, and panic plus fatal-signal handlers perform an allocation-free
1279/// blind restore when ordinary unwinding cannot run.
1280pub struct Terminal {
1281	caps: TerminalCaps,
1282	tty: File,
1283	platform: platform::State,
1284	stderr: platform::StderrGuard,
1285	keyboard: KeyboardMode,
1286	cursor_style: Option<CursorStyle>,
1287	xterm_scroll_restore_modes: u8,
1288	ansi_mode_restore_modes: u8,
1289	owned_notification_modes: u8,
1290	mouse: bool,
1291	cursor_visible: Option<bool>,
1292	alt_screen: bool,
1293	alt_mouse: bool,
1294	active: bool,
1295	inside_multiplexer: bool,
1296	seen_resize: u64,
1297	pending_resize: Option<(u64, Instant)>,
1298	appearance: Option<Appearance>,
1299	appearance_callbacks: Vec<Box<dyn FnMut(Appearance) + Send>>,
1300	appearance_query_generation: Arc<AtomicU64>,
1301	in_band_size: Option<Size>,
1302	keymap: Keymap,
1303	resize_ready: bool,
1304	resize_live: bool,
1305	events: flume::Receiver<crate::pump::TerminalEvent>,
1306	resize_watch: tokio::sync::watch::Receiver<u64>,
1307	pump: crate::pump::Pump,
1308	cell_pixel_size: Option<(u16, u16)>,
1309	progress: Option<ProgressWorker>,
1310	paste_events: PasteEvents,
1311	pending_paste: Option<Pasted>,
1312}
1313
1314impl Terminal {
1315	/// Takes ownership of the controlling terminal and emits one
1316	/// capability-aware entry batch.
1317	pub fn enter(mut options: TerminalOptions) -> io::Result<Self> {
1318		ensure_restore_hooks()?;
1319		let (caps, probe) = match options.caps {
1320			Some(caps) => (caps, std::mem::take(&mut options.probe)),
1321			None => negotiate(options.probe_timeout),
1322		};
1323		#[cfg(unix)]
1324		let (mut tty, platform) = platform::prepare()?;
1325		#[cfg(windows)]
1326		let (mut tty, mut platform) = platform::prepare()?;
1327		if ACTIVE
1328			.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1329			.is_err()
1330		{
1331			return Err(io::Error::new(
1332				io::ErrorKind::AlreadyExists,
1333				"another Terminal already owns the controlling terminal",
1334			));
1335		}
1336		if let Err(error) = platform::activate_resize_pipe() {
1337			deactivate_emergency_state();
1338			return Err(error);
1339		}
1340		// Debug server thread: started here so every host kind serves
1341		// `OMP_TUI_DEBUG`; the socket binds before the thread spawns so a
1342		// bad path fails loudly.
1343		if let Err(error) = crate::debug::ensure_server() {
1344			deactivate_emergency_state();
1345			return Err(error);
1346		}
1347		let stderr = match platform::StderrGuard::new(options.capture_stderr) {
1348			Ok(stderr) => stderr,
1349			Err(error) => {
1350				deactivate_emergency_state();
1351				return Err(error);
1352			},
1353		};
1354		#[cfg(unix)]
1355		let raw_result = platform::enable_raw(&tty, &platform);
1356		#[cfg(windows)]
1357		let raw_result = platform::enable_raw(&tty, &mut platform);
1358		if let Err(error) = raw_result {
1359			deactivate_emergency_state();
1360			return Err(error);
1361		}
1362
1363		let keyboard = keyboard_mode(caps.kitty_keyboard);
1364		let xterm_scroll_restore_modes = xterm_scroll_restore_modes(caps);
1365		let ansi_mode_restore_modes = ansi_mode_restore_modes(&probe);
1366		let owned_notification_modes = owned_notification_modes(caps, &probe);
1367		XTERM_SCROLL_RESTORE_MODES.store(xterm_scroll_restore_modes, Ordering::Release);
1368		ANSI_MODE_RESTORE_MODES.store(ansi_mode_restore_modes, Ordering::Release);
1369		OWNED_NOTIFICATION_MODES.store(owned_notification_modes, Ordering::Release);
1370		let batch = compose_enter(
1371			keyboard,
1372			options.cursor_style,
1373			xterm_scroll_restore_modes,
1374			owned_notification_modes,
1375			options.mouse,
1376			caps.paste_events,
1377		);
1378		if let Err(error) = terminal_write_all(&mut tty, &batch).and_then(|()| tty.flush()) {
1379			emergency_restore_inner();
1380			return Err(error);
1381		}
1382
1383		let appearance = caps
1384			.background
1385			.map(|(red, green, blue)| Appearance::from_rgb16(red, green, blue));
1386		let mut decoder = InputDecoder::new();
1387		decoder.set_kitty_keyboard(matches!(keyboard, KeyboardMode::Kitty(_)));
1388		let keymap = decoder.keymap().clone();
1389		// Event actor: an async task owns the decoder, the input handle,
1390		// and the resize self-pipe, and publishes decoded events on the
1391		let channels = match Self::acquire_input().and_then(|input| {
1392			crate::pump::spawn(
1393				input,
1394				decoder,
1395				&probe.preserved_input,
1396				#[cfg(unix)]
1397				platform::resize_pipe_reader().ok(),
1398				#[cfg(windows)]
1399				None,
1400			)
1401		}) {
1402			Ok(channels) => channels,
1403			Err(error) => {
1404				emergency_restore_inner();
1405				return Err(error);
1406			},
1407		};
1408		channels.pump.publish();
1409		Ok(Self {
1410			caps,
1411			tty,
1412			platform,
1413			stderr,
1414			keyboard,
1415			cursor_style: options.cursor_style,
1416			xterm_scroll_restore_modes,
1417			ansi_mode_restore_modes,
1418			owned_notification_modes,
1419			mouse: options.mouse,
1420			cursor_visible: Some(false),
1421			alt_screen: false,
1422			alt_mouse: false,
1423			active: true,
1424			inside_multiplexer: caps.inside_multiplexer,
1425			seen_resize: RESIZE_GENERATION.load(Ordering::Acquire),
1426			pending_resize: None,
1427			appearance,
1428			appearance_callbacks: Vec::new(),
1429			appearance_query_generation: Arc::new(AtomicU64::new(0)),
1430			in_band_size: None,
1431			keymap,
1432			resize_ready: false,
1433			resize_live: true,
1434			events: channels.events,
1435			resize_watch: channels.resize,
1436			pump: channels.pump,
1437			cell_pixel_size: caps.cell_px,
1438			progress: None,
1439			paste_events: PasteEvents::default(),
1440			pending_paste: None,
1441		})
1442	}
1443
1444	/// Chooses the event actor's input handle.
1445	///
1446	/// Production reads stdin when it is the terminal (matching shells and
1447	/// multiplexers), else a fresh controlling-terminal handle — the
1448	/// `OMP_TTY` override device when set. Non-macOS Unix handles are
1449	/// readiness-pollable; macOS `/dev/tty` and Windows `CONIN$` bridge
1450	/// through a reader thread.
1451	fn acquire_input() -> io::Result<crate::pump::Input> {
1452		#[cfg(all(unix, not(target_os = "macos")))]
1453		{
1454			use std::fs::OpenOptions;
1455			Ok(crate::pump::Input::Pollable(crate::tty::open(OpenOptions::new().read(true))?))
1456		}
1457		#[cfg(target_os = "macos")]
1458		{
1459			use std::fs::OpenOptions;
1460			// SAFETY: isatty reads only the fixed stdin descriptor.
1461			let stdin_is_tty = unsafe { nix::libc::isatty(nix::libc::STDIN_FILENO) } == 1;
1462			let input = if stdin_is_tty && !crate::tty::overridden() {
1463				// SAFETY: duplicating stdin does not affect Rust aliasing.
1464				let fd = unsafe { nix::libc::dup(nix::libc::STDIN_FILENO) };
1465				if fd < 0 {
1466					return Err(io::Error::last_os_error());
1467				}
1468				// SAFETY: `dup` returned a fresh descriptor owned by this file.
1469				unsafe {
1470					use std::os::fd::FromRawFd as _;
1471					File::from_raw_fd(fd)
1472				}
1473			} else {
1474				crate::tty::open(OpenOptions::new().read(true))?
1475			};
1476			Ok(crate::pump::Input::Bridged(input))
1477		}
1478		#[cfg(windows)]
1479		{
1480			Ok(crate::pump::Input::Bridged(std::fs::OpenOptions::new().read(true).open("CONIN$")?))
1481		}
1482	}
1483
1484	/// Restores every mode enabled by [`Terminal::enter`] and raw mode.
1485	///
1486	/// Keyboard enhancement, mouse reporting, and bracketed paste are disabled
1487	/// before input is drained, preventing late key-release or mouse-motion
1488	/// reports from reaching the parent shell. Calling this method more than
1489	/// once is harmless.
1490	pub fn leave(&mut self) -> io::Result<()> {
1491		if !self.active {
1492			return Ok(());
1493		}
1494		let mut first_error = None;
1495		// Restore fd 2 before any escape output or fallible teardown so panic
1496		// diagnostics and external programs immediately see the real terminal.
1497		record_error(self.stderr.restore(), &mut first_error);
1498		self
1499			.appearance_query_generation
1500			.fetch_add(1, Ordering::AcqRel);
1501
1502		if self.alt_screen {
1503			record_error(self.leave_alt(), &mut first_error);
1504		}
1505		record_error(terminal_write_all(&mut self.tty, self.keyboard.leave()), &mut first_error);
1506		let input_reports_off = compose_input_reports_off(self.owned_notification_modes);
1507		record_error(terminal_write_all(&mut self.tty, &input_reports_off), &mut first_error);
1508		record_error(self.tty.flush(), &mut first_error);
1509		record_error(self.stop_progress(false), &mut first_error);
1510		// The pump thread reads the same handle; stop it before the drain
1511		// below so teardown owns the descriptor exclusively.
1512		self.pump.stop();
1513		record_error(
1514			platform::drain(&self.tty, &self.platform, DRAIN_MAX, DRAIN_IDLE),
1515			&mut first_error,
1516		);
1517
1518		record_error(terminal_write_all(&mut self.tty, PROGRESS_CLEAR), &mut first_error);
1519		let tail = compose_leave(
1520			self.cursor_style.is_some(),
1521			self.xterm_scroll_restore_modes,
1522			self.ansi_mode_restore_modes,
1523		);
1524		record_error(terminal_write_all(&mut self.tty, &tail), &mut first_error);
1525		record_error(self.tty.flush(), &mut first_error);
1526		self.cursor_visible = Some(true);
1527		let raw_restored = match self.restore_raw() {
1528			Ok(()) => true,
1529			Err(error) => {
1530				record_error(Err(error), &mut first_error);
1531				false
1532			},
1533		};
1534
1535		if raw_restored {
1536			self.active = false;
1537			deactivate_emergency_state();
1538		}
1539		if let Some(error) = first_error {
1540			Err(error)
1541		} else {
1542			Ok(())
1543		}
1544	}
1545
1546	/// Immediately performs the blind, async-signal-safe terminal restore.
1547	///
1548	/// This is intended for crash paths. It bypasses buffered output and writes
1549	/// directly to the active controlling-terminal descriptor.
1550	pub fn emergency_restore() {
1551		emergency_restore_inner();
1552	}
1553
1554	/// Returns stderr bytes captured while this terminal owned the viewport.
1555	///
1556	/// The slice is finalized by [`Terminal::leave`]. While active it contains
1557	/// bytes drained by the event pump so far. Capture retains the newest 64
1558	/// KiB.
1559	pub fn captured_stderr(&self) -> &[u8] {
1560		self.stderr.captured()
1561	}
1562
1563	/// Returns the capabilities resolved for this terminal session.
1564	pub const fn caps(&self) -> TerminalCaps {
1565		self.caps
1566	}
1567
1568	/// Returns the active chord-to-key map.
1569	pub const fn keymap(&self) -> &Keymap {
1570		&self.keymap
1571	}
1572
1573	/// Edits the chord-to-key map; changes reach the event actor's decoder
1574	/// before the next decoded chord.
1575	pub fn edit_keymap(&mut self, edit: impl FnOnce(&mut Keymap)) {
1576		edit(&mut self.keymap);
1577		self.pump.set_keymap(self.keymap.clone());
1578	}
1579
1580	/// Returns the controlling terminal's current cell dimensions.
1581	pub fn size(&self) -> io::Result<Size> {
1582		platform::size(&self.tty, &self.platform)
1583	}
1584
1585	/// Waits for the next terminal event.
1586	///
1587	/// One async mailbox carries everything in arrival order: decoded input
1588	/// (real terminal bytes and `OMP_TUI_DEBUG` injections alike), debug
1589	/// queries, and closure. Resize rides a `watch` side channel and this
1590	/// biased select observes it before any queued input backlog; resolve
1591	/// the geometry with [`Terminal::take_resize`].
1592	///
1593	/// Terminal-owned debug queries (`text`, `info`, `resize`, `quit`) are
1594	/// answered here when dequeued — after every previously injected event —
1595	/// and never surface; a `quit` acknowledgement returns as `C-c` input.
1596	/// Retained-tree queries ([`crate::DebugOp::Frame`]/`Tree`/`Values`)
1597	/// surface as [`TerminalEvent::Debug`] for hosts that can answer them.
1598	///
1599	/// Terminal response events are returned like any input; forward them
1600	/// to [`Terminal::handle_input_event`] so appearance, geometry, and
1601	/// pixel-size state stay current.
1602	///
1603	/// Cancel-safe: events stay queued until returned.
1604	///
1605	/// # Errors
1606	///
1607	/// Fails once the terminal input closed.
1608	pub async fn next(&mut self) -> io::Result<crate::pump::TerminalEvent> {
1609		use crate::pump::TerminalEvent;
1610		self.stderr.drain();
1611		loop {
1612			tokio::select! {
1613				biased;
1614				changed = self.resize_watch.changed(), if self.resize_live => {
1615					match changed {
1616						Ok(()) => {
1617							self.resize_ready = true;
1618							return Ok(TerminalEvent::Resize);
1619						},
1620						// The actor is gone; the mailbox below reports why.
1621						Err(_) => self.resize_live = false,
1622					}
1623				},
1624				event = self.events.recv_async() => {
1625					match event {
1626						Ok(TerminalEvent::Resize) => {
1627							self.resize_ready = true;
1628							return Ok(TerminalEvent::Resize);
1629						},
1630						Ok(TerminalEvent::Closed) | Err(_) => {
1631							return Err(io::Error::new(
1632								io::ErrorKind::UnexpectedEof,
1633								"terminal input closed",
1634							));
1635						},
1636						Ok(TerminalEvent::Debug(query)) => {
1637							if query.op == crate::pump::DebugOp::Quit {
1638								crate::debug::respond_debug_query(
1639									query.id,
1640									crate::debug::terminal_response(query.op)
1641										.expect("quit is terminal-owned"),
1642								);
1643								return Ok(TerminalEvent::Input(InputEvent::Key(
1644									crate::Key::Ctrl('c'),
1645								)));
1646							}
1647							match crate::debug::terminal_response(query.op) {
1648								Some(response) => {
1649									crate::debug::respond_debug_query(query.id, response);
1650								},
1651								None => return Ok(TerminalEvent::Debug(query)),
1652							}
1653						},
1654						Ok(event) => return Ok(event),
1655					}
1656				},
1657			}
1658		}
1659	}
1660
1661	/// Takes the latest resize notification and returns its authoritative size.
1662	///
1663	/// SIGWINCH and DEC 2048 in-band geometry share this channel. A resize is
1664	/// reported once; operating-system geometry wins when it is available.
1665	pub fn take_resize(&mut self) -> io::Result<Option<Size>> {
1666		if !self.resize_ready && !self.size_changed() {
1667			return Ok(None);
1668		}
1669		self.resize_ready = false;
1670		match self.size() {
1671			Ok(size) => Ok(Some(size)),
1672			Err(error) => self.in_band_size.map(Some).ok_or(error),
1673		}
1674	}
1675
1676	/// Applies the latest DEC 2048 cell-pixel geometry to a renderer.
1677	pub fn sync_renderer<W: io::Write>(&self, renderer: &mut Renderer<W>) -> io::Result<()> {
1678		if let Some((width, height)) = self.cell_pixel_size {
1679			renderer.set_cell_pixel_size(width, height)?;
1680		}
1681		Ok(())
1682	}
1683
1684	/// Returns the latest terminal-reported cell dimensions in pixels.
1685	pub const fn cell_pixel_size(&self) -> Option<(u16, u16)> {
1686		self.cell_pixel_size
1687	}
1688
1689	/// Consumes a SIGWINCH-backed resize notification.
1690	///
1691	/// Multiplexers often deliver a burst of intermediate sizes; there this
1692	/// method returns `true` only after the observed generation has remained
1693	/// unchanged for 50 ms. Callers should continue polling while it returns
1694	/// `false` after a resize signal.
1695	pub fn size_changed(&mut self) -> bool {
1696		let generation = RESIZE_GENERATION.load(Ordering::Acquire);
1697		if !self.inside_multiplexer {
1698			if generation == self.seen_resize {
1699				return false;
1700			}
1701			self.seen_resize = generation;
1702			self.cursor_visible = None;
1703			return true;
1704		}
1705
1706		let now = Instant::now();
1707		if generation != self.seen_resize {
1708			match self.pending_resize {
1709				Some((pending, since)) if pending == generation => {
1710					if now.duration_since(since) >= RESIZE_DEBOUNCE {
1711						self.seen_resize = generation;
1712						self.pending_resize = None;
1713						self.cursor_visible = None;
1714						return true;
1715					}
1716				},
1717				_ => self.pending_resize = Some((generation, now)),
1718			}
1719		}
1720		false
1721	}
1722
1723	/// Returns the most recently classified terminal background appearance.
1724	pub const fn appearance(&self) -> Option<Appearance> {
1725		self.appearance
1726	}
1727
1728	/// Returns the effective geometry from the latest in-band resize report.
1729	///
1730	/// The operating-system size replaces reported dimensions when they
1731	/// disagree.
1732	pub const fn in_band_size(&self) -> Option<Size> {
1733		self.in_band_size
1734	}
1735
1736	/// Registers a callback for dark/light appearance flips.
1737	///
1738	/// A callback registered after initial OSC 11 detection is immediately
1739	/// invoked with the current appearance.
1740	pub fn on_appearance_change(&mut self, mut callback: impl FnMut(Appearance) + Send + 'static) {
1741		if let Some(appearance) = self.appearance {
1742			callback(appearance);
1743		}
1744		self.appearance_callbacks.push(Box::new(callback));
1745	}
1746
1747	/// Applies a decoded terminal response to appearance and image geometry.
1748	///
1749	/// Returns `true` when the response was consumed by terminal state plumbing.
1750	pub fn handle_response<W: io::Write>(
1751		&mut self,
1752		response: &TerminalResponse,
1753		renderer: &mut Renderer<W>,
1754	) -> io::Result<bool> {
1755		let consumed = self.handle_response_state(response)?;
1756		self.sync_renderer(renderer)?;
1757		Ok(consumed)
1758	}
1759
1760	fn handle_response_state(&mut self, response: &TerminalResponse) -> io::Result<bool> {
1761		// OSC replies may carry an enhanced-paste (OSC 5522) conversation
1762		// step; everything else falls through to the copy-friendly match.
1763		if let TerminalResponse::Osc(payload) = response {
1764			return match self.paste_events.handle_osc(payload) {
1765				PasteProgress::NotMine => Ok(false),
1766				PasteProgress::Consumed => Ok(true),
1767				PasteProgress::Reply(reply) => {
1768					terminal_write_all(&mut self.tty, reply.as_bytes())?;
1769					self.tty.flush()?;
1770					Ok(true)
1771				},
1772				PasteProgress::Done(pasted) => {
1773					self.pending_paste = Some(pasted);
1774					Ok(true)
1775				},
1776			};
1777		}
1778		match *response {
1779			TerminalResponse::OscColor { index: 11, r, g, b } => {
1780				self.set_appearance(Appearance::from_rgb16(r, g, b));
1781				Ok(true)
1782			},
1783			TerminalResponse::AppearanceChanged(_) => {
1784				self.debounce_appearance_query()?;
1785				Ok(true)
1786			},
1787			TerminalResponse::InBandResize { rows, cols, x_px, y_px } => {
1788				if rows == 0 || cols == 0 || x_px == 0 || y_px == 0 {
1789					return Ok(true);
1790				}
1791				let cell_width = rounded_cell_pixels(x_px, cols);
1792				let cell_height = rounded_cell_pixels(y_px, rows);
1793				self.cell_pixel_size = Some((cell_width, cell_height));
1794				let reported = Size::new(cols, rows);
1795				self.in_band_size = Some(reconcile_in_band_geometry(reported, self.size().ok()));
1796				self.resize_ready = true;
1797				Ok(true)
1798			},
1799			_ => Ok(false),
1800		}
1801	}
1802
1803	/// Applies terminal-response events while leaving user input untouched.
1804	///
1805	/// Returns `true` only for a response consumed by
1806	/// [`Terminal::handle_response`].
1807	pub fn handle_input_event<W: io::Write>(
1808		&mut self,
1809		event: &InputEvent,
1810		renderer: &mut Renderer<W>,
1811	) -> io::Result<bool> {
1812		let InputEvent::Response(response) = event else {
1813			return Ok(false);
1814		};
1815		self.handle_response(response, renderer)
1816	}
1817
1818	/// Consumes a completed OSC 5522 enhanced-paste payload.
1819	///
1820	/// Terminals supporting DEC mode 5522 (see [`TerminalCaps::paste_events`])
1821	/// deliver terminal-level pastes as out-of-band clipboard offers instead
1822	/// of bracketed paste, which is how an *image* paste reaches the
1823	/// application. The offer conversation runs inside
1824	/// [`Terminal::handle_response`]; once it completes, the assembled
1825	/// [`Pasted`] payload waits here for the host — mirroring
1826	/// [`Terminal::take_resize`].
1827	pub const fn take_paste(&mut self) -> Option<Pasted> {
1828		self.pending_paste.take()
1829	}
1830
1831	/// Copies `text` to the system clipboard.
1832	///
1833	/// Writes OSC 52 to the terminal first (works over SSH and multiplexers
1834	/// that forward it), then spawns a detached best-effort native write via
1835	/// [`crate::paste::write_clipboard_text`] for local sessions whose
1836	/// terminal ignores OSC 52.
1837	pub fn copy_to_clipboard(&mut self, text: &str) -> io::Result<()> {
1838		let encoded = base64::encode(text.as_bytes()).into_string();
1839		let mut sequence = String::with_capacity(esc!(osc, "52;c;").len() + encoded.len() + 1);
1840		sequence.push_str(esc!(osc, "52;c;"));
1841		sequence.push_str(&encoded);
1842		sequence.push('\x07');
1843		terminal_write_all(&mut self.tty, sequence.as_bytes())?;
1844		self.tty.flush()?;
1845		let text = text.to_owned();
1846		std::thread::Builder::new()
1847			.name("omp-tui-clipboard".into())
1848			.spawn(move || {
1849				let _ = crate::paste::write_clipboard_text(&text);
1850			})?;
1851		Ok(())
1852	}
1853
1854	fn set_appearance(&mut self, appearance: Appearance) {
1855		if self.appearance == Some(appearance) {
1856			return;
1857		}
1858		self.appearance = Some(appearance);
1859		for callback in &mut self.appearance_callbacks {
1860			callback(appearance);
1861		}
1862	}
1863
1864	fn debounce_appearance_query(&self) -> io::Result<()> {
1865		let generation = self
1866			.appearance_query_generation
1867			.fetch_add(1, Ordering::AcqRel)
1868			.wrapping_add(1);
1869		let observed = Arc::clone(&self.appearance_query_generation);
1870		let mut tty = self.tty.try_clone()?;
1871		thread::Builder::new()
1872			.name("omp-terminal-appearance".into())
1873			.spawn(move || {
1874				thread::sleep(APPEARANCE_DEBOUNCE);
1875				if observed.load(Ordering::Acquire) == generation && ACTIVE.load(Ordering::Acquire) {
1876					let _ = terminal_write_all(&mut tty, OSC11_QUERY).and_then(|()| tty.flush());
1877				}
1878			})?;
1879		Ok(())
1880	}
1881
1882	/// Enters the alternate screen and re-pushes screen-local Kitty keyboard
1883	/// flags. Repeated calls are deduplicated.
1884	pub fn enter_alt(&mut self) -> io::Result<()> {
1885		if self.alt_screen {
1886			return Ok(());
1887		}
1888		let mut batch = SmallVec::<u8, 64>::new();
1889		batch.extend_from_slice(esc!(alt_screen).as_bytes());
1890		if let KeyboardMode::Kitty(sequence) = self.keyboard {
1891			batch.extend_from_slice(sequence.as_bytes());
1892		}
1893		batch.extend_from_slice(esc!(!cursor_visible, !autowrap, !origin, margins_reset).as_bytes());
1894		if !self.mouse {
1895			// Fullscreen overlays get pointer interaction even when the inline
1896			// session leaves the mouse to native text selection.
1897			batch.extend_from_slice(MOUSE_TRACKING_ON);
1898		}
1899		terminal_write_all(&mut self.tty, &batch)?;
1900		self.tty.flush()?;
1901		self.alt_screen = true;
1902		self.alt_mouse = !self.mouse;
1903		ALT_SCREEN_ACTIVE.store(true, Ordering::Release);
1904		self.cursor_visible = Some(false);
1905		Ok(())
1906	}
1907
1908	/// Pops screen-local Kitty keyboard flags and leaves the alternate screen.
1909	/// Repeated calls are deduplicated.
1910	pub fn leave_alt(&mut self) -> io::Result<()> {
1911		if !self.alt_screen {
1912			return Ok(());
1913		}
1914		let mut batch = SmallVec::<u8, 48>::new();
1915		if std::mem::take(&mut self.alt_mouse) {
1916			batch.extend_from_slice(MOUSE_TRACKING_OFF);
1917		}
1918		if matches!(self.keyboard, KeyboardMode::Kitty(_)) {
1919			batch.extend_from_slice(esc!(kitty_keyboard_pop).as_bytes());
1920		}
1921		batch.extend_from_slice(esc!(!alt_screen).as_bytes());
1922		terminal_write_all(&mut self.tty, &batch)?;
1923		self.tty.flush()?;
1924		self.alt_screen = false;
1925		ALT_SCREEN_ACTIVE.store(false, Ordering::Release);
1926		self.cursor_visible = None;
1927		Ok(())
1928	}
1929
1930	/// Runs an operation while the alternate screen is active, restoring the
1931	/// main screen even when the operation returns an error.
1932	pub fn with_alt_screen<T>(
1933		&mut self,
1934		operation: impl FnOnce(&mut Self) -> io::Result<T>,
1935	) -> io::Result<T> {
1936		self.enter_alt()?;
1937		let result = operation(self);
1938		let leave = self.leave_alt();
1939		match (result, leave) {
1940			(Err(error), _) => Err(error),
1941			(Ok(_), Err(error)) => Err(error),
1942			(Ok(value), Ok(())) => Ok(value),
1943		}
1944	}
1945
1946	/// Flips alternate-screen bookkeeping on and returns the entry sequence —
1947	/// buffer switch, screen-local Kitty flag push, and, for an
1948	/// [`AltScreenUse::Interactive`] hold in an inline-mouse-off session,
1949	/// mouse tracking — for the caller to embed at the head of its next
1950	/// synchronized paint, keeping the switch atomic with the first frame
1951	/// drawn there. `None` when the alternate screen is already active.
1952	///
1953	/// [`Renderer::preview`](crate::Renderer::preview) and
1954	/// [`Renderer::preview_overlaid`](crate::Renderer::preview_overlaid)
1955	/// accept the sequence as their leading sequence. A passive
1956	/// [`AltScreenUse::Resize`] borrow never touches mouse modes: motion
1957	/// reports would flood input mid-drag. Teardown and emergency restore
1958	/// treat the alternate screen as active immediately, so the sequence
1959	/// must reach the terminal promptly.
1960	pub fn stage_alt_enter(&mut self, purpose: AltScreenUse) -> Option<Str> {
1961		if self.alt_screen {
1962			// Ownership transfer on the active screen: upgrading a passive
1963			// borrow to an interactive hold — an overlay opening mid-drag —
1964			// enables the mouse capture the hold contract promises.
1965			if purpose == AltScreenUse::Interactive && !self.alt_mouse && !self.mouse {
1966				self.alt_mouse = true;
1967				return Some(Str::new_static(esc!(mouse_vt200, mouse_any_event, mouse_sgr)));
1968			}
1969			return None;
1970		}
1971		self.alt_screen = true;
1972		ALT_SCREEN_ACTIVE.store(true, Ordering::Release);
1973		self.cursor_visible = None;
1974		self.alt_mouse = purpose == AltScreenUse::Interactive && !self.mouse;
1975		let tracking = if self.alt_mouse {
1976			esc!(mouse_vt200, mouse_any_event, mouse_sgr)
1977		} else {
1978			""
1979		};
1980		Some(match self.keyboard {
1981			KeyboardMode::Kitty(push) => fmts!("{}{}{}", esc!(alt_screen), push, tracking),
1982			KeyboardMode::ModifyOtherKeys => fmts!("{}{}", esc!(alt_screen), tracking),
1983		})
1984	}
1985
1986	/// Counterpart of [`Terminal::stage_alt_enter`]: flips bookkeeping off
1987	/// and returns the exit sequence — mouse tracking off when this alt
1988	/// ownership enabled it, Kitty flag pop, buffer switch — so leaving the
1989	/// alternate screen and repainting the main screen land in one
1990	/// synchronized update (see
1991	/// [`Renderer::rebuild`](crate::Renderer::rebuild)). `None` when already on
1992	/// the main screen.
1993	pub fn stage_alt_leave(&mut self) -> Option<&'static str> {
1994		if !self.alt_screen {
1995			return None;
1996		}
1997		self.alt_screen = false;
1998		ALT_SCREEN_ACTIVE.store(false, Ordering::Release);
1999		self.cursor_visible = None;
2000		let mouse = std::mem::take(&mut self.alt_mouse);
2001		Some(match (self.keyboard, mouse) {
2002			(KeyboardMode::Kitty(_), true) => {
2003				esc!(!mouse_sgr, !mouse_any_event, !mouse_vt200, kitty_keyboard_pop, !alt_screen)
2004			},
2005			(KeyboardMode::Kitty(_), false) => esc!(kitty_keyboard_pop, !alt_screen),
2006			(KeyboardMode::ModifyOtherKeys, true) => {
2007				esc!(!mouse_sgr, !mouse_any_event, !mouse_vt200, !alt_screen)
2008			},
2009			(KeyboardMode::ModifyOtherKeys, false) => esc!(!alt_screen),
2010		})
2011	}
2012
2013	/// Hides the cursor unless its tracked state is already hidden.
2014	pub fn hide_cursor(&mut self) -> io::Result<()> {
2015		if self.cursor_visible == Some(false) {
2016			return Ok(());
2017		}
2018		terminal_write_all(&mut self.tty, esc!(!cursor_visible).as_bytes())?;
2019		self.tty.flush()?;
2020		self.cursor_visible = Some(false);
2021		Ok(())
2022	}
2023
2024	/// Shows the cursor unless its tracked state is already visible.
2025	pub fn show_cursor(&mut self) -> io::Result<()> {
2026		if self.cursor_visible == Some(true) {
2027			return Ok(());
2028		}
2029		terminal_write_all(&mut self.tty, esc!(cursor_visible).as_bytes())?;
2030		self.tty.flush()?;
2031		self.cursor_visible = Some(true);
2032		Ok(())
2033	}
2034
2035	/// Sets both the terminal window title and icon name with OSC 0.
2036	///
2037	/// Control characters are removed so untrusted text cannot terminate the OSC
2038	/// or inject another terminal command. Entry pushes the previous title with
2039	/// XTGETTITLE's title-stack operation and teardown pops it; terminals
2040	/// without a title stack safely ignore those operations.
2041	pub fn set_title(&mut self, title: &str) -> io::Result<()> {
2042		let sequence = compose_title(title);
2043		terminal_write_all(&mut self.tty, &sequence)?;
2044		self.tty.flush()
2045	}
2046
2047	/// Updates the host's OSC 9;4 progress indicator.
2048	///
2049	/// Percentages are clamped to `0..=100`. Every non-clear state is refreshed
2050	/// once per second for terminals that expire stale indicators.
2051	pub fn set_progress(&mut self, progress: Progress) -> io::Result<()> {
2052		if progress == Progress::Clear {
2053			return self.stop_progress(true);
2054		}
2055		let state = progress_state(progress);
2056		if let Some(worker) = &self.progress {
2057			worker.state.store(state, Ordering::Release);
2058			let sequence = compose_progress(state);
2059			terminal_write_all(&mut self.tty, &sequence)?;
2060			return self.tty.flush();
2061		}
2062		let sequence = compose_progress(state);
2063		terminal_write_all(&mut self.tty, &sequence)?;
2064		self.tty.flush()?;
2065
2066		let state = Arc::new(AtomicU16::new(state));
2067		let worker_state = Arc::clone(&state);
2068		let mut tty = self.tty.try_clone()?;
2069		let worker = thread::Builder::new()
2070			.name("omp-terminal-progress".into())
2071			.spawn(move || {
2072				loop {
2073					thread::park_timeout(PROGRESS_KEEPALIVE);
2074					let current = worker_state.load(Ordering::Acquire);
2075					if current == 0 || !ACTIVE.load(Ordering::Acquire) {
2076						break;
2077					}
2078					let sequence = compose_progress(current);
2079					let _ = terminal_write_all(&mut tty, &sequence).and_then(|()| tty.flush());
2080				}
2081			})?;
2082		self.progress = Some(ProgressWorker { state, thread: worker });
2083		Ok(())
2084	}
2085
2086	fn stop_progress(&mut self, emit_clear: bool) -> io::Result<()> {
2087		if let Some(worker) = self.progress.take() {
2088			worker.state.store(0, Ordering::Release);
2089			worker.thread.thread().unpark();
2090			let _ = worker.thread.join();
2091		}
2092		if emit_clear {
2093			terminal_write_all(&mut self.tty, PROGRESS_CLEAR)?;
2094			self.tty.flush()?;
2095		}
2096		Ok(())
2097	}
2098
2099	fn restore_raw(&mut self) -> io::Result<()> {
2100		platform::restore_raw(&self.tty, &mut self.platform)
2101	}
2102}
2103fn rounded_cell_pixels(pixels: u16, cells: u16) -> u16 {
2104	let rounded = (u32::from(pixels) + u32::from(cells) / 2) / u32::from(cells);
2105	u16::try_from(rounded.max(1)).unwrap_or(u16::MAX)
2106}
2107
2108fn reconcile_in_band_geometry(reported: Size, os: Option<Size>) -> Size {
2109	match os {
2110		Some(os) if os != reported => os,
2111		_ => reported,
2112	}
2113}
2114
2115impl Drop for Terminal {
2116	fn drop(&mut self) {
2117		if self.leave().is_err() {
2118			emergency_restore_inner();
2119		}
2120	}
2121}
2122
2123const fn keyboard_mode(reported: Option<u8>) -> KeyboardMode {
2124	match reported {
2125		Some(flags) if flags & 0b0000_0001 != 0 => {
2126			if flags & 0b0000_0010 != 0 {
2127				KeyboardMode::Kitty(esc!(csi, ">3u"))
2128			} else {
2129				KeyboardMode::Kitty(esc!(csi, ">1u"))
2130			}
2131		},
2132		Some(flags) if flags & 0b0000_0010 != 0 => KeyboardMode::Kitty(esc!(csi, ">7u")),
2133		Some(_) => KeyboardMode::Kitty(esc!(csi, ">5u")),
2134		None => KeyboardMode::ModifyOtherKeys,
2135	}
2136}
2137
2138fn xterm_scroll_restore_modes(caps: TerminalCaps) -> u8 {
2139	(u8::from(caps.xterm_scroll_to_bottom_on_output) * XTERM_SCROLL_ON_OUTPUT)
2140		| (u8::from(caps.xterm_scroll_to_bottom_on_key_press) * XTERM_SCROLL_ON_KEY_PRESS)
2141}
2142
2143fn ansi_mode_restore_modes(probe: &ProbeResults) -> u8 {
2144	(u8::from(probe.insert_mode_set) * ANSI_INSERT_MODE)
2145		| (u8::from(probe.newline_mode_set) * ANSI_NEWLINE_MODE)
2146}
2147
2148fn owned_notification_modes(caps: TerminalCaps, probe: &ProbeResults) -> u8 {
2149	(u8::from(caps.appearance_notifications && !probe.appearance_notifications_set)
2150		* APPEARANCE_NOTIFICATIONS_MODE)
2151		| (u8::from(caps.in_band_resize && !probe.in_band_resize_set) * IN_BAND_RESIZE_MODE)
2152}
2153
2154fn compose_input_reports_off(owned_notification_modes: u8) -> SmallVec<u8, 96> {
2155	let mut batch = SmallVec::new();
2156	batch.extend_from_slice(INPUT_REPORTS_OFF);
2157	if owned_notification_modes & APPEARANCE_NOTIFICATIONS_MODE != 0 {
2158		batch.extend_from_slice(esc!(!appearance_notifications).as_bytes());
2159	}
2160	if owned_notification_modes & IN_BAND_RESIZE_MODE != 0 {
2161		batch.extend_from_slice(esc!(!in_band_resize).as_bytes());
2162	}
2163	batch
2164}
2165
2166fn compose_enter(
2167	keyboard: KeyboardMode,
2168	cursor_style: Option<CursorStyle>,
2169	xterm_scroll_restore_modes: u8,
2170	owned_notification_modes: u8,
2171	mouse: bool,
2172	paste_events: bool,
2173) -> SmallVec<u8, 160> {
2174	let mut batch = SmallVec::new();
2175	batch.extend_from_slice(TITLE_PUSH);
2176	batch.extend_from_slice(esc!(!insert_mode, !newline_mode).as_bytes());
2177	batch.extend_from_slice(esc!(!cursor_visible).as_bytes());
2178	if xterm_scroll_restore_modes & XTERM_SCROLL_ON_OUTPUT != 0 {
2179		batch.extend_from_slice(esc!(!scroll_on_output).as_bytes());
2180	}
2181	if xterm_scroll_restore_modes & XTERM_SCROLL_ON_KEY_PRESS != 0 {
2182		batch.extend_from_slice(esc!(!scroll_on_key_press).as_bytes());
2183	}
2184	if let Some(style) = cursor_style {
2185		batch.extend_from_slice(style.sequence());
2186	}
2187	batch.extend_from_slice(
2188		esc!(!autowrap, !origin, margins_reset, !app_cursor_keys, !app_keypad, bracketed_paste)
2189			.as_bytes(),
2190	);
2191	if owned_notification_modes & APPEARANCE_NOTIFICATIONS_MODE != 0 {
2192		batch.extend_from_slice(esc!(appearance_notifications).as_bytes());
2193	}
2194	if owned_notification_modes & IN_BAND_RESIZE_MODE != 0 {
2195		batch.extend_from_slice(esc!(in_band_resize).as_bytes());
2196	}
2197	if paste_events {
2198		batch.extend_from_slice(esc!(paste_events).as_bytes());
2199	}
2200	if mouse {
2201		batch.extend_from_slice(MOUSE_TRACKING_ON);
2202	}
2203	batch.extend_from_slice(keyboard.enter());
2204	batch
2205}
2206
2207fn compose_leave(
2208	reset_cursor_style: bool,
2209	xterm_scroll_restore_modes: u8,
2210	ansi_mode_restore_modes: u8,
2211) -> SmallVec<u8, 160> {
2212	let mut batch = SmallVec::new();
2213	batch.extend_from_slice(esc!(!sync_output).as_bytes());
2214	if xterm_scroll_restore_modes & XTERM_SCROLL_ON_OUTPUT != 0 {
2215		batch.extend_from_slice(esc!(scroll_on_output).as_bytes());
2216	}
2217	if xterm_scroll_restore_modes & XTERM_SCROLL_ON_KEY_PRESS != 0 {
2218		batch.extend_from_slice(esc!(scroll_on_key_press).as_bytes());
2219	}
2220	batch.extend_from_slice(
2221		esc!(
2222			autowrap,
2223			!app_cursor_keys,
2224			!app_keypad,
2225			style_reset,
2226			!origin,
2227			margins_reset,
2228			viewport_newline,
2229		)
2230		.as_bytes(),
2231	);
2232	if reset_cursor_style {
2233		batch.extend_from_slice(esc!(cursor_style_default).as_bytes());
2234	}
2235	batch.extend_from_slice(TITLE_POP);
2236	batch.extend_from_slice(esc!(cursor_visible).as_bytes());
2237	if ansi_mode_restore_modes & ANSI_INSERT_MODE != 0 {
2238		batch.extend_from_slice(esc!(insert_mode).as_bytes());
2239	}
2240	if ansi_mode_restore_modes & ANSI_NEWLINE_MODE != 0 {
2241		batch.extend_from_slice(esc!(newline_mode).as_bytes());
2242	}
2243	batch
2244}
2245
2246fn compose_title(title: &str) -> SmallVec<u8, 128> {
2247	let mut sequence = SmallVec::new();
2248	sequence.extend_from_slice(esc!(osc, "0;").as_bytes());
2249	for character in title.chars().filter(|character| !character.is_control()) {
2250		let mut bytes = [0; 4];
2251		sequence.extend_from_slice(character.encode_utf8(&mut bytes).as_bytes());
2252	}
2253	sequence.extend_from_slice(esc!(bel).as_bytes());
2254	sequence
2255}
2256
2257fn progress_state(progress: Progress) -> u16 {
2258	match progress {
2259		Progress::Clear => 0,
2260		Progress::Value(percent) => 0x100 | u16::from(percent.min(100)),
2261		Progress::Error(percent) => 0x200 | u16::from(percent.min(100)),
2262		Progress::Indeterminate => 0x300,
2263		Progress::Paused(percent) => 0x400 | u16::from(percent.min(100)),
2264	}
2265}
2266
2267fn compose_progress(state: u16) -> SmallVec<u8, 24> {
2268	let mut sequence = SmallVec::new();
2269	let status = state >> 8;
2270	if status == 0 {
2271		sequence.extend_from_slice(PROGRESS_CLEAR);
2272		return sequence;
2273	}
2274	sequence.extend_from_slice(esc!(osc, "9;4;").as_bytes());
2275	sequence.push(b'0' + u8::try_from(status).unwrap_or(0));
2276	if status != 3 {
2277		sequence.push(b';');
2278		push_decimal(&mut sequence, state & 0xff);
2279	}
2280	sequence.extend_from_slice(esc!(bel).as_bytes());
2281	sequence
2282}
2283
2284fn push_decimal(sequence: &mut SmallVec<u8, 24>, value: u16) {
2285	if value >= 100 {
2286		sequence.extend_from_slice(b"100");
2287	} else if value >= 10 {
2288		sequence.push(b'0' + u8::try_from(value / 10).unwrap_or(0));
2289		sequence.push(b'0' + u8::try_from(value % 10).unwrap_or(0));
2290	} else {
2291		sequence.push(b'0' + u8::try_from(value).unwrap_or(0));
2292	}
2293}
2294
2295fn record_error(result: io::Result<()>, first: &mut Option<io::Error>) {
2296	if let Err(error) = result
2297		&& first.is_none()
2298	{
2299		*first = Some(error);
2300	}
2301}
2302
2303fn ensure_restore_hooks() -> io::Result<()> {
2304	let result = &*HOOKS;
2305	if let Err(code) = result {
2306		return Err(io::Error::from_raw_os_error(*code));
2307	}
2308	PANIC_HOOK.call_once(|| {
2309		let previous = panic::take_hook();
2310		panic::set_hook(Box::new(move |information| {
2311			emergency_restore_inner();
2312			previous(information);
2313		}));
2314	});
2315	Ok(())
2316}
2317
2318fn emergency_restore_inner() {
2319	// This must precede every other crash-path operation: panic reporting uses
2320	// fd 2, and Unix restoration is only an atomic swap plus dup2/close.
2321	platform::emergency_restore_stderr();
2322	if !ACTIVE.swap(false, Ordering::AcqRel) {
2323		return;
2324	}
2325	let alt_screen = ALT_SCREEN_ACTIVE.swap(false, Ordering::AcqRel);
2326	let xterm_scroll_restore_modes = XTERM_SCROLL_RESTORE_MODES.swap(0, Ordering::AcqRel);
2327	let ansi_mode_restore_modes = ANSI_MODE_RESTORE_MODES.swap(0, Ordering::AcqRel);
2328	let owned_notification_modes = OWNED_NOTIFICATION_MODES.swap(0, Ordering::AcqRel);
2329	let payloads = [
2330		notification_modes_off_payload(owned_notification_modes),
2331		emergency_restore_payload(alt_screen, xterm_scroll_restore_modes),
2332		ansi_mode_restore_payload(ansi_mode_restore_modes),
2333	];
2334	platform::emergency_restore(payloads);
2335}
2336
2337const fn notification_modes_off_payload(modes: u8) -> &'static [u8] {
2338	match modes & (APPEARANCE_NOTIFICATIONS_MODE | IN_BAND_RESIZE_MODE) {
2339		0 => b"",
2340		APPEARANCE_NOTIFICATIONS_MODE => esc!(!appearance_notifications).as_bytes(),
2341		IN_BAND_RESIZE_MODE => esc!(!in_band_resize).as_bytes(),
2342		_ => esc!(!appearance_notifications, !in_band_resize).as_bytes(),
2343	}
2344}
2345
2346const fn ansi_mode_restore_payload(modes: u8) -> &'static [u8] {
2347	match modes & (ANSI_INSERT_MODE | ANSI_NEWLINE_MODE) {
2348		0 => b"",
2349		ANSI_INSERT_MODE => esc!(insert_mode).as_bytes(),
2350		ANSI_NEWLINE_MODE => esc!(newline_mode).as_bytes(),
2351		_ => esc!(insert_mode, newline_mode).as_bytes(),
2352	}
2353}
2354
2355const fn emergency_restore_payload(
2356	alt_screen: bool,
2357	xterm_scroll_restore_modes: u8,
2358) -> &'static [u8] {
2359	match (alt_screen, xterm_scroll_restore_modes & 0b0000_0011) {
2360		(false, 0) => emergency_restore!(main),
2361		(false, XTERM_SCROLL_ON_OUTPUT) => emergency_restore!(main, scroll_on_output),
2362		(false, XTERM_SCROLL_ON_KEY_PRESS) => emergency_restore!(main, scroll_on_key_press),
2363		(false, _) => emergency_restore!(main, scroll_on_output, scroll_on_key_press),
2364		(true, 0) => emergency_restore!(alt),
2365		(true, XTERM_SCROLL_ON_OUTPUT) => emergency_restore!(alt, scroll_on_output),
2366		(true, XTERM_SCROLL_ON_KEY_PRESS) => emergency_restore!(alt, scroll_on_key_press),
2367		(true, _) => emergency_restore!(alt, scroll_on_output, scroll_on_key_press),
2368	}
2369}
2370
2371fn deactivate_emergency_state() {
2372	ACTIVE.store(false, Ordering::Release);
2373	ALT_SCREEN_ACTIVE.store(false, Ordering::Release);
2374	XTERM_SCROLL_RESTORE_MODES.store(0, Ordering::Release);
2375	ANSI_MODE_RESTORE_MODES.store(0, Ordering::Release);
2376	OWNED_NOTIFICATION_MODES.store(0, Ordering::Release);
2377	platform::deactivate();
2378}
2379
2380#[cfg(all(test, unix))]
2381mod tests {
2382	use std::{
2383		fs::{File, OpenOptions},
2384		mem::MaybeUninit,
2385		os::fd::AsRawFd as _,
2386		process::{Command, Output},
2387		sync::{
2388			Arc,
2389			atomic::{AtomicU64, Ordering},
2390		},
2391		thread,
2392		time::{Duration, Instant},
2393	};
2394
2395	use nix::{
2396		libc,
2397		pty::{Winsize, openpty},
2398		sys::termios::{SetArg, cfmakeraw, tcgetattr, tcsetattr},
2399		unistd::{pipe, read, write},
2400	};
2401	use parking_lot::Mutex;
2402
2403	use super::{
2404		ACTIVE, ANSI_INSERT_MODE, ANSI_NEWLINE_MODE, APPEARANCE_NOTIFICATIONS_MODE, AltScreenUse,
2405		ConsoleCodepage, CursorStyle, IN_BAND_RESIZE_MODE, INPUT_REPORTS_OFF, KeyboardMode,
2406		MOUSE_TRACKING_ON, OSC11_QUERY, Progress, RESIZE_GENERATION, TITLE_POP, TITLE_PUSH, Terminal,
2407		UTF8_CODEPAGE, XTERM_SCROLL_ON_KEY_PRESS, XTERM_SCROLL_ON_OUTPUT, ansi_mode_restore_modes,
2408		ansi_mode_restore_payload, base64, compose_enter, compose_input_reports_off, compose_leave,
2409		compose_progress, compose_title, emergency_restore_payload, ensure_console_utf8,
2410		ensure_restore_hooks, keyboard_mode, notification_modes_off_payload,
2411		owned_notification_modes, platform, progress_state, reconcile_in_band_geometry,
2412		rounded_cell_pixels,
2413	};
2414	use crate::{
2415		Appearance, InputDecoder, InputEvent, Key, Mods, Mouse, MouseButton, MouseReport,
2416		ProbeResults, Renderer, Size, TerminalResponse, escape::esc, paste::Pasted,
2417	};
2418
2419	fn contains(haystack: &[u8], needle: &[u8]) -> bool {
2420		haystack
2421			.windows(needle.len())
2422			.any(|window| window == needle)
2423	}
2424
2425	/// One unframed OSC 5522 packet as the decoder would deliver it.
2426	fn osc(body: &str) -> InputEvent {
2427		InputEvent::Response(TerminalResponse::Osc(body.into()))
2428	}
2429
2430	#[tokio::test]
2431	async fn enhanced_paste_offer_replies_and_stages_the_payload() {
2432		let dir = std::env::temp_dir().join(format!("omp-tui-5522-{}", std::process::id()));
2433		std::fs::create_dir_all(&dir).expect("temp dir");
2434		let path = dir.join("tty");
2435		std::fs::write(&path, b"").expect("tty file");
2436		let tty = OpenOptions::new()
2437			.read(true)
2438			.write(true)
2439			.open(&path)
2440			.expect("tty opens");
2441		let mut terminal = test_terminal(tty);
2442		let mut renderer = Renderer::new(Vec::new());
2443		let mime = base64::encode(b"text/plain").into_string();
2444
2445		// Unrelated OSC replies stay application input.
2446		assert!(
2447			!terminal
2448				.handle_input_event(&osc("52;c;?"), &mut renderer)
2449				.expect("io ok")
2450		);
2451		// Offer: OK opens the listing, DATA names text/plain, DONE elicits
2452		// the read request instead of completing a paste.
2453		for body in [
2454			"5522;type=read:status=OK:pw=123".to_owned(),
2455			format!("5522;type=read:status=DATA:mime={mime}"),
2456			"5522;type=read:status=DONE".to_owned(),
2457		] {
2458			assert!(
2459				terminal
2460					.handle_input_event(&osc(&body), &mut renderer)
2461					.expect("io ok")
2462			);
2463		}
2464		assert!(terminal.take_paste().is_none(), "listing DONE only requests the payload");
2465		let request = std::fs::read_to_string(&path).expect("request written");
2466		assert!(request.contains("pw=123"), "read request echoes the grant: {request:?}");
2467		assert!(request.contains(&mime));
2468
2469		// Payload chunk + DONE completes the paste.
2470		let chunk = base64::encode(b"hello").into_string();
2471		for body in [
2472			format!("5522;type=read:status=DATA:mime={mime};{chunk}"),
2473			"5522;type=read:status=DONE".to_owned(),
2474		] {
2475			assert!(
2476				terminal
2477					.handle_input_event(&osc(&body), &mut renderer)
2478					.expect("io ok")
2479			);
2480		}
2481		assert_eq!(terminal.take_paste(), Some(Pasted::Text("hello".into())));
2482		std::fs::remove_dir_all(&dir).ok();
2483	}
2484
2485	#[derive(Default)]
2486	struct MockCodepage {
2487		current: u32,
2488		sets:    Vec<u32>,
2489	}
2490
2491	impl ConsoleCodepage for MockCodepage {
2492		fn output_codepage(&mut self) -> u32 {
2493			self.current
2494		}
2495
2496		fn set_output_codepage(&mut self, codepage: u32) -> bool {
2497			self.sets.push(codepage);
2498			self.current = codepage;
2499			true
2500		}
2501	}
2502
2503	#[test]
2504	fn console_codepage_guard_only_reasserts_utf8_after_a_flip() {
2505		let mut utf8 = MockCodepage { current: UTF8_CODEPAGE, sets: Vec::new() };
2506		ensure_console_utf8(&mut utf8);
2507		assert_eq!(utf8.sets, [] as [u32; 0]);
2508
2509		let mut detached = MockCodepage::default();
2510		ensure_console_utf8(&mut detached);
2511		assert_eq!(detached.sets, [] as [u32; 0]);
2512
2513		let mut legacy = MockCodepage { current: 437, sets: Vec::new() };
2514		ensure_console_utf8(&mut legacy);
2515		assert_eq!(legacy.sets, [UTF8_CODEPAGE]);
2516		ensure_console_utf8(&mut legacy);
2517		assert_eq!(legacy.sets, [UTF8_CODEPAGE]);
2518	}
2519
2520	#[test]
2521	fn enter_batch_selects_reported_kitty_flags_or_xterm_fallback() {
2522		let cases = [
2523			(Some(0), esc!(csi, ">5u").as_bytes()),
2524			(Some(1), esc!(csi, ">1u").as_bytes()),
2525			(Some(2), esc!(csi, ">7u").as_bytes()),
2526			(Some(3), esc!(csi, ">3u").as_bytes()),
2527			(None, esc!(modify_other_keys).as_bytes()),
2528		];
2529		// Inline sessions leave the mouse alone so native selection works;
2530		// opting in appends the tracking set before the keyboard mode.
2531		const PREFIX: &[u8] = esc!(
2532			title_push,
2533			!insert_mode,
2534			!newline_mode,
2535			!cursor_visible,
2536			!autowrap,
2537			!origin,
2538			margins_reset,
2539			!app_cursor_keys,
2540			!app_keypad,
2541			bracketed_paste,
2542		)
2543		.as_bytes();
2544		const PREFIX_MOUSE: &[u8] = esc!(
2545			title_push,
2546			!insert_mode,
2547			!newline_mode,
2548			!cursor_visible,
2549			!autowrap,
2550			!origin,
2551			margins_reset,
2552			!app_cursor_keys,
2553			!app_keypad,
2554			bracketed_paste,
2555			mouse_vt200,
2556			mouse_any_event,
2557			mouse_sgr,
2558		)
2559		.as_bytes();
2560		for (reported, keyboard) in cases {
2561			let batch = compose_enter(keyboard_mode(reported), None, 0, 0, false, false);
2562			assert_eq!(batch.as_slice(), [PREFIX, keyboard].concat());
2563			let batch = compose_enter(keyboard_mode(reported), None, 0, 0, true, false);
2564			assert_eq!(batch.as_slice(), [PREFIX_MOUSE, keyboard].concat());
2565		}
2566	}
2567
2568	#[test]
2569	fn inherited_modes_drive_entry_ownership_and_restoration() {
2570		let mut caps = crate::detect();
2571		caps.appearance_notifications = true;
2572		caps.in_band_resize = true;
2573		let probe = ProbeResults {
2574			insert_mode_set: true,
2575			newline_mode_set: true,
2576			appearance_notifications_set: true,
2577			..ProbeResults::default()
2578		};
2579		let ansi_modes = ansi_mode_restore_modes(&probe);
2580		let notification_modes = owned_notification_modes(caps, &probe);
2581		assert_eq!(ansi_modes, ANSI_INSERT_MODE | ANSI_NEWLINE_MODE);
2582		assert_eq!(notification_modes, IN_BAND_RESIZE_MODE);
2583
2584		let keyboard = KeyboardMode::Kitty(esc!(csi, ">5u"));
2585		let enter = compose_enter(keyboard, None, 0, notification_modes, false, false);
2586		assert!(contains(&enter, esc!(!insert_mode, !newline_mode).as_bytes()));
2587		assert!(!contains(&enter, esc!(appearance_notifications).as_bytes()));
2588		assert!(contains(&enter, esc!(in_band_resize).as_bytes()));
2589		let leave = compose_leave(false, 0, ansi_modes);
2590		assert!(
2591			leave.ends_with(esc!(title_pop, cursor_visible, insert_mode, newline_mode).as_bytes())
2592		);
2593	}
2594
2595	#[test]
2596	fn teardown_disables_owned_input_reports_before_drain_and_raw_restore() {
2597		let keyboard = KeyboardMode::Kitty(esc!(csi, ">5u")).leave();
2598		assert_eq!(keyboard, esc!(kitty_keyboard_pop).as_bytes());
2599		assert_eq!(compose_input_reports_off(0).as_slice(), INPUT_REPORTS_OFF);
2600		let reports_off =
2601			compose_input_reports_off(APPEARANCE_NOTIFICATIONS_MODE | IN_BAND_RESIZE_MODE);
2602		assert_eq!(
2603			reports_off.as_slice(),
2604			esc!(
2605				!mouse_sgr,
2606				!mouse_any_event,
2607				!mouse_button_event,
2608				!mouse_vt200,
2609				!bracketed_paste,
2610				!paste_events,
2611				!appearance_notifications,
2612				!in_band_resize,
2613			)
2614			.as_bytes()
2615		);
2616		let tail = compose_leave(true, 0, 0);
2617		assert!(tail.starts_with(esc!(!sync_output).as_bytes()));
2618		assert!(tail.ends_with(esc!(cursor_style_default, title_pop, cursor_visible).as_bytes()));
2619		// Terminal::leave flushes keyboard and report shutdown before draining,
2620		// then flushes this tail before restoring raw mode.
2621	}
2622
2623	#[test]
2624	fn emergency_payloads_reset_every_mode_the_tracking_set_enables() {
2625		// A panic or fatal signal in an opted-in app restores through the
2626		// blind payloads, so each `?Nh` in MOUSE_TRACKING_ON needs a matching
2627		// `?Nl` there — otherwise tracking survives the crash and native
2628		// selection stays broken in the parent shell.
2629		for alt_screen in [false, true] {
2630			for modes in [
2631				0,
2632				XTERM_SCROLL_ON_OUTPUT,
2633				XTERM_SCROLL_ON_KEY_PRESS,
2634				XTERM_SCROLL_ON_OUTPUT | XTERM_SCROLL_ON_KEY_PRESS,
2635			] {
2636				let payload = emergency_restore_payload(alt_screen, modes);
2637				for mode in String::from_utf8_lossy(MOUSE_TRACKING_ON).split('h') {
2638					if mode.is_empty() {
2639						continue;
2640					}
2641					let reset = format!("{mode}l");
2642					assert!(contains(payload, reset.as_bytes()), "missing {reset:?}");
2643				}
2644			}
2645		}
2646	}
2647
2648	#[test]
2649	fn xterm_scroll_to_bottom_modes_are_composed_in_order() {
2650		let keyboard = KeyboardMode::Kitty(esc!(csi, ">5u"));
2651		let enter_prefix = esc!(title_push, !insert_mode, !newline_mode, !cursor_visible).as_bytes();
2652		let enter_suffix = esc!(
2653			!autowrap,
2654			!origin,
2655			margins_reset,
2656			!app_cursor_keys,
2657			!app_keypad,
2658			bracketed_paste,
2659			mouse_vt200,
2660			mouse_any_event,
2661			mouse_sgr,
2662			csi,
2663			">5u",
2664		)
2665		.as_bytes();
2666		let leave_prefix = esc!(!sync_output).as_bytes();
2667		let leave_suffix = esc!(
2668			autowrap,
2669			!app_cursor_keys,
2670			!app_keypad,
2671			style_reset,
2672			!origin,
2673			margins_reset,
2674			viewport_newline,
2675			title_pop,
2676			cursor_visible,
2677		)
2678		.as_bytes();
2679		for (modes, enter_modes, leave_modes) in [
2680			(0, esc!().as_bytes(), esc!().as_bytes()),
2681			(
2682				XTERM_SCROLL_ON_OUTPUT,
2683				esc!(!scroll_on_output).as_bytes(),
2684				esc!(scroll_on_output).as_bytes(),
2685			),
2686			(
2687				XTERM_SCROLL_ON_KEY_PRESS,
2688				esc!(!scroll_on_key_press).as_bytes(),
2689				esc!(scroll_on_key_press).as_bytes(),
2690			),
2691			(
2692				XTERM_SCROLL_ON_OUTPUT | XTERM_SCROLL_ON_KEY_PRESS,
2693				esc!(!scroll_on_output, !scroll_on_key_press).as_bytes(),
2694				esc!(scroll_on_output, scroll_on_key_press).as_bytes(),
2695			),
2696		] {
2697			assert_eq!(
2698				compose_enter(keyboard, None, modes, 0, true, false).as_slice(),
2699				[enter_prefix, enter_modes, enter_suffix].concat()
2700			);
2701			assert_eq!(
2702				compose_leave(false, modes, 0).as_slice(),
2703				[leave_prefix, leave_modes, leave_suffix].concat()
2704			);
2705		}
2706	}
2707
2708	#[test]
2709	fn emergency_payload_splices_deltas_in_wire_order() {
2710		// Flat expectations, independent of the emergency_restore! splice
2711		// points: a delta landing in the wrong slot fails here even though
2712		// every atom is correct. Wire bytes are anchored raw in escape.rs.
2713		assert_eq!(
2714			emergency_restore_payload(false, 0),
2715			esc!(
2716				progress_clear,
2717				!sync_output,
2718				margins_reset,
2719				viewport_bottom,
2720				autowrap,
2721				!app_cursor_keys,
2722				!app_keypad,
2723				!bracketed_paste,
2724				!paste_events,
2725				kitty_keyboard_pop,
2726				!modify_other_keys,
2727				!mouse_sgr,
2728				!mouse_any_event,
2729				!mouse_vt200,
2730				title_pop,
2731				cursor_visible,
2732			)
2733			.as_bytes()
2734		);
2735		assert_eq!(
2736			emergency_restore_payload(true, XTERM_SCROLL_ON_OUTPUT),
2737			esc!(
2738				progress_clear,
2739				!sync_output,
2740				margins_reset,
2741				autowrap,
2742				!app_cursor_keys,
2743				!app_keypad,
2744				!bracketed_paste,
2745				scroll_on_output,
2746				!paste_events,
2747				kitty_keyboard_pop,
2748				!modify_other_keys,
2749				!mouse_sgr,
2750				!mouse_any_event,
2751				!mouse_vt200,
2752				!alt_screen,
2753				!app_cursor_keys,
2754				!app_keypad,
2755				kitty_keyboard_pop,
2756				title_pop,
2757				cursor_visible,
2758			)
2759			.as_bytes()
2760		);
2761	}
2762
2763	#[test]
2764	fn emergency_owned_mode_deltas_are_byte_exact() {
2765		for (modes, expected) in [
2766			(0, esc!().as_bytes()),
2767			(APPEARANCE_NOTIFICATIONS_MODE, esc!(!appearance_notifications).as_bytes()),
2768			(IN_BAND_RESIZE_MODE, esc!(!in_band_resize).as_bytes()),
2769			(
2770				APPEARANCE_NOTIFICATIONS_MODE | IN_BAND_RESIZE_MODE,
2771				esc!(!appearance_notifications, !in_band_resize).as_bytes(),
2772			),
2773		] {
2774			assert_eq!(notification_modes_off_payload(modes), expected);
2775		}
2776		for (modes, expected) in [
2777			(0, esc!().as_bytes()),
2778			(ANSI_INSERT_MODE, esc!(insert_mode).as_bytes()),
2779			(ANSI_NEWLINE_MODE, esc!(newline_mode).as_bytes()),
2780			(ANSI_INSERT_MODE | ANSI_NEWLINE_MODE, esc!(insert_mode, newline_mode).as_bytes()),
2781		] {
2782			assert_eq!(ansi_mode_restore_payload(modes), expected);
2783		}
2784	}
2785
2786	#[test]
2787	fn emergency_payload_selects_only_requested_scroll_mode_restores() {
2788		for alt_screen in [false, true] {
2789			for modes in [
2790				0,
2791				XTERM_SCROLL_ON_OUTPUT,
2792				XTERM_SCROLL_ON_KEY_PRESS,
2793				XTERM_SCROLL_ON_OUTPUT | XTERM_SCROLL_ON_KEY_PRESS,
2794			] {
2795				let payload = emergency_restore_payload(alt_screen, modes);
2796				assert_eq!(
2797					contains(payload, esc!(scroll_on_output).as_bytes()),
2798					modes & XTERM_SCROLL_ON_OUTPUT != 0
2799				);
2800				assert_eq!(
2801					contains(payload, esc!(scroll_on_key_press).as_bytes()),
2802					modes & XTERM_SCROLL_ON_KEY_PRESS != 0
2803				);
2804				assert_eq!(contains(payload, esc!(!alt_screen).as_bytes()), alt_screen);
2805			}
2806		}
2807	}
2808
2809	#[test]
2810	fn stderr_guard_captures_direct_writes_and_leave_restores_fd_2() {
2811		let output = run_stderr_guard_child("leave");
2812		assert!(output.status.success(), "{output:?}");
2813		let stderr = String::from_utf8_lossy(&output.stderr);
2814		assert!(stderr.contains("stderr-after-leave"), "{stderr:?}");
2815		assert!(!stderr.contains("stderr-under-guard"), "{stderr:?}");
2816	}
2817
2818	#[test]
2819	fn panic_hook_restores_stderr_before_printing() {
2820		let output = run_stderr_guard_child("panic");
2821		assert!(!output.status.success(), "{output:?}");
2822		let stderr = String::from_utf8_lossy(&output.stderr);
2823		assert!(stderr.contains("panic-after-emergency-restore"), "{stderr:?}");
2824	}
2825
2826	#[test]
2827	fn stderr_guard_subprocess() {
2828		let runtime = tokio::runtime::Builder::new_current_thread()
2829			.enable_all()
2830			.build()
2831			.expect("test runtime builds");
2832		let _guard = runtime.enter();
2833		match std::env::var("OMP_TUI_STDERR_GUARD_CASE").as_deref() {
2834			Ok("leave") => {
2835				let before = stderr_stat();
2836				let mut terminal = test_terminal(
2837					OpenOptions::new()
2838						.read(true)
2839						.write(true)
2840						.open("/dev/null")
2841						.expect("/dev/null opens"),
2842				);
2843				terminal.stderr = platform::StderrGuard::new(true).expect("stderr capture engages");
2844				terminal.active = true;
2845				ACTIVE.store(true, Ordering::Release);
2846				raw_stderr_write(b"stderr-under-guard\n");
2847				terminal.leave().expect("terminal leaves");
2848				assert_eq!(terminal.captured_stderr(), b"stderr-under-guard\n");
2849				let after = stderr_stat();
2850				assert_eq!((after.st_dev, after.st_ino), (before.st_dev, before.st_ino));
2851				raw_stderr_write(b"stderr-after-leave\n");
2852			},
2853			Ok("panic") => {
2854				ensure_restore_hooks().expect("restore hooks install");
2855				let _guard = platform::StderrGuard::new(true).expect("stderr capture engages");
2856				ACTIVE.store(true, Ordering::Release);
2857				panic!("panic-after-emergency-restore");
2858			},
2859			_ => {},
2860		}
2861	}
2862
2863	fn run_stderr_guard_child(case: &str) -> Output {
2864		Command::new(std::env::current_exe().expect("current test executable"))
2865			.args(["--exact", "terminal::tests::stderr_guard_subprocess", "--nocapture"])
2866			.env("OMP_TUI_STDERR_GUARD_CASE", case)
2867			.output()
2868			.expect("stderr guard subprocess runs")
2869	}
2870
2871	fn stderr_stat() -> libc::stat {
2872		let mut stat = MaybeUninit::<libc::stat>::zeroed();
2873		// SAFETY: `stat` is writable and fstat initializes it on success.
2874		assert_eq!(unsafe { libc::fstat(libc::STDERR_FILENO, stat.as_mut_ptr()) }, 0);
2875		// SAFETY: fstat succeeded and initialized `stat`.
2876		unsafe { stat.assume_init() }
2877	}
2878
2879	fn raw_stderr_write(bytes: &[u8]) {
2880		assert_eq!(
2881			// SAFETY: `bytes` is readable for its stated length and stderr is open.
2882			unsafe { libc::write(libc::STDERR_FILENO, bytes.as_ptr().cast(), bytes.len()) },
2883			bytes.len() as isize
2884		);
2885	}
2886
2887	#[test]
2888	fn title_and_progress_sequences_are_sanitized_and_exact() {
2889		assert_eq!(
2890			compose_title(esc!("omp", osc, "2;bad", bel, " title")).as_slice(),
2891			esc!(osc, "0;omp]2;bad title", bel).as_bytes()
2892		);
2893		assert!(
2894			compose_enter(KeyboardMode::Kitty(esc!(csi, ">5u")), None, 0, 0, false, false)
2895				.starts_with(TITLE_PUSH)
2896		);
2897		assert!(contains(&compose_leave(false, 0, 0), TITLE_POP));
2898		assert_eq!(
2899			compose_progress(progress_state(Progress::Value(42))).as_slice(),
2900			esc!(osc, "9;4;1;42", bel).as_bytes()
2901		);
2902		assert_eq!(
2903			compose_progress(progress_state(Progress::Error(150))).as_slice(),
2904			esc!(osc, "9;4;2;100", bel).as_bytes()
2905		);
2906		assert_eq!(
2907			compose_progress(progress_state(Progress::Indeterminate)).as_slice(),
2908			esc!(osc, "9;4;3", bel).as_bytes()
2909		);
2910		assert_eq!(
2911			compose_progress(progress_state(Progress::Paused(7))).as_slice(),
2912			esc!(osc, "9;4;4;7", bel).as_bytes()
2913		);
2914		assert_eq!(
2915			compose_progress(progress_state(Progress::Clear)).as_slice(),
2916			esc!(progress_clear).as_bytes()
2917		);
2918	}
2919
2920	#[test]
2921	fn cursor_style_is_composed_after_cursor_hide() {
2922		let batch = compose_enter(
2923			KeyboardMode::Kitty(esc!(csi, ">5u")),
2924			Some(CursorStyle::BlinkingBar),
2925			0,
2926			0,
2927			false,
2928			false,
2929		);
2930		assert!(contains(&batch, esc!(!cursor_visible, cursor_style_blinking_bar).as_bytes()));
2931	}
2932
2933	#[test]
2934	fn enter_batch_enables_enhanced_paste_only_when_supported() {
2935		let keyboard = KeyboardMode::Kitty(esc!(csi, ">5u"));
2936		let without = compose_enter(keyboard, None, 0, 0, false, false);
2937		assert!(!contains(&without, esc!(paste_events).as_bytes()));
2938		let with = compose_enter(keyboard, None, 0, 0, false, true);
2939		// Mode 5522 rides directly after bracketed paste so a supporting
2940		// terminal switches paste delivery before any input can arrive.
2941		assert!(contains(&with, esc!(bracketed_paste, paste_events).as_bytes()));
2942	}
2943
2944	#[test]
2945	fn input_drain_stops_after_idle_window() {
2946		let (reader, writer) = pipe().expect("pipe opens");
2947		write(&writer, b"late release").expect("pipe accepts input");
2948		let started = Instant::now();
2949
2950		platform::drain_for_test(
2951			reader.as_raw_fd(),
2952			Duration::from_millis(300),
2953			Duration::from_millis(20),
2954		)
2955		.expect("drain succeeds");
2956		let elapsed = started.elapsed();
2957		assert!(elapsed >= Duration::from_millis(15));
2958		assert!(elapsed < Duration::from_millis(200));
2959	}
2960	#[test]
2961	fn resize_pipe_wakes_coalesces_and_preserves_input() {
2962		if std::env::var_os("OMP_TUI_RESIZE_PIPE_CHILD").is_none() {
2963			let output = Command::new(std::env::current_exe().expect("test executable resolves"))
2964				.args(["--exact", "terminal::tests::resize_pipe_wakes_coalesces_and_preserves_input"])
2965				.env("OMP_TUI_RESIZE_PIPE_CHILD", "1")
2966				.output()
2967				.expect("resize test child starts");
2968			assert!(output.status.success(), "{output:?}");
2969			return;
2970		}
2971		ensure_restore_hooks().expect("signal handlers install");
2972		platform::activate_resize_pipe().expect("resize pipe opens");
2973		let window = Winsize { ws_row: 24, ws_col: 80, ws_xpixel: 0, ws_ypixel: 0 };
2974		let pty = openpty(Some(&window), None).expect("PTY opens");
2975		let mut raw = tcgetattr(&pty.slave).expect("PTY attributes read");
2976		cfmakeraw(&mut raw);
2977		tcsetattr(&pty.slave, SetArg::TCSANOW, &raw).expect("PTY enters raw mode");
2978		tokio::runtime::Builder::new_current_thread()
2979			.enable_all()
2980			.build()
2981			.expect("test runtime builds")
2982			.block_on(async {
2983				let mut terminal = test_terminal(File::from(pty.slave));
2984				thread::spawn(|| {
2985					thread::sleep(Duration::from_millis(30));
2986					// SAFETY: delivering SIGWINCH to this process exercises the installed handler.
2987					unsafe {
2988						libc::raise(libc::SIGWINCH);
2989					}
2990				});
2991				let started = Instant::now();
2992				let event = tokio::time::timeout(Duration::from_secs(3), terminal.next())
2993					.await
2994					.expect("resize wakes the event loop")
2995					.expect("resize event arrives");
2996				assert_eq!(event, crate::pump::TerminalEvent::Resize);
2997				assert!(started.elapsed() < Duration::from_millis(500));
2998				assert!(terminal.resize_ready);
2999				assert_eq!(terminal.take_resize().expect("resize size reads"), Some(Size::new(80, 24)));
3000
3001				// SAFETY: delivering SIGWINCH to this process exercises the installed handler.
3002				unsafe {
3003					libc::raise(libc::SIGWINCH);
3004					libc::raise(libc::SIGWINCH);
3005				}
3006				let event = tokio::time::timeout(Duration::from_secs(3), terminal.next())
3007					.await
3008					.expect("burst wakes the event loop")
3009					.expect("burst resize arrives");
3010				assert_eq!(event, crate::pump::TerminalEvent::Resize);
3011				assert_eq!(
3012					terminal.take_resize().expect("coalesced resize reads"),
3013					Some(Size::new(80, 24))
3014				);
3015				// The watch coalesces the burst; at most resize echoes drain
3016				// before the mailbox goes quiet.
3017				while let Ok(event) =
3018					tokio::time::timeout(Duration::from_millis(30), terminal.next()).await
3019				{
3020					assert_eq!(
3021						event.expect("drained event decodes"),
3022						crate::pump::TerminalEvent::Resize,
3023						"only resize echoes remain queued"
3024					);
3025					let _ = terminal.take_resize();
3026				}
3027
3028				write(&pty.master, b"x").expect("PTY accepts key");
3029				let event = tokio::time::timeout(Duration::from_secs(1), terminal.next())
3030					.await
3031					.expect("key wakes the event loop")
3032					.expect("key decodes");
3033				assert_eq!(event, crate::pump::TerminalEvent::Input(InputEvent::Key(Key::Char('x'))));
3034				platform::deactivate();
3035			});
3036	}
3037
3038	/// Collects the next `count` input events, applying terminal responses
3039	/// to `terminal` state as a host's event loop would.
3040	async fn collect_inputs(
3041		terminal: &mut Terminal,
3042		renderer: &mut Renderer<Vec<u8>>,
3043		count: usize,
3044	) -> Vec<InputEvent> {
3045		let mut events = Vec::new();
3046		while events.len() < count {
3047			let event = tokio::time::timeout(Duration::from_secs(1), terminal.next())
3048				.await
3049				.expect("event arrives")
3050				.expect("event decodes");
3051			match event {
3052				crate::pump::TerminalEvent::Input(InputEvent::Response(response)) => {
3053					terminal
3054						.handle_response(&response, renderer)
3055						.expect("response applies");
3056				},
3057				crate::pump::TerminalEvent::Input(event) => events.push(event),
3058				crate::pump::TerminalEvent::Resize => {
3059					// Process-wide SIGWINCH tests can race this terminal.
3060					terminal.resize_ready = false;
3061				},
3062				other => panic!("unexpected event {other:?}"),
3063			}
3064		}
3065		events
3066	}
3067
3068	#[tokio::test]
3069	async fn probe_window_events_are_first_and_responses_surface() {
3070		let (reader, _writer) = pipe().expect("pipe opens");
3071		let mut terminal = test_terminal_seeded(
3072			File::from(reader),
3073			esc!(
3074				"k",
3075				csi,
3076				"<0;4;3M",
3077				csi,
3078				"200~pasted",
3079				csi,
3080				"201~",
3081				csi,
3082				"I",
3083				osc,
3084				"11;rgb:ffff/ffff/ffff",
3085				bel,
3086			)
3087			.as_bytes(),
3088		);
3089		let mut renderer = Renderer::new(Vec::new());
3090		let events = collect_inputs(&mut terminal, &mut renderer, 4).await;
3091		assert_eq!(events, [
3092			InputEvent::Key(Key::Char('k')),
3093			InputEvent::Mouse(MouseReport {
3094				kind:    Mouse::Click,
3095				col:     3,
3096				row:     2,
3097				button:  MouseButton::Left,
3098				mods:    Mods::default(),
3099				pressed: true,
3100			}),
3101			InputEvent::Paste("pasted".into()),
3102			InputEvent::Focus(true),
3103		]);
3104		// The trailing OSC 11 reply is the fifth queued event; apply it like
3105		// a host event loop would.
3106		let event = tokio::time::timeout(Duration::from_secs(1), terminal.next())
3107			.await
3108			.expect("response arrives")
3109			.expect("response decodes");
3110		let crate::pump::TerminalEvent::Input(InputEvent::Response(response)) = event else {
3111			panic!("expected the trailing terminal response, got {event:?}");
3112		};
3113		terminal
3114			.handle_response(&response, &mut renderer)
3115			.expect("response applies");
3116		assert_eq!(terminal.appearance(), Some(Appearance::Light));
3117	}
3118	#[tokio::test]
3119	async fn probe_window_partial_sequence_continues_in_live_pump() {
3120		let (reader, writer) = pipe().expect("pipe opens");
3121		let mut terminal = test_terminal_seeded(File::from(reader), esc!(csi).as_bytes());
3122		write(&writer, b"A").expect("pipe accepts sequence tail");
3123		let mut renderer = Renderer::new(Vec::new());
3124		let events = collect_inputs(&mut terminal, &mut renderer, 1).await;
3125		assert_eq!(events, [InputEvent::Key(Key::Up)]);
3126	}
3127
3128	#[tokio::test]
3129	async fn pump_joins_split_escape_sequence_into_one_key() {
3130		let (reader, writer) = pipe().expect("pipe opens");
3131		let mut terminal = test_terminal(File::from(reader));
3132		write(&writer, esc!(escape).as_bytes()).expect("pipe accepts escape");
3133		// Inside the decoder's partial-hold window the tail joins the held
3134		// escape into one decoded key.
3135		tokio::time::sleep(Duration::from_millis(20)).await;
3136		write(&writer, b"[A").expect("pipe accepts sequence tail");
3137		let mut renderer = Renderer::new(Vec::new());
3138		let events = collect_inputs(&mut terminal, &mut renderer, 1).await;
3139		assert_eq!(events, [InputEvent::Key(Key::Up)]);
3140	}
3141
3142	#[tokio::test]
3143	async fn pump_responses_surface_and_update_terminal_state() {
3144		let (reader, writer) = pipe().expect("pipe opens");
3145		let mut terminal = test_terminal(File::from(reader));
3146		write(
3147			&writer,
3148			esc!(osc, "11;rgb:ffff/ffff/ffff", bel, csi, "48;24;80;1600;800 t").as_bytes(),
3149		)
3150		.expect("pipe accepts terminal replies");
3151		write(&writer, b"x").expect("pipe accepts trailing key");
3152		let mut renderer = Renderer::new(Vec::new());
3153		// Both replies surface as `Input(Response)` and apply through
3154		// `handle_response` before the trailing key is collected.
3155		let events = collect_inputs(&mut terminal, &mut renderer, 1).await;
3156		assert_eq!(events, [InputEvent::Key(Key::Char('x'))]);
3157		assert_eq!(terminal.appearance(), Some(Appearance::Light));
3158		assert_eq!(terminal.cell_pixel_size(), Some((10, 67)));
3159		assert_eq!(terminal.take_resize().expect("resize is available"), Some(Size::new(80, 24)));
3160	}
3161
3162	#[tokio::test]
3163	async fn pump_timeout_tick_flushes_held_partial() {
3164		let (reader, writer) = pipe().expect("pipe opens");
3165		let mut terminal = test_terminal(File::from(reader));
3166		write(&writer, esc!(escape).as_bytes()).expect("pipe accepts escape");
3167		// The actor's own decoder deadline releases the held escape; no
3168		// host-side polling is involved.
3169		let mut renderer = Renderer::new(Vec::new());
3170		let events = collect_inputs(&mut terminal, &mut renderer, 1).await;
3171		assert_eq!(events, [InputEvent::Key(Key::Esc)]);
3172	}
3173	#[tokio::test]
3174	async fn appearance_callback_only_fires_on_a_classification_flip() {
3175		let (reader, writer) = pipe().expect("pipe opens");
3176		drop(reader);
3177		let mut terminal = test_terminal(File::from(writer));
3178		let observed = Arc::new(Mutex::new(None));
3179		let callback_observed = Arc::clone(&observed);
3180		terminal.on_appearance_change(move |appearance| *callback_observed.lock() = Some(appearance));
3181		let mut renderer = Renderer::new(Vec::new());
3182		terminal
3183			.handle_response(
3184				&TerminalResponse::OscColor {
3185					index: 11,
3186					r:     u16::MAX,
3187					g:     u16::MAX,
3188					b:     u16::MAX,
3189				},
3190				&mut renderer,
3191			)
3192			.unwrap();
3193		assert_eq!(terminal.appearance(), Some(Appearance::Light));
3194		assert_eq!(*observed.lock(), Some(Appearance::Light));
3195		*observed.lock() = None;
3196		terminal
3197			.handle_response(
3198				&TerminalResponse::OscColor { index: 11, r: 0xeeee, g: 0xeeee, b: 0xeeee },
3199				&mut renderer,
3200			)
3201			.unwrap();
3202		assert_eq!(*observed.lock(), None);
3203	}
3204
3205	#[tokio::test]
3206	async fn appearance_pushes_collapse_to_one_debounced_query() {
3207		let (reader, writer) = pipe().expect("pipe opens");
3208		let mut terminal = test_terminal(File::from(writer));
3209		let mut renderer = Renderer::new(Vec::new());
3210		ACTIVE.store(true, Ordering::Release);
3211		terminal
3212			.handle_response(&TerminalResponse::AppearanceChanged(1), &mut renderer)
3213			.unwrap();
3214		thread::sleep(Duration::from_millis(10));
3215		terminal
3216			.handle_response(&TerminalResponse::AppearanceChanged(2), &mut renderer)
3217			.unwrap();
3218		thread::sleep(Duration::from_millis(120));
3219		let mut bytes = [0; 64];
3220		let count = read(&reader, &mut bytes).expect("query is written");
3221		ACTIVE.store(false, Ordering::Release);
3222		assert_eq!(&bytes[..count], OSC11_QUERY);
3223	}
3224
3225	#[tokio::test]
3226	async fn in_band_resize_derives_pixels_and_os_geometry_wins() {
3227		assert_eq!(rounded_cell_pixels(1000, 120), 8);
3228		assert_eq!(rounded_cell_pixels(777, 80), 10);
3229		let reported = Size::new(120, 40);
3230		let os = Size::new(100, 30);
3231		assert_eq!(reconcile_in_band_geometry(reported, Some(os)), os);
3232		assert_eq!(reconcile_in_band_geometry(reported, Some(reported)), reported);
3233
3234		let (reader, writer) = pipe().expect("pipe opens");
3235		drop(reader);
3236		let mut terminal = test_terminal(File::from(writer));
3237		let mut renderer = Renderer::new(Vec::new());
3238		terminal
3239			.handle_response(
3240				&TerminalResponse::InBandResize { rows: 40, cols: 120, x_px: 1000, y_px: 800 },
3241				&mut renderer,
3242			)
3243			.unwrap();
3244		assert_eq!(terminal.in_band_size(), Some(reported));
3245	}
3246
3247	#[tokio::test]
3248	async fn staged_alt_sequences_split_interactive_and_resize_ownership() {
3249		let (reader, writer) = pipe().expect("pipe opens");
3250		drop(reader);
3251		let mut terminal = test_terminal(File::from(writer));
3252
3253		// An interactive hold captures the mouse for its lifetime.
3254		let enter = terminal
3255			.stage_alt_enter(AltScreenUse::Interactive)
3256			.expect("first entry stages");
3257		assert_eq!(
3258			enter.as_str(),
3259			esc!(alt_screen, csi, ">5u", mouse_vt200, mouse_any_event, mouse_sgr)
3260		);
3261		assert!(
3262			terminal
3263				.stage_alt_enter(AltScreenUse::Interactive)
3264				.is_none(),
3265			"entry while active is a no-op"
3266		);
3267		let leave = terminal.stage_alt_leave().expect("exit stages");
3268		assert_eq!(
3269			leave,
3270			esc!(!mouse_sgr, !mouse_any_event, !mouse_vt200, kitty_keyboard_pop, !alt_screen)
3271		);
3272		assert!(terminal.stage_alt_leave().is_none(), "exit on the main screen is a no-op");
3273
3274		// A resize borrow never touches mouse modes: motion reports would
3275		// flood input mid-drag, and the exit stays symmetric.
3276		let enter = terminal
3277			.stage_alt_enter(AltScreenUse::Resize)
3278			.expect("borrow stages");
3279		assert_eq!(enter.as_str(), esc!(alt_screen, csi, ">5u"));
3280		let leave = terminal.stage_alt_leave().expect("borrow exit stages");
3281		assert_eq!(leave, esc!(kitty_keyboard_pop, !alt_screen));
3282
3283		// An overlay opening mid-drag upgrades the live borrow in place:
3284		// mouse capture turns on without a buffer round-trip, and the
3285		// upgraded exit turns it back off.
3286		let _ = terminal
3287			.stage_alt_enter(AltScreenUse::Resize)
3288			.expect("borrow re-stages");
3289		assert!(
3290			terminal.stage_alt_enter(AltScreenUse::Resize).is_none(),
3291			"borrow while active is a no-op"
3292		);
3293		let upgrade = terminal
3294			.stage_alt_enter(AltScreenUse::Interactive)
3295			.expect("mid-drag hold upgrades in place");
3296		assert_eq!(upgrade.as_str(), esc!(mouse_vt200, mouse_any_event, mouse_sgr));
3297		assert!(
3298			terminal
3299				.stage_alt_enter(AltScreenUse::Interactive)
3300				.is_none(),
3301			"an upgraded hold is already interactive"
3302		);
3303		let leave = terminal.stage_alt_leave().expect("upgraded exit stages");
3304		assert_eq!(
3305			leave,
3306			esc!(!mouse_sgr, !mouse_any_event, !mouse_vt200, kitty_keyboard_pop, !alt_screen)
3307		);
3308
3309		// A session that owns the mouse inline never toggles tracking here.
3310		terminal.keyboard = KeyboardMode::ModifyOtherKeys;
3311		terminal.mouse = true;
3312		assert_eq!(
3313			terminal
3314				.stage_alt_enter(AltScreenUse::Interactive)
3315				.expect("re-entry stages")
3316				.as_str(),
3317			esc!(alt_screen)
3318		);
3319		assert_eq!(terminal.stage_alt_leave().expect("re-exit stages"), esc!(!alt_screen));
3320	}
3321
3322	/// Terminal over an arbitrary readable handle with a live event actor;
3323	/// call inside a tokio runtime.
3324	fn test_terminal(tty: File) -> Terminal {
3325		test_terminal_seeded(tty, b"")
3326	}
3327
3328	/// [`test_terminal`] with `preserved` bytes seeding the actor's decoder,
3329	/// mirroring capability-negotiation carry-over.
3330	fn test_terminal_seeded(tty: File, preserved: &[u8]) -> Terminal {
3331		let source = tty.try_clone().expect("test tty clones");
3332		let channels = match crate::pump::spawn(
3333			crate::pump::Input::Pollable(source),
3334			InputDecoder::new(),
3335			preserved,
3336			platform::resize_pipe_reader().ok(),
3337		) {
3338			Ok(channels) => channels,
3339			// `/dev/null` and friends are not readiness-pollable; bridge.
3340			Err(_) => crate::pump::spawn(
3341				crate::pump::Input::Bridged(tty.try_clone().expect("test tty clones")),
3342				InputDecoder::new(),
3343				preserved,
3344				platform::resize_pipe_reader().ok(),
3345			)
3346			.expect("bridged test actor spawns"),
3347		};
3348		Terminal {
3349			caps: crate::detect(),
3350			tty,
3351			platform: platform::state_for_test(),
3352			stderr: platform::StderrGuard::new(false).expect("disabled stderr guard"),
3353			keyboard: KeyboardMode::Kitty(esc!(csi, ">5u")),
3354			cursor_style: None,
3355			xterm_scroll_restore_modes: 0,
3356			ansi_mode_restore_modes: 0,
3357			owned_notification_modes: 0,
3358			mouse: false,
3359			cursor_visible: None,
3360			alt_screen: false,
3361			alt_mouse: false,
3362			active: false,
3363			inside_multiplexer: false,
3364			seen_resize: RESIZE_GENERATION.load(Ordering::Acquire),
3365			pending_resize: None,
3366			appearance: Some(Appearance::Dark),
3367			appearance_callbacks: Vec::new(),
3368			appearance_query_generation: Arc::new(AtomicU64::new(0)),
3369			in_band_size: None,
3370			keymap: crate::Keymap::default(),
3371			resize_ready: false,
3372			resize_live: true,
3373			events: channels.events,
3374			resize_watch: channels.resize,
3375			pump: channels.pump,
3376			cell_pixel_size: None,
3377			progress: None,
3378			paste_events: crate::paste::PasteEvents::default(),
3379			pending_paste: None,
3380		}
3381	}
3382}