diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index f2226e07..43a72d97 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -20,6 +20,7 @@ pub mod shells; #[allow(dead_code)] pub mod ssh_profile; pub mod tab_view; +pub mod term_modes; pub mod threads; pub mod window_state; pub mod worktree; diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs new file mode 100644 index 00000000..cc9dea8b --- /dev/null +++ b/crates/tty7-core/src/core/term_modes.rs @@ -0,0 +1,276 @@ +//! Tracks the DEC private modes a pane's output has switched on, so a +//! re-attaching client can be told about them instead of having to find them +//! in the replayed bytes. +//! +//! A pane's screen comes back on re-attach as raw bytes out of the replay ring, +//! and the ring is a *window*: it holds the last few megabytes and drops the +//! rest from the front. That is fine for text, which is only worth what is +//! still on screen, and wrong for modes, which a full-screen program sets +//! exactly once — `btop` sends `?1049h` and its mouse modes at startup and then +//! never again, so a day of refreshes pushes the only copy of them out of the +//! ring. The client that replays what is left ends up painting an alternate +//! screen onto its primary buffer with mouse reporting off, and its wheel falls +//! back to scrolling the scrollback of a screen that should not scroll (#774). +//! +//! So the daemon folds the same bytes into this tracker as they pass, and +//! `replay_state` re-sends what is still on ahead of the ring — the same +//! treatment cwd, the prompt state and the agent already get. +//! +//! Only modes that change how input is routed or which buffer is on screen are +//! tracked. Cursor visibility (`?25`) and autowrap (`?7`) are deliberately left +//! out: any frame of a running TUI paints them back within milliseconds, while +//! restoring them from a stale fold could leave a shell with an invisible +//! cursor, which is a worse failure than the one being fixed. + +/// The modes worth restoring. +/// +/// `47`, `1047` and `1049` are the alternate screen in its three spellings — +/// the mode the wheel consults before it decides the pane has a scrollback to +/// move at all, and the one that decides which buffer the replayed frames are +/// painted into. `1000`, `1002` and `1003` are the mouse reporting level and +/// `1005`, `1006`, `1015` and `1016` its encodings: a program that negotiated +/// SGR and comes back without it reads every wheel report as a click at a +/// wrong, truncated coordinate. `1007` is alternate scroll, which is what turns +/// the wheel into arrow keys inside a full-screen program, and `1` (DECCKM) +/// decides whether those arrows are `ESC O A` or `ESC [ A`. `1004` is focus +/// reporting, which the client stops sending without it, and `2004` bracketed +/// paste — without it a paste into a restored TUI arrives as plain keystrokes, +/// which is how a paste turns into commands. +const TRACKED: &[u16] = &[ + 1, 47, 1047, 1049, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004, +]; + +/// A CSI longer than this is not a mode set; keep the buffer bounded. +const MAX_PARAMS: usize = 64; + +/// The private modes currently on, in the order they were last switched on. +/// +/// Order matters because the emulator treats some of these as levels rather +/// than as independent bits: setting `?1002` clears the other mouse-reporting +/// modes. Replaying them in the order the application set them therefore lands +/// on the same state the application asked for, whatever it asked for. +#[derive(Debug, Default, Clone)] +pub struct TerminalModes { + on: Vec, + state: State, + params: Vec, +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +enum State { + #[default] + Text, + Esc, + /// A CSI whose parameter bytes are being read. `private` records the `?` + /// that makes it a DEC private mode rather than an ANSI one. + Csi { + private: bool, + }, + Osc, + OscEsc, +} + +impl TerminalModes { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.on.is_empty() + } + + /// The modes currently on, oldest set first. + pub fn active(&self) -> &[u16] { + &self.on + } + + /// The bytes that put a freshly reset terminal back into these modes, or + /// `None` when there is nothing to restore. + pub fn restore_bytes(&self) -> Option> { + if self.on.is_empty() { + return None; + } + let mut out = Vec::with_capacity(self.on.len() * 8); + for mode in &self.on { + out.extend_from_slice(b"\x1b[?"); + out.extend_from_slice(mode.to_string().as_bytes()); + out.push(b'h'); + } + Some(out) + } + + /// Folds one chunk of pty output into the tracked state. Sequences split + /// across chunks are carried, so the caller may feed whatever sizes the pty + /// hands it. + pub fn feed(&mut self, bytes: &[u8]) { + let mut i = 0; + while i < bytes.len() { + if self.state == State::Text { + let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else { + return; + }; + self.state = State::Esc; + i += off + 1; + continue; + } + let b = bytes[i]; + match self.state { + State::Text => unreachable!(), + State::Esc => match b { + b'[' => { + self.params.clear(); + self.state = State::Csi { private: false }; + } + b']' => self.state = State::Osc, + // RIS. Everything this tracker knows goes back to default, + // exactly as it does in the client's emulator. + b'c' => { + self.on.clear(); + self.state = State::Text; + } + 0x1b => {} + _ => self.state = State::Text, + }, + State::Csi { private } => match b { + b'?' if self.params.is_empty() => self.state = State::Csi { private: true }, + b'0'..=b'9' | b';' => { + self.params.push(b); + if self.params.len() > MAX_PARAMS { + self.state = State::Text; + } + } + b'h' | b'l' => { + if private { + self.apply(b == b'h'); + } + self.state = State::Text; + } + // Any other final byte — or an intermediate such as the `$` + // of a DECRQM query — ends a sequence that is not a mode + // set. Intermediates are lumped in with finals on purpose: + // `?…$p` is a *request*, and answering it is the emulator's + // job, not ours. + _ => self.state = State::Text, + }, + State::Osc => match b { + 0x07 => self.state = State::Text, + 0x1b => self.state = State::OscEsc, + _ => {} + }, + State::OscEsc => match b { + b'\\' => self.state = State::Text, + 0x1b => {} + _ => self.state = State::Osc, + }, + } + i += 1; + } + } + + fn apply(&mut self, on: bool) { + for param in self.params.split(|b| *b == b';') { + let Ok(text) = std::str::from_utf8(param) else { + continue; + }; + let Ok(mode) = text.parse::() else { + continue; + }; + if !TRACKED.contains(&mode) { + continue; + } + // Removed either way: switching a mode on again moves it to the + // back, so the replay repeats the application's own order. + self.on.retain(|m| *m != mode); + if on { + self.on.push(mode); + } + } + self.params.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_the_alternate_screen_and_mouse_modes_a_full_screen_tool_sets() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h"); + assert_eq!(modes.active(), &[1049, 1002, 1006]); + assert_eq!( + modes.restore_bytes().unwrap(), + b"\x1b[?1049h\x1b[?1002h\x1b[?1006h".to_vec() + ); + } + + #[test] + fn a_mode_switched_off_is_forgotten() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049h\x1b[?1006h"); + modes.feed(b"\x1b[?1049l"); + assert_eq!(modes.active(), &[1006]); + + modes.feed(b"\x1b[?1006l"); + assert!(modes.is_empty()); + assert!(modes.restore_bytes().is_none()); + } + + #[test] + fn one_csi_may_carry_several_modes() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1000;1002;1006h"); + assert_eq!(modes.active(), &[1000, 1002, 1006]); + modes.feed(b"\x1b[?1000;1002l"); + assert_eq!(modes.active(), &[1006]); + } + + #[test] + fn re_setting_a_mode_moves_it_behind_the_ones_set_since() { + let mut modes = TerminalModes::new(); + // The emulator treats the reporting modes as a level, so the last one + // set is the one that wins — the replay has to end on it too. + modes.feed(b"\x1b[?1002h\x1b[?1003h\x1b[?1002h"); + assert_eq!(modes.active(), &[1003, 1002]); + } + + #[test] + fn a_sequence_split_across_chunks_is_still_seen() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?10"); + modes.feed(b"49"); + modes.feed(b"h"); + assert_eq!(modes.active(), &[1049]); + } + + #[test] + fn untracked_modes_and_ansi_mode_sets_are_ignored() { + let mut modes = TerminalModes::new(); + // `?25` (cursor) and `?2026` (synchronised update) are not restored, + // and `[4h` is ANSI insert mode, not a private one. + modes.feed(b"\x1b[?25l\x1b[?2026h\x1b[4h\x1b[?1049h"); + assert_eq!(modes.active(), &[1049]); + } + + #[test] + fn a_mode_query_is_not_a_mode_set() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049$p"); + assert!(modes.is_empty()); + } + + #[test] + fn an_osc_payload_that_looks_like_a_mode_set_is_not_one() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b]0;\x1b[?1049h\x07\x1b[?1002h"); + assert_eq!(modes.active(), &[1002]); + } + + #[test] + fn a_full_reset_clears_everything() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049h\x1b[?1006h"); + modes.feed(b"\x1bc"); + assert!(modes.is_empty()); + } +} diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 3954a7e0..232677a9 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -15,6 +15,7 @@ use crate::core::clipboard::{ }; use crate::core::kitty_graphics::{GraphicsSniffer, Segment, Sniffed}; use crate::core::osc::OscTokenizer; +use crate::core::term_modes::TerminalModes; use crate::daemon::protocol::{ AuthResponse, DaemonMsg, MAX_FRAME, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, WinSize, @@ -693,6 +694,12 @@ struct PaneState { /// record. See [`crate::core::machine::PaneRecord::osc_title`]. osc_title: Option, shell: ShellState, + /// The private modes the pane's output has switched on — the alternate + /// screen and mouse reporting above all. Folded from the same bytes the + /// ring gets, because the ring cannot be trusted to still hold them: a + /// full-screen tool sets them once at startup and the ring drops its front + /// (#774). See [`TerminalModes`]. + modes: TerminalModes, /// What this pane is running, for the machine tree to record. Distinct from /// `shell` above, which is the shell-integration state. shell_spec: Option, @@ -1461,6 +1468,7 @@ impl DaemonPane { cwd: spawn.initial_cwd, osc_title: restored_title, shell: ShellState::default(), + modes: TerminalModes::default(), shell_spec: spawn.shell.clone(), remote: spawn.remote.clone(), agent: None, @@ -1683,6 +1691,7 @@ impl DaemonPane { last_exit_code: carried.last_exit, command: None, }, + modes: TerminalModes::default(), remote: carried.remote, agent: carried.agent, agent_session: carried.agent_session, @@ -1735,6 +1744,7 @@ impl DaemonPane { cwd: None, osc_title: None, shell: ShellState::default(), + modes: TerminalModes::default(), remote: Some(remote), agent: None, agent_session: None, @@ -2035,7 +2045,7 @@ impl DaemonPane { || probed_cwd.is_some(); let mut st = state.lock().unwrap(); let facts_before = may_change_facts.then(|| observed_facts(&st)); - st.ring.append(bytes); + record_output(&mut st, bytes); fan_out_output(&mut st, bytes, frames, &gate); apply_signals(&mut st, signals); if let Some(remote) = remote { @@ -2575,7 +2585,26 @@ impl ReplayRing { } } +/// Everything the pane remembers about a chunk of output: the bytes +/// themselves, and the modes they switched on. One function so the two can +/// never drift apart — the modes are only worth anything if they were folded +/// from exactly the bytes the ring was given. +fn record_output(st: &mut PaneState, bytes: &[u8]) { + st.ring.append(bytes); + st.modes.feed(bytes); +} + fn replay_state(st: &PaneState, subscriber: &Sender) { + // Ahead of the ring, not after it: a client that is put into the alternate + // screen first paints the replayed frames into the buffer they belong to, + // and re-entering an alternate screen the ring turns out to still carry is + // a no-op in the emulator, so the two cannot fight. What the ring does + // carry always wins on its own terms — the fold is over every byte the + // pane ever wrote, so any mode the ring still toggles ends where the fold + // says it ends. + if let Some(modes) = st.modes.restore_bytes() { + let _ = subscriber.send(DaemonMsg::Snapshot(modes)); + } st.ring.replay(subscriber); if let Some(cwd) = &st.cwd { let _ = subscriber.send(DaemonMsg::Cwd(cwd.clone())); @@ -4376,6 +4405,7 @@ mod tests { cwd: None, osc_title: None, shell: ShellState::default(), + modes: TerminalModes::default(), remote: None, agent: None, agent_session: None, @@ -4770,6 +4800,64 @@ mod tests { ); } + /// Issue #774: `btop` sends its alternate-screen and mouse-reporting + /// prefix once, when it starts, and then refreshes for hours. The ring + /// holds the last few megabytes of those refreshes and nothing of the + /// prefix, so a client that rebuilt its terminal from replayed bytes alone + /// came back on the primary screen with reporting off — and its wheel, + /// reading those modes, scrolled the scrollback of a screen that has none. + #[test] + fn attach_restores_modes_whose_bytes_the_ring_has_dropped() { + let mut st = test_state(true); + record_output(&mut st, b"\x1b[?1049h\x1b[?1002h\x1b[?1006h"); + // A long enough run of refreshes to push the prefix out of the front. + record_output(&mut st, &vec![b'.'; RING_CAP]); + assert!( + !st.ring.flatten().windows(8).any(|w| w == b"\x1b[?1049h"), + "the point of the test is that the prefix is gone from the ring" + ); + + let (tx, rx) = mpsc::channel(); + attach_subscriber(&mut st, tx); + + // Ahead of the replayed screen, so the frames land in the buffer the + // modes put the client on. + assert!( + matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(b)) if b == b"\x1b[?1049h\x1b[?1002h\x1b[?1006h"), + "the modes the ring lost must be re-sent, in the order they were set" + ); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Size(_)))); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(_)))); + } + + #[test] + fn a_pane_that_left_the_alternate_screen_restores_no_modes() { + let mut st = test_state(true); + record_output(&mut st, b"\x1b[?1049h\x1b[?1002hvim\x1b[?1002l\x1b[?1049l"); + record_output(&mut st, b"$ "); + + let (tx, rx) = mpsc::channel(); + attach_subscriber(&mut st, tx); + + // Straight to the ring: a shell prompt is not owed a mode frame, and + // sending one would put the pane on a screen it had left. + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Size(_)))); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(_)))); + assert!(rx.try_recv().is_err()); + } + + /// An observer joins mid-session too, and reads the same pane state. + #[test] + fn observers_are_told_the_pane_modes_as_well() { + let mut st = test_state(true); + record_output(&mut st, b"\x1b[?1049h"); + record_output(&mut st, &vec![b'.'; RING_CAP]); + + let (tx, rx) = mpsc::channel(); + observe_subscriber(&mut st, tx, Arc::new(OutputGate::new())); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(b)) if b == b"\x1b[?1049h")); + } + #[test] fn attach_to_a_dead_pane_replays_exited() { let mut st = test_state(false); diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 01650704..6d5f8888 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -4064,6 +4064,58 @@ mod parked_cursor_tests { } } +/// Issue #774, from the client's end. A full-screen tool sets its modes once +/// and the replay ring drops them, so the daemon re-sends them from what it +/// folded out of the stream ([`tty7_core::core::term_modes`]). This is the +/// other half of that: the bytes it re-sends have to land the emulator back +/// where the application left it, because those are the modes `wheel_route` +/// reads before it decides the pane has a scrollback to move at all. +#[cfg(test)] +mod replayed_mode_tests { + use super::replay_tests::socket_pair; + use super::*; + use std::io::Write as _; + use tty7_core::core::term_modes::TerminalModes; + + #[test] + fn a_replayed_mode_frame_puts_the_client_back_on_the_alternate_screen() { + crate::core::config::pin_test_config_dir(); + // `btop`'s startup prefix, folded the way the daemon folds it out of + // the pty — and then re-sent from the fold, the ring having dropped + // the bytes themselves hours ago. + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h"); + + let (client_side, mut daemon_side) = socket_pair(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + DaemonMsg::Snapshot(modes.restore_bytes().expect("a fold with modes in it")) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + for _ in 0..600 { + if term.term.lock().mode().contains(TermMode::ALT_SCREEN) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let mode = *term.term.lock().mode(); + assert!( + mode.contains(TermMode::ALT_SCREEN), + "the pane belongs on the alternate screen it never left: {mode:?}" + ); + // Reporting first, SGR encoding with it: `wheel_route` sends the wheel + // to the application on the first and encodes the report with the + // second — see `wheel_routes_by_negotiated_mode_with_reporting_first`. + assert!( + mode.intersects(TermMode::MOUSE_MODE), + "mouse reporting is what keeps the wheel off the scrollback: {mode:?}" + ); + assert!(mode.contains(TermMode::SGR_MOUSE), "{mode:?}"); + } +} + #[cfg(all(test, unix))] mod tests { use super::*;