fix(daemon): restore a pane's terminal modes on re-attach (#774)

A pane's screen comes back on re-attach out of the replay ring, and the ring is
a window: eight megabytes wide, dropped from the front as it fills. That is the
right shape for text, which is only worth what is still on screen, and the
wrong one for modes. A full-screen program announces itself exactly once —
`btop` sends `?1049h` and its mouse-reporting modes when it starts and then
does nothing but refresh — so a long enough run of refreshes pushes the only
copy of that announcement out of the front of the ring. What the client
replays is then a screenful of alternate-buffer frames with nothing left to say
they belong on the alternate buffer: it paints them onto its primary screen
with reporting off, and `wheel_route`, which reads exactly those modes, sends
the wheel to the scrollback of a screen that has none. That is the "a screen
that should not scroll starts scrolling" in the report.

`replay_state` already refuses to rely on the ring for anything that matters —
cwd, prompt state, the remote context, the agent, the exit — because all of
those are facts about the pane rather than bytes on it. The modes are the same
kind of fact and were the exception, so the daemon now folds the bytes it hands
the ring into a small tracker (`core::term_modes`) and `replay_state` re-sends
what is still on. Tracked are the modes that decide input routing or which
buffer is on screen: the alternate screen in its three spellings, the mouse
reporting level and its encodings, alternate scroll, DECCKM, focus reporting
and bracketed paste. They are replayed in the order the application set them,
because the emulator treats the reporting modes as a level and not as
independent bits, so the last one set has to be last here too.

The frame goes *ahead* of the ring rather than after it. That way the replayed
frames are painted into the buffer they were drawn for, and re-entering an
alternate screen the ring turns out to still carry is a no-op in the emulator,
so a prefix and a ring that both carry the mode cannot fight. Where the ring
does still carry a toggle it wins on its own terms, since the fold runs over
every byte the pane ever wrote and therefore agrees with the ring's last word
on any mode the ring still mentions.

Cursor visibility (`?25`) and autowrap (`?7`) are deliberately not tracked. Any
frame of a running TUI repaints them within milliseconds, whereas restoring a
stale `?25l` would leave a shell with an invisible cursor — a worse failure
than the one being fixed, and one the existing `restore_preamble` already goes
out of its way to avoid. A daemon handoff starts the fold empty rather than
carrying it, so a pane adopted across a daemon restart is no worse off than it
is today; the ring it carries is all it ever had.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
This commit is contained in:
l0ng-ai
2026-09-09 18:09:15 +08:00
parent a6e1271697
commit 237b2a14c4
4 changed files with 418 additions and 1 deletions
+1
View File
@@ -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;
+276
View File
@@ -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<u16>,
state: State,
params: Vec<u8>,
}
#[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<Vec<u8>> {
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::<u16>() 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());
}
}
+89 -1
View File
@@ -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<String>,
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<ShellSpec>,
@@ -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<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() {
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);
+52
View File
@@ -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::*;