fix(daemon): leave the ring the modes it still carries

`replay_state` sends the mode fold ahead of the ring so the replayed
frames land in the buffer they were drawn for, and it sent every mode
that was on — including the ones the ring itself still switches on.
`?1049h` is a no-op in the emulator once the mode is already set, so the
ring's own copy then stopped clearing the alternate screen: everything
the ring holds *ahead* of that sequence, which is the shell scrollback
the user had behind the program, was painted into the alternate buffer
instead. That buffer keeps no history, so those lines were thrown away,
and the primary buffer the program's exit returns the client to was left
empty. Reconnecting a minute after opening `vim` is the ordinary case,
and it lost the prompt the user left behind.

Only the modes a replay of the ring cannot speak for go ahead of it now,
and they are read from a fold over the bytes the ring actually still
holds — so a sequence the front cut in half counts as lost, exactly as
it will for the emulator that reads the same bytes.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
This commit is contained in:
l0ng-ai
2026-09-10 14:02:13 +08:00
parent 237b2a14c4
commit 8ee9474045
2 changed files with 153 additions and 11 deletions
+79 -4
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
let missing: Vec<u16> = self
.on
.iter()
.copied()
.filter(|mode| !replayed.on.contains(mode))
.collect();
Self::bytes_for(&missing)
}
fn bytes_for(modes: &[u16]) -> Option<Vec<u8>> {
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();
+74 -7
View File
@@ -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<u8> {
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<DaemonMsg>) {
// 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() {