diff --git a/Cargo.lock b/Cargo.lock index d950809..e976fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "etr" -version = "0.6.4" +version = "0.6.5" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 2dd6332..0548d48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "etr" -version = "0.6.4" +version = "0.6.5" edition = "2024" description = "A Rust implementation of Eternal Terminal (et)" license = "GPL-3.0-only" @@ -37,11 +37,14 @@ serde = { version = "1", features = ["derive"] } toml = "1" # Windows-only: put the console into virtual-terminal mode so the client speaks -# the same key/ANSI byte conventions a Unix PTY expects (Backspace → DEL, etc.). +# the same key/ANSI byte conventions a Unix PTY expects (Backspace → DEL, etc.), +# and read the console input handle directly with `ReadFile` +# (Win32_Storage_FileSystem) to get an unbatched, per-keystroke VT byte stream. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_Console", + "Win32_Storage_FileSystem", ] } [dev-dependencies] diff --git a/NOTES.md b/NOTES.md index 1789b46..5adaadf 100644 --- a/NOTES.md +++ b/NOTES.md @@ -9,7 +9,107 @@ the link drops. This project uses **QUIC** (via the `quinn` crate) for the tran layer, which provides reliable, ordered, multiplexed streams with congestion control and TLS 1.3 built-in. -## Current state: v0.6.4 — Windows Backspace fix +## Current state: v0.6.5 — Windows input path + terminal restore on exit + +New in v0.6.5 (four independent Windows parity fixes): + +- **Special characters no longer "eaten".** The client's stdin reader previously + used `std::io::stdin().read()`, which on Windows goes through Rust std's + `ReadConsoleW` shim (UTF-16→UTF-8 + internal line cooking). That shim **drops + bytes that aren't clean UTF-8**, which is what made special characters + disappear (e.g. zellij keybindings not registering, needing `^g`). The reader + now reads the console input handle directly with `ReadFile` (new `read_stdin`, + Windows path). With `ENABLE_VIRTUAL_TERMINAL_INPUT` already on, `ReadFile` + returns the same VT byte stream a Unix terminal emits — no UTF-8 mangling. + `enable_vt_console` also now sets the console **input codepage to UTF-8 + (65001)** (saved and restored on exit) so typed multi-byte characters reach + the remote as UTF-8, matching what the std path produced. No-op on Unix. +- **First line of input now echoes as typed (issue #54).** The single stdin + reader thread is spawned before the QUIC connect, but raw + VT-input mode is + only enabled *after* the connect. On Windows a `ReadFile` issued while the + console is still in cooked/line mode stays line-buffered for that whole read, + so the first line was held client-side until Enter (the reported "no echo + until first Enter"; the `ReadFile` change above did **not** fix this on its + own — it is a timing problem, not a read-mechanism one). The Windows reader + now waits on a one-shot signal fired immediately after the first + `enable_raw_mode` + `enable_vt_console`, so its very first read happens in raw + + VT mode and is per-keystroke. Unix has no such coupling (and never showed the + bug), so its reader is ungated and starts immediately as before. +- **Local terminal is restored on exit.** A remote full-screen app + (zellij/vim/less) puts the *local* terminal into alternate-screen, + mouse-reporting, bracketed-paste, hidden-cursor, application-keypad and + scroll-region modes via escapes we relay. On a hard drop (remote reboot) or a + forced `~.` the remote never sends its cleanup, so those modes were left set: + the mouse wheel spewed escape sequences and the terminal was unusable. + `disable_raw_mode` only restores console line/echo flags, not these + emulator modes. The client now emits an explicit VT reset (`restore_terminal`) + on every final-exit path. It is split in two: a **cursor-safe** part + (`TERM_RESET_MODES` — disable mouse/paste/app-keys, show cursor, reset SGR) + emitted on every exit, and a **screen-restoring** part (`TERM_RESET_SCREEN` — + leave alternate screen, reset scroll region; both move the cursor to home) + emitted **only** on unclean exits (`~.`, abandoned hard drop, remote command + whose TUI may still be up). Clean shell exits skip the screen reset so the + cursor is left untouched. Cross-platform (Unix terminals honour the same + resets); deliberately avoids a full RIS so scrollback is preserved. +- **Local shell's Enter works again after etr exits.** `enable_vt_console` sets + `ENABLE_VIRTUAL_TERMINAL_INPUT` on the console, but crossterm's + `disable_raw_mode` only ORs the line/echo/processed-input bits back — it never + clears that VT-input flag. So after etr exited, the console was left with + VT-input still enabled, and the *local* shell echoed typed characters but did + not accept Enter (the VT-translated Enter wasn't recognised as line + submission). etr now captures the console's exact original input/output modes + and input codepage once (`capture_console_originals`, before raw mode is first + enabled) and restores them verbatim on exit (`restore_console_state`), which + clears the leftover VT-input flag. Pre-existing since VT-input was introduced + in v0.6.4. No-op on Unix (crossterm fully restores termios there). +- Test count: 110 → 112 (two regression tests asserting the reset sequences + cover the critical modes and never move the cursor on the safe path / never + clear scrollback). + +**Live verification (Windows → WSL Fedora 44 etrs, 2026-07-21):** both fixes were +verified end-to-end against a real Unix `etrs`, driving the rebuilt v0.6.5 client +with *synthesized real console key events* (`WriteConsoleInputW` into the +client's own console — the same INPUT_RECORDs a physical keyboard produces): + +- *Fix #1 (input not eaten, per-keystroke):* An isolated harness confirmed the + exact path `read_stdin` uses (console in raw + `ENABLE_VIRTUAL_TERMINAL_INPUT`, + read via `ReadFile`) delivers every key intact and unbatched: `a`, **Ctrl+G → + `0x07`**, Up-arrow → `ESC [ A`, a rapid 5-key burst, and `é` → UTF-8 `c3 a9`. + A full-composition harness then injected keystrokes into a live `etr` session; + the remote `zsh-syntax-highlighting` re-coloured the command **character by + character** as it arrived (proof of per-keystroke delivery, not batching) and + the typed command executed and round-tripped. This is the root cause of the + "characters eaten / zellij needs `^g`" report. +- *First-line echo (#54):* A/B harness that types the first line and snapshots + the client's stdout **before** sending Enter. Pre-fix binary: the typed + command is absent from the snapshot (held client-side until Enter). With the + reader gate: the command appears in the pre-Enter snapshot, echoed back + per-keystroke (remote syntax-highlighting recolours char-by-char) — first line + now echoes as typed. +- *Fix #2 (terminal restore):* On clean shell exit the client emitted exactly the + 70-byte cursor-safe `TERM_RESET_MODES` (no cursor-moving screen reset), + confirmed in the live output byte stream. +- *Console-mode restore:* A harness recorded the console input mode before + launching etr and again after etr exited via `~.`. Result: the mode was + restored byte-identical (`0x01f7` → `0x01f7`) and `ENABLE_VIRTUAL_TERMINAL_INPUT` + was not left set — the local shell's line input (Enter) works after exit. + +Note (adjacent, pre-existing, out of scope): running `etr host 'cmd'` with +redirected/`> = std::sync::OnceLock::new(); static IN_RAW_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +// The console input/output modes and input codepage in effect before etr +// touched anything, captured once by `capture_console_originals` so they can be +// restored verbatim on exit. Restoring the exact modes is essential: crossterm's +// `disable_raw_mode` only ORs the line/echo/processed-input bits back, so the +// `ENABLE_VIRTUAL_TERMINAL_INPUT` we add in `enable_vt_console` would otherwise +// be left set — which breaks the *local* shell's Enter handling after etr exits. +#[cfg(windows)] +static CONSOLE_CAPTURED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(windows)] +static ORIG_INPUT_MODE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +#[cfg(windows)] +static OUTPUT_CAPTURED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(windows)] +static ORIG_OUTPUT_MODE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +#[cfg(windows)] +static ORIG_INPUT_CP: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Cursor-safe VT resets emitted on *every* session exit. +/// +/// A remote full-screen program (zellij, vim, less, …) switches the *local* +/// terminal into mouse-reporting, bracketed-paste, application-cursor-key, +/// application-keypad and hidden-cursor modes via escape sequences we relay to +/// stdout. If the session dies before that program emits its own cleanup — a +/// hard drop (remote reboot) or a forced `~.` — those modes stay set and the +/// terminal is left unusable (mouse wheel spews escape sequences, cursor +/// hidden). These are terminal-*emulator* modes, not console line/echo flags, +/// so `disable_raw_mode` does not undo them; we emit the resets ourselves. +/// +/// Every reset here is idempotent *and* leaves the cursor where it is, so it is +/// safe to send even on a clean exit where nothing was left set. +const TERM_RESET_MODES: &[u8] = b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b[?1l\x1b>\x1b[?7h\x1b[?25h\x1b[0m"; + +/// Screen-restoring VT resets: leave the alternate screen buffer and reset the +/// scrolling region. Both move the cursor to home per the VT spec, so these are +/// emitted *only* when the session ended uncleanly (hard drop / forced `~.`) and +/// a full-screen app may still hold the alternate screen — never on a clean exit +/// where the remote app already switched back (re-emitting them would reposition +/// the cursor). Deliberately avoids a full RIS (`\x1bc`) so scrollback is kept. +const TERM_RESET_SCREEN: &[u8] = b"\x1b[?1049l\x1b[r"; + /// Escape character for the client: `~` (0x7E), SSH-style. Type it at the /// start of a line followed by `.` to force-disconnect. The line-start guard /// prevents false triggers from `~` in shell paths or git refs. @@ -45,8 +85,15 @@ const ESCAPE_CHAR: u8 = b'~'; fn enable_vt_console() { use windows_sys::Win32::System::Console::{ CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, - GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCP, + SetConsoleMode, }; + // The UTF-8 codepage. We read the console input handle directly with + // `ReadFile` (see `read_stdin`), which returns typed characters encoded in + // the console *input* codepage. Set it to UTF-8 so multi-byte input reaches + // the remote as the UTF-8 the Unix PTY expects, rather than the legacy + // OEM/ANSI codepage. + const CP_UTF8: u32 = 65001; // SAFETY: standard Win32 console calls. `GetStdHandle` returns a process // std handle; `GetConsoleMode` fails (returns 0) for non-console handles // (e.g. redirected stdio), so we only call `SetConsoleMode` on a handle it @@ -56,6 +103,7 @@ fn enable_vt_console() { let mut mode: CONSOLE_MODE = 0; if GetConsoleMode(h_in, &mut mode) != 0 { SetConsoleMode(h_in, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + SetConsoleCP(CP_UTF8); } let h_out = GetStdHandle(STD_OUTPUT_HANDLE); let mut out_mode: CONSOLE_MODE = 0; @@ -69,6 +117,154 @@ fn enable_vt_console() { #[cfg(not(windows))] fn enable_vt_console() {} +/// Capture the console's original input/output modes and input codepage exactly +/// once, before etr changes any of them, so they can be restored verbatim on +/// exit. Must be called before the first `enable_raw_mode`/`enable_vt_console`. +/// No-op on Unix (crossterm's `disable_raw_mode` fully restores termios there). +#[cfg(windows)] +fn capture_console_originals() { + use std::sync::atomic::Ordering; + use windows_sys::Win32::System::Console::{ + CONSOLE_MODE, GetConsoleCP, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, + }; + if CONSOLE_CAPTURED.load(Ordering::Relaxed) { + return; + } + // SAFETY: standard Win32 console queries. `GetConsoleMode` fails (returns 0) + // for non-console handles, in which case we capture nothing and restore is a + // no-op. All pointers reference locals that outlive the call. + unsafe { + let h_in = GetStdHandle(STD_INPUT_HANDLE); + let mut mode: CONSOLE_MODE = 0; + if GetConsoleMode(h_in, &mut mode) == 0 { + return; // no real console (e.g. redirected stdin); nothing to restore + } + ORIG_INPUT_MODE.store(mode, Ordering::Relaxed); + let cp = GetConsoleCP(); + if cp != 0 { + ORIG_INPUT_CP.store(cp, Ordering::Relaxed); + } + let h_out = GetStdHandle(STD_OUTPUT_HANDLE); + let mut out_mode: CONSOLE_MODE = 0; + if GetConsoleMode(h_out, &mut out_mode) != 0 { + ORIG_OUTPUT_MODE.store(out_mode, Ordering::Relaxed); + OUTPUT_CAPTURED.store(true, Ordering::Relaxed); + } + CONSOLE_CAPTURED.store(true, Ordering::Relaxed); + } +} + +#[cfg(not(windows))] +fn capture_console_originals() {} + +/// Restore the console modes and input codepage captured by +/// `capture_console_originals`. This is what actually clears the +/// `ENABLE_VIRTUAL_TERMINAL_INPUT` flag `enable_vt_console` set — crossterm's +/// `disable_raw_mode` does not — so the *local* shell's line input (Enter) works +/// again after etr exits. No-op on Unix and if nothing was captured. +#[cfg(windows)] +fn restore_console_state() { + use std::sync::atomic::Ordering; + use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCP, SetConsoleMode, + }; + if !CONSOLE_CAPTURED.load(Ordering::Relaxed) { + return; + } + // SAFETY: restoring modes/codepage captured earlier from the same handles. + unsafe { + let h_in = GetStdHandle(STD_INPUT_HANDLE); + SetConsoleMode(h_in, ORIG_INPUT_MODE.load(Ordering::Relaxed)); + let cp = ORIG_INPUT_CP.load(Ordering::Relaxed); + if cp != 0 { + SetConsoleCP(cp); + } + if OUTPUT_CAPTURED.load(Ordering::Relaxed) { + let h_out = GetStdHandle(STD_OUTPUT_HANDLE); + SetConsoleMode(h_out, ORIG_OUTPUT_MODE.load(Ordering::Relaxed)); + } + } +} + +#[cfg(not(windows))] +fn restore_console_state() {} + +/// Read a chunk of stdin bytes into `buf`, returning the number read (0 = EOF). +/// +/// On Unix this is a plain `stdin().read`. On Windows it reads the console +/// input handle directly with `ReadFile`, bypassing Rust std's `ReadConsoleW` +/// shim: with `ENABLE_VIRTUAL_TERMINAL_INPUT` on (set by `enable_vt_console`) +/// the console hands `ReadFile` the same per-keystroke VT byte stream a Unix +/// terminal emits. The std shim instead batches input and drops bytes that +/// aren't valid UTF-8, which manifested as "special characters eaten" and the +/// "first line not echoed until Enter" bug (#54). +#[cfg(not(windows))] +fn read_stdin(buf: &mut [u8]) -> io::Result { + use std::io::Read; + std::io::stdin().read(buf) +} + +#[cfg(windows)] +fn read_stdin(buf: &mut [u8]) -> io::Result { + use windows_sys::Win32::Storage::FileSystem::ReadFile; + use windows_sys::Win32::System::Console::{GetStdHandle, STD_INPUT_HANDLE}; + // SAFETY: `GetStdHandle` returns the process stdin handle, valid for the + // process lifetime. `ReadFile` writes at most `buf.len()` bytes into `buf` + // and reports the count via `read`; both pointers reference locals/`buf` + // that outlive the call. A null overlapped pointer requests a synchronous + // read, which is correct for the (synchronous) console handle. + unsafe { + let h = GetStdHandle(STD_INPUT_HANDLE); + let mut read: u32 = 0; + let ok = ReadFile( + h, + buf.as_mut_ptr(), + buf.len() as u32, + &mut read, + std::ptr::null_mut(), + ); + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(read as usize) + } +} + +/// Return the local terminal to a sane state after a session ends for good +/// (clean exit, `~.`, or a hard drop we give up on). Emits the VT resets while +/// output VT processing is still enabled, drops raw mode, then restores the +/// console's exact original modes + codepage (which is what clears the +/// `ENABLE_VIRTUAL_TERMINAL_INPUT` flag so the local shell's Enter works again). +/// +/// `reset_screen` should be `true` only for unclean endings (forced `~.`, a hard +/// drop we abandon, a remote command whose TUI may still be up): it additionally +/// leaves the alternate screen and resets the scroll region ([`TERM_RESET_SCREEN`]), +/// which move the cursor. On a clean exit pass `false` so the cursor is left +/// untouched, since the remote already restored the screen. +/// +/// Must only be called when raw/VT mode was actually entered — otherwise the +/// escape bytes would print literally on a console without VT processing enabled. +fn restore_terminal(reset_screen: bool) { + IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); + { + let mut out = io::stdout(); + // Leave the alternate screen first (back to the normal buffer), then + // reset the cursor-safe modes on the buffer the user will actually see. + if reset_screen { + let _ = out.write_all(TERM_RESET_SCREEN); + } + let _ = out.write_all(TERM_RESET_MODES); + let _ = out.flush(); + } + // Drop crossterm's raw mode first (keeps its internal state consistent), then + // restore the exact original console modes — the latter wins, and crucially + // clears ENABLE_VIRTUAL_TERMINAL_INPUT, which crossterm's disable_raw_mode + // leaves set. + let _ = disable_raw_mode(); + restore_console_state(); +} + macro_rules! vlog { ($verbose:expr, $level:expr, $($arg:tt)*) => { if $verbose >= $level { @@ -624,6 +820,10 @@ async fn run_connection_loop( x11_auth_cookie: Vec, verbose: u8, ) -> io::Result<()> { + // Snapshot the console's original modes/codepage before anything (raw mode, + // VT-input) changes them, so `restore_terminal` can put it back exactly. + capture_console_originals(); + let host = if let Some(idx) = target.find('@') { &target[idx + 1..] } else { @@ -658,14 +858,27 @@ async fn run_connection_loop( // ~. triggers this to exit the reconnect loop. let (escape_tx, escape_rx) = tokio::sync::watch::channel(false); + // Windows only: the reader must not issue its first `ReadFile` until raw + + // VT-input mode is enabled. A `ReadFile` issued while the console is still + // in cooked/line mode stays line-buffered for that whole read, so the first + // line would be held until Enter (issue #54 — "first line not echoed until + // Enter"). Raw mode is enabled per-connect (after the QUIC handshake), so + // we gate the reader on a one-shot signal fired right after the first + // `enable_raw_mode` + `enable_vt_console`. Unix has no such coupling (and + // never exhibited the bug), so its reader starts immediately as before. + #[cfg(windows)] + let (raw_ready_tx, raw_ready_rx) = std::sync::mpsc::channel::<()>(); + let _stdin_reader = tokio::task::spawn_blocking(move || { - use std::io::Read; + // Wait until the console is in raw + VT-input mode before the first read. + #[cfg(windows)] + let _ = raw_ready_rx.recv(); let mut buf = [0u8; 1024]; // `~` is common in shell input, so only recognise it at line-start // (mirrors ssh ~. behaviour). let mut at_line_start = true; let mut escape_pending = false; - while let Ok(n) = std::io::stdin().read(&mut buf) { + while let Ok(n) = read_stdin(&mut buf) { if n == 0 { break; } @@ -719,6 +932,10 @@ async fn run_connection_loop( // Track whether the terminal is currently in raw mode so reconnect messages // can use \r\n (raw) vs \n (cooked) and so we don't over-call disable_raw_mode. let mut in_raw = false; + // Windows only: fired once, right after raw + VT mode is first enabled, to + // release the gated stdin reader (see the reader spawn above). + #[cfg(windows)] + let mut raw_ready_tx = Some(raw_ready_tx); 'reconnect: loop { if !first { @@ -733,13 +950,13 @@ async fn run_connection_loop( tokio::select! { _ = tokio::time::sleep(Duration::from_secs(2)) => {} Ok(_) = escape_rx.wait_for(|&v| v) => { + // Only restore (emit the VT reset) if we ever entered raw/VT + // mode — otherwise the escape bytes would print literally. + // Unclean ending: reset the screen too. if in_raw { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprint!("\r\n[etr] Disconnected (~.).\r\n"); - } else { - eprintln!("[etr] Disconnected (~.)."); + restore_terminal(true); } + eprintln!("[etr] Disconnected (~.)."); return Ok(()); } } @@ -787,12 +1004,9 @@ async fn run_connection_loop( }, Ok(_) = escape_rx.wait_for(|&v| v) => { if in_raw { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprint!("\r\n[etr] Disconnected (~.).\r\n"); - } else { - eprintln!("[etr] Disconnected (~.)."); + restore_terminal(true); } + eprintln!("[etr] Disconnected (~.)."); return Ok(()); } }; @@ -807,6 +1021,12 @@ async fn run_connection_loop( enable_vt_console(); IN_RAW_MODE.store(true, std::sync::atomic::Ordering::Relaxed); in_raw = true; + // Release the stdin reader now that raw + VT mode is active, so its first + // read is per-keystroke rather than a line-buffered cooked read (#54). + #[cfg(windows)] + if let Some(tx) = raw_ready_tx.take() { + let _ = tx.send(()); + } let result = tokio::select! { r = run_session( conn, @@ -823,34 +1043,33 @@ async fn run_connection_loop( verbose, ) => r, Ok(_) = escape_rx.wait_for(|&v| v) => { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprint!("\r\n[etr] Disconnected (~.).\r\n"); + restore_terminal(true); + eprintln!("[etr] Disconnected (~.)."); std::process::exit(0); } }; match result { Ok(_) => { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); + // Clean exit: the remote shell already restored the screen, so + // don't touch the cursor — just reset emulator modes. + restore_terminal(false); vlog!(verbose, 1, "[etr] Connection closed cleanly."); std::process::exit(0); } Err(e) if e.kind() == io::ErrorKind::ConnectionAborted => { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); + restore_terminal(false); vlog!(verbose, 1, "[etr] Connection closed cleanly."); std::process::exit(0); } Err(e) => { // For remote commands: exit rather than reconnect. The command // has finished (or the server is gone), so there is nothing to - // reconnect to. Restore the terminal before printing. + // reconnect to. A full-screen command (btop, …) may have been + // left holding the alternate screen, so reset it too. if has_remote_command { - IN_RAW_MODE.store(false, std::sync::atomic::Ordering::Relaxed); - let _ = disable_raw_mode(); - eprintln!("\n[etr] Session ended: {e}"); + restore_terminal(true); + eprintln!("[etr] Session ended: {e}"); std::process::exit(1); } // Keep raw mode ON during reconnect so ~. fires immediately. @@ -1914,6 +2133,44 @@ mod tests { assert_eq!(ESCAPE_CHAR, b'~'); } + #[test] + fn test_term_reset_modes_covers_critical_modes() { + // Regression guard: the cursor-safe reset emitted on every session exit + // must undo the emulator modes a remote full-screen app leaves set, or + // the local terminal is unusable after a hard drop / `~.` quit. Bytes + // are checked so an accidental edit that drops one is caught. + let seq = TERM_RESET_MODES; + let contains = |needle: &[u8]| seq.windows(needle.len()).any(|w| w == needle); + // Disable every mouse-reporting mode (mouse wheel spewing escapes). + assert!(contains(b"\x1b[?1000l")); + assert!(contains(b"\x1b[?1002l")); + assert!(contains(b"\x1b[?1003l")); + assert!(contains(b"\x1b[?1006l")); + // Disable bracketed paste. + assert!(contains(b"\x1b[?2004l")); + // Show the cursor again. + assert!(contains(b"\x1b[?25h")); + // Reset SGR attributes. + assert!(seq.ends_with(b"\x1b[0m")); + // The cursor-safe reset must NOT move the cursor: no alternate-screen + // switch (`?1049l`), no scroll-region reset (`\x1b[r`), no full RIS. + assert!(!contains(b"\x1b[?1049l")); + assert!(!contains(b"\x1b[r")); + assert!(!contains(b"\x1bc")); + } + + #[test] + fn test_term_reset_screen_leaves_alt_screen() { + // The screen-restoring reset (unclean exits only) must leave the + // alternate screen and reset the scroll region, but never clear + // scrollback with a full RIS. + let seq = TERM_RESET_SCREEN; + let contains = |needle: &[u8]| seq.windows(needle.len()).any(|w| w == needle); + assert!(contains(b"\x1b[?1049l")); + assert!(contains(b"\x1b[r")); + assert!(!contains(b"\x1bc")); + } + #[test] fn test_log_paths_fallback_to_config() { let toml = "[client]\nlog_path = \"/config/client.log\"\nserver_log_path = \"/config/server.log\"\n";