Skip to main content

ftui_core/
terminal_session.rs

1#![forbid(unsafe_code)]
2
3//! Terminal session lifecycle guard.
4//!
5//! This module provides RAII-based terminal lifecycle management that ensures
6//! cleanup even on panic. It owns raw-mode entry/exit and tracks all terminal
7//! state changes.
8//!
9//! # Lifecycle Guarantees
10//!
11//! 1. **All terminal state changes are tracked** - Each mode (raw, alt-screen,
12//!    mouse, bracketed paste, focus events) has a corresponding flag.
13//!
14//! 2. **Drop restores previous state** - When the [`TerminalSession`] is
15//!    dropped, all enabled modes are disabled in reverse order.
16//!
17//! 3. **Panic safety** - Because cleanup is in [`Drop`], it runs during panic
18//!    unwinding (unless `panic = "abort"` is set).
19//!
20//! 4. **No leaked state on any exit path** - Whether by return, `?`, panic,
21//!    or `process::exit()` (excluding abort), terminal state is restored.
22//!
23//! # Backend Decision (ADR-003)
24//!
25//! This module uses Crossterm as the terminal backend. Key requirements:
26//! - Raw mode enter/exit must be reliable
27//! - Cleanup must happen on normal exit AND panic
28//! - Resize events must be delivered accurately
29//!
30//! See ADR-003 for the full backend decision rationale.
31//!
32//! # Escape Sequences Reference
33//!
34//! The following escape sequences are used (via Crossterm):
35//!
36//! | Feature | Enable | Disable |
37//! |---------|--------|---------|
38//! | Alternate screen | `CSI ? 1049 h` | `CSI ? 1049 l` |
39//! | Mouse (SGR) | `CSI ? 1000;1002;1006 h` | `CSI ? 1000;1002;1006 l` |
40//! | Bracketed paste | `CSI ? 2004 h` | `CSI ? 2004 l` |
41//! | Focus events | `CSI ? 1004 h` | `CSI ? 1004 l` |
42//! | Kitty keyboard | `CSI > 15 u` | `CSI < u` |
43//! | Show cursor | `CSI ? 25 h` | `CSI ? 25 l` |
44//! | Reset style | `CSI 0 m` | N/A |
45//!
46//! # Cleanup Order
47//!
48//! On drop, cleanup happens in reverse order of enabling:
49//! 1. Disable kitty keyboard (if enabled)
50//! 2. Disable focus events (if enabled)
51//! 3. Disable bracketed paste (if enabled)
52//! 4. Disable mouse capture (if enabled)
53//! 5. Show cursor (always)
54//! 6. Leave alternate screen (if enabled)
55//! 7. Exit raw mode (always)
56//! 8. Flush stdout
57//!
58//! # Usage
59//!
60//! ```no_run
61//! use ftui_core::terminal_session::{TerminalSession, SessionOptions};
62//!
63//! // Create a session with desired options
64//! let session = TerminalSession::new(SessionOptions {
65//!     alternate_screen: true,
66//!     mouse_capture: true,
67//!     ..Default::default()
68//! })?;
69//!
70//! // Terminal is now in raw mode with alt screen and mouse
71//! // ... do work ...
72//!
73//! // When `session` is dropped, terminal is restored
74//! # Ok::<(), std::io::Error>(())
75//! ```
76
77use std::env;
78use std::io::{self, Write};
79use std::sync::OnceLock;
80use std::time::Duration;
81
82use crate::event::Event;
83
84const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
85const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
86const SYNC_END: &[u8] = b"\x1b[?2026l";
87
88#[cfg(unix)]
89use signal_hook::consts::signal::{SIGINT, SIGTERM, SIGWINCH};
90#[cfg(unix)]
91use signal_hook::iterator::Signals;
92
93/// Terminal session configuration options.
94///
95/// These options control which terminal modes are enabled when a session
96/// starts. All options default to `false` for maximum portability.
97///
98/// # Example
99///
100/// ```
101/// use ftui_core::terminal_session::SessionOptions;
102///
103/// // Full-featured TUI
104/// let opts = SessionOptions {
105///     alternate_screen: true,
106///     mouse_capture: true,
107///     bracketed_paste: true,
108///     focus_events: true,
109///     ..Default::default()
110/// };
111///
112/// // Minimal inline mode
113/// let inline_opts = SessionOptions::default();
114/// ```
115#[derive(Debug, Clone, Default)]
116pub struct SessionOptions {
117    /// Enable alternate screen buffer (`CSI ? 1049 h`).
118    ///
119    /// When enabled, the terminal switches to a separate screen buffer,
120    /// preserving the original scrollback. On exit, the original screen
121    /// is restored.
122    ///
123    /// Use this for full-screen applications. For inline mode (preserving
124    /// scrollback), leave this `false`.
125    pub alternate_screen: bool,
126
127    /// Enable mouse capture with SGR encoding (`CSI ? 1000;1002;1006 h`).
128    ///
129    /// Enables:
130    /// - Normal mouse tracking (1000)
131    /// - Button event tracking (1002)
132    /// - SGR extended coordinates (1006) - supports coordinates > 223
133    pub mouse_capture: bool,
134
135    /// Enable bracketed paste mode (`CSI ? 2004 h`).
136    ///
137    /// When enabled, pasted text is wrapped in escape sequences:
138    /// - Start: `ESC [ 200 ~`
139    /// - End: `ESC [ 201 ~`
140    ///
141    /// This allows distinguishing pasted text from typed text.
142    pub bracketed_paste: bool,
143
144    /// Enable focus change events (`CSI ? 1004 h`).
145    ///
146    /// When enabled, the terminal sends events when focus is gained or lost:
147    /// - Focus in: `ESC [ I`
148    /// - Focus out: `ESC [ O`
149    pub focus_events: bool,
150
151    /// Enable Kitty keyboard protocol (pushes flags with `CSI > 15 u`).
152    ///
153    /// Uses the kitty protocol to report repeat/release events and disambiguate
154    /// keys. This is optional and only supported by select terminals.
155    pub kitty_keyboard: bool,
156}
157
158/// A terminal session that manages raw mode and cleanup.
159///
160/// This struct owns the terminal configuration and ensures cleanup on drop.
161/// It tracks all enabled modes and disables them in reverse order when dropped.
162///
163/// # Contract
164///
165/// - **Exclusive ownership**: Only one `TerminalSession` should exist at a time.
166///   Creating multiple sessions will cause undefined terminal behavior.
167///
168/// - **Raw mode entry**: Creating a session automatically enters raw mode.
169///   This disables line buffering and echo.
170///
171/// - **Cleanup guarantee**: When dropped (normally or via panic), all enabled
172///   modes are disabled and the terminal is restored to its previous state.
173///
174/// # State Tracking
175///
176/// Each optional mode has a corresponding `_enabled` flag. These flags are
177/// set when a mode is successfully enabled and cleared during cleanup.
178/// This ensures we only disable modes that were actually enabled.
179///
180/// # Example
181///
182/// ```no_run
183/// use ftui_core::terminal_session::{TerminalSession, SessionOptions};
184///
185/// fn run_app() -> std::io::Result<()> {
186///     let session = TerminalSession::new(SessionOptions {
187///         alternate_screen: true,
188///         mouse_capture: true,
189///         ..Default::default()
190///     })?;
191///
192///     // Application loop
193///     loop {
194///         if session.poll_event(std::time::Duration::from_millis(100))? {
195///             if let Some(event) = session.read_event()? {
196///                 // Handle event...
197///             }
198///         }
199///     }
200///     // Session cleaned up when dropped
201/// }
202/// ```
203#[derive(Debug)]
204pub struct TerminalSession {
205    options: SessionOptions,
206    /// Track what was enabled so we can disable on drop.
207    alternate_screen_enabled: bool,
208    mouse_enabled: bool,
209    bracketed_paste_enabled: bool,
210    focus_events_enabled: bool,
211    kitty_keyboard_enabled: bool,
212    #[cfg(unix)]
213    signal_guard: Option<SignalGuard>,
214}
215
216impl TerminalSession {
217    /// Enter raw mode and optionally enable additional features.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if raw mode cannot be enabled.
222    pub fn new(options: SessionOptions) -> io::Result<Self> {
223        install_panic_hook();
224
225        // Create signal guard before raw mode so that a failure here
226        // does not leave the terminal in raw mode (the struct would never
227        // be fully constructed, so Drop would not run).
228        #[cfg(unix)]
229        let signal_guard = Some(SignalGuard::new()?);
230
231        // Enter raw mode
232        crossterm::terminal::enable_raw_mode()?;
233        #[cfg(feature = "tracing")]
234        tracing::info!("terminal raw mode enabled");
235
236        let mut session = Self {
237            options: options.clone(),
238            alternate_screen_enabled: false,
239            mouse_enabled: false,
240            bracketed_paste_enabled: false,
241            focus_events_enabled: false,
242            kitty_keyboard_enabled: false,
243            #[cfg(unix)]
244            signal_guard,
245        };
246
247        // Enable optional features
248        let mut stdout = io::stdout();
249
250        if options.alternate_screen {
251            // Enter alternate screen and explicitly clear it.
252            // Some terminals (including WezTerm) may show stale content in the
253            // alt-screen buffer without an explicit clear. We also position the
254            // cursor at the top-left to ensure a known initial state.
255            crossterm::execute!(
256                stdout,
257                crossterm::terminal::EnterAlternateScreen,
258                crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
259                crossterm::cursor::MoveTo(0, 0)
260            )?;
261            session.alternate_screen_enabled = true;
262            #[cfg(feature = "tracing")]
263            tracing::info!("alternate screen enabled (with clear)");
264        }
265
266        if options.mouse_capture {
267            crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
268            session.mouse_enabled = true;
269            #[cfg(feature = "tracing")]
270            tracing::info!("mouse capture enabled");
271        }
272
273        if options.bracketed_paste {
274            crossterm::execute!(stdout, crossterm::event::EnableBracketedPaste)?;
275            session.bracketed_paste_enabled = true;
276            #[cfg(feature = "tracing")]
277            tracing::info!("bracketed paste enabled");
278        }
279
280        if options.focus_events {
281            crossterm::execute!(stdout, crossterm::event::EnableFocusChange)?;
282            session.focus_events_enabled = true;
283            #[cfg(feature = "tracing")]
284            tracing::info!("focus events enabled");
285        }
286
287        if options.kitty_keyboard {
288            Self::enable_kitty_keyboard(&mut stdout)?;
289            session.kitty_keyboard_enabled = true;
290            #[cfg(feature = "tracing")]
291            tracing::info!("kitty keyboard enabled");
292        }
293
294        Ok(session)
295    }
296
297    /// Create a minimal session (raw mode only).
298    pub fn minimal() -> io::Result<Self> {
299        Self::new(SessionOptions::default())
300    }
301
302    /// Get the current terminal size (columns, rows).
303    pub fn size(&self) -> io::Result<(u16, u16)> {
304        let (w, h) = crossterm::terminal::size()?;
305        if w > 1 && h > 1 {
306            return Ok((w, h));
307        }
308
309        // Some terminals briefly report 1x1 on startup; fall back to env vars when available.
310        if let Some((env_w, env_h)) = size_from_env() {
311            return Ok((env_w, env_h));
312        }
313
314        // Re-probe once after a short delay to catch terminals that report size late.
315        std::thread::sleep(Duration::from_millis(10));
316        let (w2, h2) = crossterm::terminal::size()?;
317        if w2 > 1 && h2 > 1 {
318            return Ok((w2, h2));
319        }
320
321        // Ensure minimum viable size to prevent downstream panics in buffer allocation
322        // and layout calculations. 2x2 is the absolute minimum for a functional TUI.
323        let final_w = w.max(2);
324        let final_h = h.max(2);
325        Ok((final_w, final_h))
326    }
327
328    /// Poll for an event with a timeout.
329    ///
330    /// Returns `Ok(true)` if an event is available, `Ok(false)` if timeout.
331    pub fn poll_event(&self, timeout: std::time::Duration) -> io::Result<bool> {
332        crossterm::event::poll(timeout)
333    }
334
335    /// Read the next event (blocking until available).
336    ///
337    /// Returns `Ok(None)` if the event cannot be represented by the
338    /// ftui canonical event types (e.g. unsupported key codes).
339    pub fn read_event(&self) -> io::Result<Option<Event>> {
340        let event = crossterm::event::read()?;
341        Ok(Event::from_crossterm(event))
342    }
343
344    /// Show the cursor.
345    pub fn show_cursor(&self) -> io::Result<()> {
346        crossterm::execute!(io::stdout(), crossterm::cursor::Show)
347    }
348
349    /// Hide the cursor.
350    pub fn hide_cursor(&self) -> io::Result<()> {
351        crossterm::execute!(io::stdout(), crossterm::cursor::Hide)
352    }
353
354    /// Get the session options.
355    pub fn options(&self) -> &SessionOptions {
356        &self.options
357    }
358
359    /// Cleanup helper (shared between drop and explicit cleanup).
360    fn cleanup(&mut self) {
361        #[cfg(unix)]
362        let _ = self.signal_guard.take();
363
364        let mut stdout = io::stdout();
365
366        // End synchronized output first to ensure terminal updates resume
367        let _ = stdout.write_all(SYNC_END);
368
369        // Disable features in reverse order of enabling
370        if self.kitty_keyboard_enabled {
371            let _ = Self::disable_kitty_keyboard(&mut stdout);
372            self.kitty_keyboard_enabled = false;
373            #[cfg(feature = "tracing")]
374            tracing::info!("kitty keyboard disabled");
375        }
376
377        if self.focus_events_enabled {
378            let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
379            self.focus_events_enabled = false;
380            #[cfg(feature = "tracing")]
381            tracing::info!("focus events disabled");
382        }
383
384        if self.bracketed_paste_enabled {
385            let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
386            self.bracketed_paste_enabled = false;
387            #[cfg(feature = "tracing")]
388            tracing::info!("bracketed paste disabled");
389        }
390
391        if self.mouse_enabled {
392            let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
393            self.mouse_enabled = false;
394            #[cfg(feature = "tracing")]
395            tracing::info!("mouse capture disabled");
396        }
397
398        // Always show cursor before leaving
399        let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
400
401        if self.alternate_screen_enabled {
402            let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
403            self.alternate_screen_enabled = false;
404            #[cfg(feature = "tracing")]
405            tracing::info!("alternate screen disabled");
406        }
407
408        // Exit raw mode last
409        let _ = crossterm::terminal::disable_raw_mode();
410        #[cfg(feature = "tracing")]
411        tracing::info!("terminal raw mode disabled");
412
413        // Flush to ensure cleanup bytes are sent
414        let _ = stdout.flush();
415    }
416
417    fn enable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
418        writer.write_all(KITTY_KEYBOARD_ENABLE)?;
419        writer.flush()
420    }
421
422    fn disable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
423        writer.write_all(KITTY_KEYBOARD_DISABLE)?;
424        writer.flush()
425    }
426}
427
428impl Drop for TerminalSession {
429    fn drop(&mut self) {
430        self.cleanup();
431    }
432}
433
434fn size_from_env() -> Option<(u16, u16)> {
435    let cols = env::var("COLUMNS").ok()?.parse::<u16>().ok()?;
436    let rows = env::var("LINES").ok()?.parse::<u16>().ok()?;
437    if cols > 1 && rows > 1 {
438        Some((cols, rows))
439    } else {
440        None
441    }
442}
443
444fn install_panic_hook() {
445    static HOOK: OnceLock<()> = OnceLock::new();
446    HOOK.get_or_init(|| {
447        let previous = std::panic::take_hook();
448        std::panic::set_hook(Box::new(move |info| {
449            best_effort_cleanup();
450            previous(info);
451        }));
452    });
453}
454
455/// Best-effort cleanup for termination paths that skip `Drop`.
456///
457/// Call this before `std::process::exit` to restore terminal state when
458/// unwinding won't run destructors.
459pub fn best_effort_cleanup_for_exit() {
460    best_effort_cleanup();
461}
462
463fn best_effort_cleanup() {
464    let mut stdout = io::stdout();
465
466    // End synchronized output first to ensure any buffered content (like panic messages)
467    // is flushed to the terminal.
468    let _ = stdout.write_all(SYNC_END);
469
470    let _ = TerminalSession::disable_kitty_keyboard(&mut stdout);
471    let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
472    let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
473    let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
474    let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
475    let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
476    let _ = crossterm::terminal::disable_raw_mode();
477    let _ = stdout.flush();
478}
479
480#[cfg(unix)]
481#[derive(Debug)]
482struct SignalGuard {
483    handle: signal_hook::iterator::Handle,
484    thread: Option<std::thread::JoinHandle<()>>,
485}
486
487#[cfg(unix)]
488impl SignalGuard {
489    fn new() -> io::Result<Self> {
490        let mut signals = Signals::new([SIGINT, SIGTERM, SIGWINCH]).map_err(io::Error::other)?;
491        let handle = signals.handle();
492        let thread = std::thread::spawn(move || {
493            for signal in signals.forever() {
494                match signal {
495                    SIGWINCH => {
496                        #[cfg(feature = "tracing")]
497                        tracing::debug!("SIGWINCH received");
498                    }
499                    SIGINT | SIGTERM => {
500                        #[cfg(feature = "tracing")]
501                        tracing::warn!("termination signal received, cleaning up");
502                        best_effort_cleanup();
503                        std::process::exit(128 + signal);
504                    }
505                    _ => {}
506                }
507            }
508        });
509        Ok(Self {
510            handle,
511            thread: Some(thread),
512        })
513    }
514}
515
516#[cfg(unix)]
517impl Drop for SignalGuard {
518    fn drop(&mut self) {
519        self.handle.close();
520        if let Some(thread) = self.thread.take() {
521            let _ = thread.join();
522        }
523    }
524}
525
526/// Spike validation notes (for ADR-003).
527///
528/// ## Crossterm Evaluation Results
529///
530/// ### Functionality (all verified)
531/// - ✅ raw mode: `enable_raw_mode()` / `disable_raw_mode()`
532/// - ✅ alternate screen: `EnterAlternateScreen` / `LeaveAlternateScreen`
533/// - ✅ cursor show/hide: `Show` / `Hide`
534/// - ✅ mouse mode (SGR): `EnableMouseCapture` / `DisableMouseCapture`
535/// - ✅ bracketed paste: `EnableBracketedPaste` / `DisableBracketedPaste`
536/// - ✅ focus events: `EnableFocusChange` / `DisableFocusChange`
537/// - ✅ resize events: `Event::Resize(cols, rows)`
538///
539/// ### Robustness
540/// - ✅ bounded-time reads via `poll()` with timeout
541/// - ✅ handles partial sequences (internal buffer management)
542/// - ⚠️ adversarial input: not fuzz-tested in this spike
543///
544/// ### Cleanup Discipline
545/// - ✅ Drop impl guarantees cleanup on normal exit
546/// - ✅ Drop impl guarantees cleanup on panic (via unwinding)
547/// - ✅ cursor shown before exit
548/// - ✅ raw mode disabled last
549///
550/// ### Platform Coverage
551/// - ✅ Linux: fully supported
552/// - ✅ macOS: fully supported
553/// - ⚠️ Windows: supported with some feature limitations (see ADR-004)
554///
555/// ## Decision
556/// **Crossterm is approved as the v1 terminal backend.**
557///
558/// Rationale: It provides all required functionality, handles cleanup via
559/// standard Rust drop semantics, and has broad platform support.
560///
561/// Limitations documented in ADR-004 (Windows scope).
562#[doc(hidden)]
563pub const _SPIKE_NOTES: () = ();
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    #[cfg(unix)]
569    use portable_pty::{CommandBuilder, PtySize};
570    #[cfg(unix)]
571    use std::io::{self, Read, Write};
572    #[cfg(unix)]
573    use std::sync::mpsc;
574    #[cfg(unix)]
575    use std::thread;
576    #[cfg(unix)]
577    use std::time::{Duration, Instant};
578
579    #[test]
580    fn session_options_default_is_minimal() {
581        let opts = SessionOptions::default();
582        assert!(!opts.alternate_screen);
583        assert!(!opts.mouse_capture);
584        assert!(!opts.bracketed_paste);
585        assert!(!opts.focus_events);
586        assert!(!opts.kitty_keyboard);
587    }
588
589    #[test]
590    fn session_options_clone() {
591        let opts = SessionOptions {
592            alternate_screen: true,
593            mouse_capture: true,
594            bracketed_paste: false,
595            focus_events: true,
596            kitty_keyboard: false,
597        };
598        let cloned = opts.clone();
599        assert_eq!(cloned.alternate_screen, opts.alternate_screen);
600        assert_eq!(cloned.mouse_capture, opts.mouse_capture);
601        assert_eq!(cloned.bracketed_paste, opts.bracketed_paste);
602        assert_eq!(cloned.focus_events, opts.focus_events);
603        assert_eq!(cloned.kitty_keyboard, opts.kitty_keyboard);
604    }
605
606    #[test]
607    fn session_options_debug() {
608        let opts = SessionOptions::default();
609        let debug = format!("{:?}", opts);
610        assert!(debug.contains("SessionOptions"));
611        assert!(debug.contains("alternate_screen"));
612    }
613
614    #[test]
615    fn kitty_keyboard_escape_sequences() {
616        // Verify the escape sequences are correct
617        assert_eq!(KITTY_KEYBOARD_ENABLE, b"\x1b[>15u");
618        assert_eq!(KITTY_KEYBOARD_DISABLE, b"\x1b[<u");
619    }
620
621    #[test]
622    fn session_options_partial_config() {
623        let opts = SessionOptions {
624            alternate_screen: true,
625            mouse_capture: false,
626            bracketed_paste: true,
627            ..Default::default()
628        };
629        assert!(opts.alternate_screen);
630        assert!(!opts.mouse_capture);
631        assert!(opts.bracketed_paste);
632        assert!(!opts.focus_events);
633        assert!(!opts.kitty_keyboard);
634    }
635
636    #[cfg(unix)]
637    enum ReaderMsg {
638        Data(Vec<u8>),
639        Eof,
640        Err(std::io::Error),
641    }
642
643    #[cfg(unix)]
644    fn read_until_pattern(
645        rx: &mpsc::Receiver<ReaderMsg>,
646        captured: &mut Vec<u8>,
647        pattern: &[u8],
648        timeout: Duration,
649    ) -> std::io::Result<()> {
650        let deadline = Instant::now() + timeout;
651        while Instant::now() < deadline {
652            let remaining = deadline.saturating_duration_since(Instant::now());
653            let wait = remaining.min(Duration::from_millis(50));
654            match rx.recv_timeout(wait) {
655                Ok(ReaderMsg::Data(chunk)) => {
656                    captured.extend_from_slice(&chunk);
657                    if captured.windows(pattern.len()).any(|w| w == pattern) {
658                        return Ok(());
659                    }
660                }
661                Ok(ReaderMsg::Eof) => break,
662                Ok(ReaderMsg::Err(err)) => return Err(err),
663                Err(mpsc::RecvTimeoutError::Timeout) => continue,
664                Err(mpsc::RecvTimeoutError::Disconnected) => break,
665            }
666        }
667        Err(std::io::Error::other(
668            "timeout waiting for PTY output marker",
669        ))
670    }
671
672    #[cfg(unix)]
673    fn assert_contains_any(output: &[u8], options: &[&[u8]], label: &str) {
674        let found = options
675            .iter()
676            .any(|needle| output.windows(needle.len()).any(|w| w == *needle));
677        assert!(found, "expected cleanup sequence for {label}");
678    }
679
680    #[cfg(unix)]
681    #[test]
682    fn terminal_session_panic_cleanup_idempotent() {
683        const MARKER: &[u8] = b"PANIC_CAUGHT";
684        const TEST_NAME: &str =
685            "terminal_session::tests::terminal_session_panic_cleanup_idempotent";
686        const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
687        const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
688            b"\x1b[?1000;1002;1006l",
689            b"\x1b[?1000;1002l",
690            b"\x1b[?1000l",
691        ];
692        const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
693        const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
694        const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
695        const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
696
697        if std::env::var("FTUI_CORE_PANIC_CHILD").is_ok() {
698            let _ = std::panic::catch_unwind(|| {
699                let _session = TerminalSession::new(SessionOptions {
700                    alternate_screen: true,
701                    mouse_capture: true,
702                    bracketed_paste: true,
703                    focus_events: true,
704                    kitty_keyboard: true,
705                })
706                .expect("TerminalSession::new should succeed in PTY");
707                panic!("intentional panic to exercise cleanup");
708            });
709
710            // The panic hook + Drop will have already attempted cleanup; call again to
711            // verify idempotence when cleanup paths run multiple times.
712            best_effort_cleanup_for_exit();
713
714            let _ = io::stdout().write_all(MARKER);
715            let _ = io::stdout().flush();
716            return;
717        }
718
719        let exe = std::env::current_exe().expect("current_exe");
720        let mut cmd = CommandBuilder::new(exe);
721        cmd.args(["--exact", TEST_NAME, "--nocapture"]);
722        cmd.env("FTUI_CORE_PANIC_CHILD", "1");
723        cmd.env("RUST_BACKTRACE", "0");
724
725        let pty_system = portable_pty::native_pty_system();
726        let pair = pty_system
727            .openpty(PtySize {
728                rows: 24,
729                cols: 80,
730                pixel_width: 0,
731                pixel_height: 0,
732            })
733            .expect("openpty");
734
735        let mut child = pair.slave.spawn_command(cmd).expect("spawn PTY child");
736        drop(pair.slave);
737
738        let mut reader = pair.master.try_clone_reader().expect("clone PTY reader");
739        let _writer = pair.master.take_writer().expect("take PTY writer");
740
741        let (tx, rx) = mpsc::channel::<ReaderMsg>();
742        let reader_thread = thread::spawn(move || {
743            let mut buf = [0u8; 4096];
744            loop {
745                match reader.read(&mut buf) {
746                    Ok(0) => {
747                        let _ = tx.send(ReaderMsg::Eof);
748                        break;
749                    }
750                    Ok(n) => {
751                        let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
752                    }
753                    Err(err) => {
754                        let _ = tx.send(ReaderMsg::Err(err));
755                        break;
756                    }
757                }
758            }
759        });
760
761        let mut captured = Vec::new();
762        read_until_pattern(&rx, &mut captured, MARKER, Duration::from_secs(5))
763            .expect("expected marker from child");
764
765        let status = child.wait().expect("child wait");
766        let _ = reader_thread.join();
767
768        assert!(status.success(), "child should exit successfully");
769        assert!(
770            captured.windows(MARKER.len()).any(|w| w == MARKER),
771            "expected panic marker in PTY output"
772        );
773        assert_contains_any(&captured, ALT_SCREEN_EXIT_SEQS, "alt-screen exit");
774        assert_contains_any(&captured, MOUSE_DISABLE_SEQS, "mouse disable");
775        assert_contains_any(
776            &captured,
777            BRACKETED_PASTE_DISABLE_SEQS,
778            "bracketed paste disable",
779        );
780        assert_contains_any(&captured, FOCUS_DISABLE_SEQS, "focus disable");
781        assert_contains_any(&captured, KITTY_DISABLE_SEQS, "kitty disable");
782        assert_contains_any(&captured, CURSOR_SHOW_SEQS, "cursor show");
783    }
784}