fix(daemon): a pane keeps its private modes across a handoff (#1145)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Adam Hitchcock
2026-10-10 21:53:49 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2afebab175
commit a39d97d5c5
2 changed files with 74 additions and 3 deletions
+17
View File
@@ -94,6 +94,8 @@ struct PaneRecord {
agent: Option<crate::core::cli_agent::CLIAgent>,
agent_argv: Option<Vec<String>>,
agent_session: Option<crate::core::cli_agent::AgentSessionState>,
#[serde(default)]
head_modes: Option<Vec<u8>>,
/// Length of this pane's ring in the data section, which follows the
/// manifest in pane order.
ring_len: u32,
@@ -242,6 +244,7 @@ fn stage(panes: &[Carried], next_pane_id: u64) -> std::io::Result<std::fs::File>
agent: pane.agent,
agent_argv: pane.agent_argv.clone(),
agent_session: pane.agent_session.clone(),
head_modes: pane.head_modes.clone(),
ring_len: encoded.len() as u32,
});
data.extend_from_slice(&encoded);
@@ -376,6 +379,7 @@ pub fn adopt(fd: RawFd) -> Option<Adopted> {
agent: record.agent,
agent_argv: record.agent_argv,
agent_session: record.agent_session,
head_modes: record.head_modes,
});
}
@@ -420,6 +424,7 @@ mod tests {
agent: None,
agent_argv: None,
agent_session: None,
head_modes: None,
}
}
@@ -453,6 +458,18 @@ mod tests {
);
}
#[test]
fn a_pane_keeps_the_modes_its_ring_no_longer_holds() {
let mut tui = carried(5, 21, b"long after startup");
tui.head_modes = Some(b"\x1b[>1u\x1b[?2031h".to_vec());
let staged = stage(&[tui], 6).expect("stage");
let adopted = adopt(std::os::fd::IntoRawFd::into_raw_fd(staged)).expect("read back");
assert_eq!(
adopted.panes[0].head_modes.as_deref(),
Some(&b"\x1b[>1u\x1b[?2031h"[..])
);
}
#[test]
fn a_pane_that_printed_nothing_still_crosses() {
let mut empty = carried(3, 20, b"");
+57 -3
View File
@@ -1420,6 +1420,11 @@ pub struct Carried {
pub agent: Option<crate::core::cli_agent::CLIAgent>,
pub agent_argv: Option<Vec<String>>,
pub agent_session: Option<crate::core::cli_agent::AgentSessionState>,
/// The modes the ring dropped off its front, as bytes that switch them
/// back on. A program sets them once at startup, so after an `exec` a
/// long-running TUI would otherwise lose its alternate screen, mouse, 2031
/// and kitty keyboard flags for good.
pub head_modes: Option<Vec<u8>>,
}
/// A pty master this process inherited from its own previous image.
@@ -2005,6 +2010,7 @@ impl DaemonPane {
agent: st.agent,
agent_argv: st.agent_argv.clone(),
agent_session: st.agent_session.clone(),
head_modes: st.ring.head_modes.restore_bytes(),
})
}
@@ -2037,7 +2043,8 @@ impl DaemonPane {
})
.unwrap_or(carried.size);
let mut ring = ReplayRing::seeded(carried.ring, size);
let (mut ring, modes) =
ReplayRing::adopted(carried.ring, size, carried.head_modes.as_deref());
// Not a restore banner: nothing was lost, so there is nothing to
// announce and nothing to reset. The shell below these bytes is the
// same shell that wrote them.
@@ -2080,7 +2087,7 @@ impl DaemonPane {
},
remote_prompt_seen: false,
ssh_phase: None,
modes: TerminalModes::default(),
modes,
remote: carried.remote,
agent: carried.agent,
agent_session: carried.agent_session,
@@ -3139,6 +3146,22 @@ impl ReplayRing {
ring
}
/// A ring carried across a handoff, with the pane's modes folded back
/// from it: `head` is the carried [`Self::head_modes`], and the pane's
/// modes are that plus every byte the ring still holds.
fn adopted(
segments: Vec<crate::daemon::scrollback::Segment>,
size: WinSize,
head: Option<&[u8]>,
) -> (Self, TerminalModes) {
let mut ring = Self::seeded(segments, size);
if let Some(bytes) = head {
ring.head_modes.feed(bytes);
}
let modes = ring.modes_from(ring.head_modes.clone());
(ring, modes)
}
/// The geometry new output would be recorded at.
fn tail_size(&self) -> WinSize {
self.segments
@@ -3269,7 +3292,11 @@ impl ReplayRing {
/// [`Self::evict`]), and the answer here has to be the one the client's
/// emulator will reach from what is left.
fn modes(&self) -> TerminalModes {
let mut modes = TerminalModes::new();
self.modes_from(TerminalModes::new())
}
/// `start` with every byte the ring holds folded into it.
fn modes_from(&self, mut modes: TerminalModes) -> TerminalModes {
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.
@@ -5149,6 +5176,33 @@ mod tests {
);
}
#[test]
fn a_handoff_keeps_the_modes_the_ring_dropped_off_its_front() {
use crate::daemon::scrollback::Segment;
let mut head = TerminalModes::new();
head.feed(b"\x1b[?1049h\x1b[?2031h\x1b[>1u");
let (ring, modes) = ReplayRing::adopted(
vec![Segment {
size: ws(80, 24),
bytes: b"long after startup".to_vec(),
}],
ws(80, 24),
head.restore_bytes().as_deref(),
);
assert!(modes.is_on(crate::core::term_modes::COLOR_SCHEME_UPDATES));
let prelude = modes
.replay_prelude(&ring.head_modes, &ring.modes())
.expect("a prelude");
let has = |needle: &[u8]| prelude.windows(needle.len()).any(|w| w == needle);
assert!(has(b"\x1b[?2031h"));
assert!(
has(b"\x1b[>1u"),
"the keyboard flags come back from the head fold, so it has to cross the exec too"
);
}
#[test]
fn an_empty_seed_leaves_an_ordinary_ring() {
let mut ring = ReplayRing::seeded(Vec::new(), ws(80, 24));