diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs index cc9dea8b..b23763df 100644 --- a/crates/tty7-core/src/core/term_modes.rs +++ b/crates/tty7-core/src/core/term_modes.rs @@ -14,7 +14,8 @@ //! //! 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. +//! treatment cwd, the prompt state and the agent already get. Only what the +//! ring itself no longer carries, though: see [`TerminalModes::restore_bytes_beyond`]. //! //! 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 @@ -87,11 +88,40 @@ impl TerminalModes { /// 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() { + Self::bytes_for(&self.on) + } + + /// The same, minus every mode `replayed` switches on by itself. + /// + /// `replayed` is a fold over the bytes that are about to be sent after + /// these, and whatever it carries has to be left to it — a mode sequence in + /// a stream does more than set a bit. `?1049h` clears the alternate screen + /// and takes the cursor there, and the emulator makes it a *no-op* once the + /// mode is already on, so restoring such a mode ahead of a replay that + /// still contains it does not harmlessly double up: it paints everything + /// the replay wrote before its own `?1049h` into the alternate screen, + /// which has no scrollback to hold it, and leaves the primary buffer the + /// program's exit returns the client to empty. + /// + /// What is left over is exactly what the replay can no longer speak for, + /// and it is a prefix of `on`: the replay is a suffix of the stream, so any + /// mode it sets was set later than one it does not. + pub fn restore_bytes_beyond(&self, replayed: &TerminalModes) -> Option> { + let missing: Vec = self + .on + .iter() + .copied() + .filter(|mode| !replayed.on.contains(mode)) + .collect(); + Self::bytes_for(&missing) + } + + fn bytes_for(modes: &[u16]) -> Option> { + if modes.is_empty() { return None; } - let mut out = Vec::with_capacity(self.on.len() * 8); - for mode in &self.on { + let mut out = Vec::with_capacity(modes.len() * 8); + for mode in modes { out.extend_from_slice(b"\x1b[?"); out.extend_from_slice(mode.to_string().as_bytes()); out.push(b'h'); @@ -266,6 +296,51 @@ mod tests { assert_eq!(modes.active(), &[1002]); } + #[test] + fn modes_the_replay_still_carries_are_left_to_the_replay() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h"); + + // A ring that still holds the whole prefix speaks for all three, and + // has to: its own `?1049h` is what clears the alternate screen and + // decides which buffer the bytes around it are painted into. + let mut whole = TerminalModes::new(); + whole.feed(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h"); + assert!(modes.restore_bytes_beyond(&whole).is_none()); + + // One that holds only the tail speaks for the tail; the rest comes back + // ahead of it, in the order the application set it. + let mut tail = TerminalModes::new(); + tail.feed(b"\x1b[?1006h"); + assert_eq!( + modes.restore_bytes_beyond(&tail).unwrap(), + b"\x1b[?1049h\x1b[?1002h".to_vec() + ); + + // And a ring with nothing left of the prefix is the #774 case: all of + // it is re-sent. + assert_eq!( + modes.restore_bytes_beyond(&TerminalModes::new()).unwrap(), + modes.restore_bytes().unwrap() + ); + } + + /// A ring drops from its front mid-sequence, so its first bytes can be the + /// tail of a mode set. The emulator will not act on that, so neither does + /// the fold that stands in for it. + #[test] + fn a_mode_set_the_replay_only_half_carries_is_still_restored() { + let mut modes = TerminalModes::new(); + modes.feed(b"\x1b[?1049h"); + + let mut replayed = TerminalModes::new(); + replayed.feed(b"049h and the rest of the screen"); + assert_eq!( + modes.restore_bytes_beyond(&replayed).unwrap(), + b"\x1b[?1049h".to_vec() + ); + } + #[test] fn a_full_reset_clears_everything() { let mut modes = TerminalModes::new(); diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 232677a9..5e784300 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2575,6 +2575,25 @@ impl ReplayRing { } } + /// The modes a replay of this ring switches on by itself — the same fold + /// the pane keeps, over the bytes that are actually left. + /// + /// Folded rather than remembered per mode because the front of the ring cuts + /// wherever the cap fell, possibly through a sequence: the client's emulator + /// will not act on half a `?1049h` either, and the answer here has to be the + /// one the emulator will reach. + fn modes(&self) -> TerminalModes { + let mut modes = TerminalModes::new(); + for seg in &self.segments { + // Both halves of the deque, in order: `feed` carries a sequence + // across calls, so the split is invisible to the fold. + let (a, b) = seg.bytes.as_slices(); + modes.feed(a); + modes.feed(b); + } + modes + } + #[cfg(test)] fn flatten(&self) -> Vec { let mut out = Vec::with_capacity(self.len); @@ -2596,13 +2615,17 @@ fn record_output(st: &mut PaneState, bytes: &[u8]) { 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() { + // screen first paints the replayed frames into the buffer they belong to. + // + // Only the modes the ring cannot switch on itself, though. Re-entering an + // alternate screen the ring still carries is not a harmless duplicate — + // the emulator makes `?1049h` a no-op when the mode is already on, so the + // ring's own copy stops clearing the alternate screen, and everything the + // ring holds from *before* that sequence (the shell scrollback the user + // had behind the program) is painted into the alternate buffer, which has + // no history to keep it and is left behind when the program exits. What + // the ring carries always wins on its own terms. + if let Some(modes) = st.modes.restore_bytes_beyond(&st.ring.modes()) { let _ = subscriber.send(DaemonMsg::Snapshot(modes)); } st.ring.replay(subscriber); @@ -4846,6 +4869,50 @@ mod tests { assert!(rx.try_recv().is_err()); } + /// The other side of the same coin, and the common case: reconnect a minute + /// after opening `vim` and the ring still holds the whole session, prefix + /// included. Re-sending the prefix then would turn the ring's own `?1049h` + /// into a no-op, so the shell scrollback the ring holds ahead of it would be + /// painted into the alternate screen — which keeps no history and is thrown + /// away when the program exits, leaving the user back on a blank primary + /// buffer instead of the prompt they left behind. + #[test] + fn a_prefix_the_ring_still_carries_is_left_to_the_ring() { + let mut st = test_state(true); + record_output(&mut st, b"$ vim notes.md\r\n"); + record_output(&mut st, b"\x1b[?1049h\x1b[?1002h\x1b[?1006hthe file\r\n"); + + let (tx, rx) = mpsc::channel(); + attach_subscriber(&mut st, tx); + + assert!( + matches!(rx.try_recv(), Ok(DaemonMsg::Size(_))), + "the ring speaks for its own modes; nothing goes ahead of it" + ); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(_)))); + assert!(rx.try_recv().is_err()); + } + + /// A mode the ring half-carries is a mode the ring cannot set: the front + /// cuts wherever the cap fell, and the emulator will not act on the tail of + /// a sequence any more than the fold does. + #[test] + fn a_prefix_the_ring_cut_in_half_is_restored() { + let mut st = test_state(true); + record_output(&mut st, b"\x1b[?1049h"); + // Four bytes over the cap, so the front eats exactly the `ESC [ ? 1` + // the sequence opens with and leaves the rest of it in place. + record_output(&mut st, &vec![b'.'; RING_CAP - 4]); + assert!(st.ring.flatten().starts_with(b"049h")); + + let (tx, rx) = mpsc::channel(); + attach_subscriber(&mut st, tx); + assert!( + matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(b)) if b == b"\x1b[?1049h"), + "the bytes left in the ring put no client on the alternate screen" + ); + } + /// An observer joins mid-session too, and reads the same pane state. #[test] fn observers_are_told_the_pane_modes_as_well() {