diff --git a/crates/tty7-core/src/daemon/handoff.rs b/crates/tty7-core/src/daemon/handoff.rs index 909df4e9..e4b07743 100644 --- a/crates/tty7-core/src/daemon/handoff.rs +++ b/crates/tty7-core/src/daemon/handoff.rs @@ -94,6 +94,8 @@ struct PaneRecord { agent: Option, agent_argv: Option>, agent_session: Option, + #[serde(default)] + head_modes: Option>, /// 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 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 { 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""); diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index bb475a97..7d6c820c 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -1420,6 +1420,11 @@ pub struct Carried { pub agent: Option, pub agent_argv: Option>, pub agent_session: Option, + /// 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>, } /// 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, + 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));