diff --git a/crates/tty7-core/src/client/pane.rs b/crates/tty7-core/src/client/pane.rs index c1f64fc8..e8cc79e4 100644 --- a/crates/tty7-core/src/client/pane.rs +++ b/crates/tty7-core/src/client/pane.rs @@ -160,6 +160,10 @@ impl PaneSession { shell, owner, workspace, + // Restoring a dead pane's screen is a window concern: it exists to + // put a workspace back the way its user left it. Nothing that + // scripts panes through this library has a screen to put back. + restore: None, } .encode(&mut stream)?; let mut session = PaneSession::over(stream, 0)?; diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 1996de57..420a005d 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -268,6 +268,17 @@ pub struct Config { pub agent_commands: HashMap, #[serde(default = "default_true")] pub restore_agent_sessions: bool, + /// Keep a capped tail of each pane's output on disk, so a daemon that dies + /// without getting to hand off — a crash, a `kill -9`, a reboot — comes + /// back to panes that still show what was in them. + /// + /// Off by default, and the default is the interesting part. What the ring + /// holds is whatever the pane printed, which routinely includes secrets: + /// an echoed token, the output of `env`, an agent's transcript. In memory + /// they die with the daemon. Writing them down is the entire feature and + /// also its entire cost, so it is the user who decides to pay it. + #[serde(default)] + pub persist_scrollback: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -524,6 +535,7 @@ impl Default for Config { command_frecency: HashMap::new(), agent_commands: HashMap::new(), restore_agent_sessions: true, + persist_scrollback: false, } } } diff --git a/crates/tty7-core/src/daemon/mod.rs b/crates/tty7-core/src/daemon/mod.rs index 72f72d99..d6b30d82 100644 --- a/crates/tty7-core/src/daemon/mod.rs +++ b/crates/tty7-core/src/daemon/mod.rs @@ -8,6 +8,7 @@ pub mod protocol; pub(crate) mod remote; pub mod remote_link; pub mod router; +pub mod scrollback; pub mod server; pub mod singleton; pub mod spawn; diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index aef0e537..b18c1e92 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -921,6 +921,41 @@ fn push_image_frame(frames: &mut Vec, frame: Vec) { } } +/// The screen a restored pane opens with. +/// +/// `segments` is what some earlier pane — the one this one replaces after a +/// daemon died without handing off — last had on it. `banner` is the sentence +/// that says so, supplied by the client because the daemon has no locale of its +/// own. Both are decoration over a shell that is unambiguously new: nothing +/// here revives a process. +pub struct Restore { + pub segments: Vec, + pub banner: Option, +} + +/// The bytes that separate restored output from the new shell's own. +/// +/// The resets are not cosmetic. A snapshot is trimmed at the front, so it can +/// begin in the middle of anything: an SGR run whose reset was cut, a hidden +/// cursor, a disabled autowrap, an alternate screen whose `?1049h` survived but +/// whose `?1049l` never came because the daemon died while `vim` was open. +/// Replaying that leaves the emulator in a state the incoming shell did not ask +/// for and cannot see. Leaving the alternate screen also does the useful thing +/// in the common case: the primary buffer still holds the pre-`vim` scrollback +/// from earlier in the same snapshot. +fn restore_preamble(banner: Option<&str>) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"\x1b[?1049l\x1b[?25h\x1b[?7h\x1b[0m"); + if let Some(banner) = banner.map(str::trim).filter(|b| !b.is_empty()) { + out.extend_from_slice(b"\r\n\x1b[2m\xe2\x94\x80\xe2\x94\x80 "); + // A newline inside the banner would be a client writing multiple lines + // through a one-line hole; the rest of the sequence assumes one line. + out.extend_from_slice(banner.replace(['\r', '\n'], " ").as_bytes()); + out.extend_from_slice(b" \xe2\x94\x80\xe2\x94\x80\x1b[0m\r\n"); + } + out +} + impl DaemonPane { pub fn spawn( id: u64, @@ -929,6 +964,7 @@ impl DaemonPane { shell: Option, owner: Option, workspace: Option, + restore: Option, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result> { let pty_size = pty_size(size); @@ -945,9 +981,18 @@ impl DaemonPane { let reader_handle = pair.master.try_clone_reader()?; let writer = Arc::new(Mutex::new(pair.master.take_writer()?)); + let ring = match restore { + Some(restore) => { + let mut ring = ReplayRing::seeded(restore.segments, size); + ring.append(&restore_preamble(restore.banner.as_deref())); + ring + } + None => ReplayRing::new(size), + }; + let state = Arc::new(Mutex::new(PaneState { id, - ring: ReplayRing::new(size), + ring, subscriber: None, subscriber_epoch: 0, observers: Vec::new(), @@ -1485,6 +1530,21 @@ impl DaemonPane { cached.or_else(|| self.foreground_remote_context()) } + /// The pane's screen, capped for storage, with the mark that says how much + /// output it had produced when the copy was taken. + /// + /// Both come from one acquisition of the state lock. Reading them apart + /// would let output land in between, and the writer would then record a + /// mark that claims to cover bytes its snapshot does not have — a pane + /// that stopped producing at that moment would keep the stale copy for + /// good, because its mark would never move again. + pub fn scrollback_snapshot(&self) -> (Vec, u64) { + let st = self.state.lock().unwrap(); + let mut segments = st.ring.snapshot(); + crate::daemon::scrollback::trim_to(&mut segments, crate::daemon::scrollback::SNAPSHOT_CAP); + (segments, st.ring.appended) + } + pub fn kill(&self) { self.hangup(); } @@ -1671,6 +1731,12 @@ fn pty_size(size: WinSize) -> PtySize { struct ReplayRing { segments: VecDeque, len: usize, + /// Bytes ever appended, never reset — the mark the scrollback writer uses to + /// tell a ring that has moved from one that has not. Comparing lengths would + /// not do it: a ring at its cap stays exactly `RING_CAP` long no matter how + /// much output flows through it, which is precisely the busy pane whose + /// snapshot is most stale. + appended: u64, } struct RingSegment { @@ -1700,9 +1766,49 @@ impl ReplayRing { Self { segments: VecDeque::from([RingSegment::empty(size)]), len: 0, + appended: 0, } } + /// A ring that starts with output some earlier pane produced. + /// + /// Used when a pane is restored from disk: the saved segments go in ahead + /// of anything the new shell writes, so a client attaching sees the screen + /// it lost and then the new prompt below it. The seeded bytes are counted + /// into `len` — they are subject to the same cap as live output, and the + /// cap is far above what a snapshot can hold — but not into `appended`, + /// which exists to answer "has this pane produced anything since the last + /// snapshot?" and would otherwise answer yes for a pane that never ran. + fn seeded(segments: Vec, size: WinSize) -> Self { + let mut ring = Self::new(size); + if segments.is_empty() { + return ring; + } + ring.segments.clear(); + for seg in segments { + ring.len += seg.bytes.len(); + ring.segments.push_back(RingSegment { + size: seg.size, + bytes: seg.bytes.into(), + }); + } + // Whatever the shell writes from here was written at *this* pane's + // size, which is not necessarily the size the snapshot was taken at. + ring.resize(size); + ring + } + + fn snapshot(&self) -> Vec { + self.segments + .iter() + .filter(|seg| !seg.bytes.is_empty()) + .map(|seg| crate::daemon::scrollback::Segment { + size: seg.size, + bytes: seg.to_vec(), + }) + .collect() + } + fn tail(&mut self) -> &mut RingSegment { self.segments.back_mut().expect("ring always has a tail") } @@ -1727,6 +1833,7 @@ impl ReplayRing { } fn append(&mut self, bytes: &[u8]) { + self.appended = self.appended.saturating_add(bytes.len() as u64); if bytes.len() >= RING_CAP { let size = self.tail().size; self.segments.clear(); @@ -2446,6 +2553,7 @@ mod tests { }), None, None, + None, || {}, ) .expect("spawn pane"); @@ -2819,6 +2927,107 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[test] + fn a_seeded_ring_replays_the_old_screen_at_the_size_it_was_written() { + use crate::daemon::scrollback::Segment; + + let mut ring = ReplayRing::seeded( + vec![Segment { + size: ws(100, 24), + bytes: b"what the dead pane had on it".to_vec(), + }], + ws(80, 30), + ); + ring.append(b"the new shell's prompt"); + + let (tx, rx) = mpsc::channel(); + ring.replay(&tx); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Size(s)) if s == ws(100, 24))); + assert!( + matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(b)) if b == b"what the dead pane had on it"), + "restored output has to be replayed at the width it was produced at, or the client \ + rewraps it to whatever the window happens to be now" + ); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Size(s)) if s == ws(80, 30))); + assert!( + matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(b)) if b == b"the new shell's prompt") + ); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn seeding_does_not_make_an_untouched_pane_look_like_it_produced_output() { + use crate::daemon::scrollback::Segment; + + let ring = ReplayRing::seeded( + vec![Segment { + size: ws(80, 24), + bytes: b"restored".to_vec(), + }], + ws(80, 24), + ); + assert_eq!( + ring.appended, 0, + "the mark answers 'has this pane written anything since the last snapshot', and \ + bytes it was handed at birth are not an answer of yes" + ); + } + + #[test] + fn an_empty_seed_leaves_an_ordinary_ring() { + let mut ring = ReplayRing::seeded(Vec::new(), ws(80, 24)); + ring.append(b"output"); + assert_eq!( + ring.segments.len(), + 1, + "the ring always has exactly one tail" + ); + assert_eq!(ring.flatten(), b"output"); + } + + #[test] + fn the_restore_preamble_hands_the_new_shell_a_terminal_it_can_use() { + let bytes = restore_preamble(Some("this shell is new")); + let text = String::from_utf8(bytes).expect("the preamble is text"); + // A snapshot is cut at the front, so it can begin inside anything: an + // unterminated SGR run, a hidden cursor, an alternate screen whose exit + // never came because the daemon died while a full-screen app was up. + assert!(text.contains("\x1b[?1049l"), "leave any alternate screen"); + assert!(text.contains("\x1b[?25h"), "give the cursor back"); + assert!(text.contains("\x1b[?7h"), "put autowrap back"); + assert!(text.contains("\x1b[0m"), "drop any colour left mid-run"); + assert!(text.contains("this shell is new")); + } + + #[test] + fn a_banner_cannot_smuggle_extra_lines_into_the_pane() { + let text = String::from_utf8(restore_preamble(Some("first\r\nsecond"))).unwrap(); + assert!( + text.contains("first second"), + "the rule is drawn as one line, so the words placed in it stay on one line" + ); + assert_eq!( + text.matches("\r\n").count(), + 2, + "one break before the rule and one after it, and no others" + ); + } + + #[test] + fn a_pane_with_nothing_to_say_still_gets_the_resets() { + for banner in [None, Some(""), Some(" ")] { + let text = String::from_utf8(restore_preamble(banner)).unwrap(); + assert!( + text.starts_with("\x1b[?1049l"), + "the terminal still has to be handed back in a usable state" + ); + assert!( + !text.contains('\n'), + "with no words there is no rule to draw, so no line is spent on one" + ); + } + } + #[test] fn ring_idle_resizes_collapse_and_replay_ends_at_current_size() { let mut ring = ReplayRing::new(ws(100, 24)); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 223ef869..572ed23f 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -16,6 +16,13 @@ pub const FEATURE_PANE_OWNER: &str = "pane-owner"; /// older daemon it must keep reflowing locally at request time. pub const FEATURE_RESIZE_ECHO: &str = "resize-echo"; +/// The daemon can seed a new pane with the screen a dead one left behind, named +/// by `ClientMsg::Spawn`'s `restore` field. A client that does not see this +/// feature leaves the field out: an older daemon would ignore it and spawn a +/// blank pane, which is the same outcome, but sending it would make the wire +/// claim a restore that never happened. +pub const FEATURE_RESTORE_SCROLLBACK: &str = "restore-scrollback"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonVersion { pub protocol: u32, @@ -35,6 +42,7 @@ impl DaemonVersion { features: vec![ FEATURE_PANE_OWNER.to_string(), FEATURE_RESIZE_ECHO.to_string(), + FEATURE_RESTORE_SCROLLBACK.to_string(), ], instance: process_instance().to_string(), } @@ -584,6 +592,7 @@ pub enum ClientMsg { shell: Option, owner: Option, workspace: Option, + restore: Option, }, Attach { pane_id: u64, @@ -855,6 +864,29 @@ struct OwnedSpawn { owner: Option, #[serde(default)] workspace: Option, + #[serde(default)] + restore: Option, +} + +/// "This pane replaces one that died with the daemon." +/// +/// Carried on a spawn rather than an attach because there is nothing to attach +/// to: the process is gone. The daemon looks up what pane `pane_id` last had on +/// its screen and seeds the new pane's ring with it, so the window shows the +/// output it lost under a shell that is plainly new. +/// +/// `banner` is the line drawn between the two, and it comes from the client +/// because the daemon has no locale — it serves a GUI that might be running in +/// any language, and a CLI whose output is always English. A client that has +/// nothing to say can leave it out; the reset sequence is emitted either way. +/// +/// Old daemons decode this frame without the field and simply spawn a blank +/// pane, which is what they did before it existed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestoreFrom { + pub pane_id: u64, + #[serde(default)] + pub banner: Option, } impl ClientMsg { @@ -866,6 +898,7 @@ impl ClientMsg { shell: None, owner: None, workspace: None, + restore: None, } => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?), ClientMsg::Spawn { cwd, @@ -873,6 +906,7 @@ impl ClientMsg { shell: shell @ Some(_), owner: None, workspace: None, + restore: None, } => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?), ClientMsg::Spawn { cwd, @@ -880,6 +914,7 @@ impl ClientMsg { shell, owner, workspace, + restore, } => write_frame( w, kind::SPAWN_OWNED, @@ -889,6 +924,7 @@ impl ClientMsg { shell: shell.clone(), owner: owner.clone(), workspace: workspace.clone(), + restore: restore.clone(), })?, ), ClientMsg::Attach { pane_id, size } => { @@ -967,6 +1003,7 @@ impl ClientMsg { shell: None, owner: None, workspace: None, + restore: None, } } kind::SPAWN_SHELL => { @@ -977,6 +1014,7 @@ impl ClientMsg { shell, owner: None, workspace: None, + restore: None, } } kind::SPAWN_OWNED => { @@ -986,6 +1024,7 @@ impl ClientMsg { shell, owner, workspace, + restore, } = from_json(&payload)?; ClientMsg::Spawn { cwd, @@ -993,6 +1032,7 @@ impl ClientMsg { shell, owner, workspace, + restore, } } kind::ATTACH => { @@ -1226,6 +1266,7 @@ mod tests { shell: None, owner: None, workspace: None, + restore: None, }, ClientMsg::Resize(SIZE), ClientMsg::Input(vec![b'l', b's', b'\r']), @@ -1280,6 +1321,7 @@ mod tests { shell: None, owner: None, workspace: None, + restore: None, }, ClientMsg::Spawn { cwd: None, @@ -1287,6 +1329,7 @@ mod tests { shell: None, owner: None, workspace: None, + restore: None, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1298,6 +1341,7 @@ mod tests { }), owner: None, workspace: None, + restore: None, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1305,6 +1349,7 @@ mod tests { shell: None, owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()), workspace: None, + restore: None, }, ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), @@ -1312,6 +1357,7 @@ mod tests { shell: None, owner: None, workspace: Some("ws-main".into()), + restore: None, }, ClientMsg::Observe { pane_id: 42, @@ -1615,6 +1661,7 @@ mod tests { shell: None, owner: None, workspace: None, + restore: None, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1634,6 +1681,7 @@ mod tests { shell: None, owner: None, workspace: None, + restore: None, } ); } @@ -1651,6 +1699,7 @@ mod tests { shell: Some(shell.clone()), owner: None, workspace: None, + restore: None, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1665,6 +1714,7 @@ mod tests { shell: Some(shell), owner: None, workspace: None, + restore: None, } ); } @@ -1681,6 +1731,7 @@ mod tests { }), owner: Some("bda10e44-02de-44a0-8412-ec1cda2b5f5b".into()), workspace: Some("ws-7".into()), + restore: None, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); @@ -1710,6 +1761,7 @@ mod tests { shell: None, owner: None, workspace: None, + restore: None, } ); } @@ -1722,6 +1774,7 @@ mod tests { shell: None, owner: None, workspace: Some("ws-main".into()), + restore: None, }; let mut buf = Vec::new(); msg.encode(&mut buf).unwrap(); diff --git a/crates/tty7-core/src/daemon/scrollback.rs b/crates/tty7-core/src/daemon/scrollback.rs new file mode 100644 index 00000000..3dbe114a --- /dev/null +++ b/crates/tty7-core/src/daemon/scrollback.rs @@ -0,0 +1,410 @@ +//! What a pane looked like, kept where the daemon's own death cannot reach it. +//! +//! The replay ring already holds every pane's recent output so a reattaching +//! client can be shown the screen it left. It holds it in this process, which +//! makes it exactly as durable as this process: a crash, a `kill -9`, or a +//! reboot takes the panes and their scrollback together, and the window comes +//! back to a row of blank shells with no trace of what was in them. +//! +//! The planned upgrade path does not lose anything — see `daemon::handoff`, +//! which carries the live ptys and their rings into the new binary without the +//! shells ever noticing. This module is for the paths a handoff cannot cover, +//! where the process does not get to run any code on its way out. It cannot +//! keep the *processes*; nothing written to a file can. It keeps the picture. +//! +//! Consequences of that being the goal: +//! +//! - **The snapshot is periodic, not write-through.** Every pty byte reaching +//! the disk would be an enormous amount of write amplification to buy a few +//! seconds of freshness at the tail of a crash. The writer runs on a timer +//! and skips panes whose ring has not changed. +//! - **It is capped far below the ring.** [`SNAPSHOT_CAP`] keeps what fills a +//! screen or two, not the ring's whole 8 MiB. The value of scrollback decays +//! steeply with distance from the bottom, and every byte here is a byte of +//! someone's terminal sitting on disk. +//! - **It is off unless asked for.** These bytes include whatever was echoed +//! into the pane: tokens, `env` output, an agent's transcript. In memory +//! they die with the daemon. On disk they outlive it, which is the whole +//! point and also the whole risk, so the choice is the user's. +//! - **It is dropped as soon as it is meaningless.** A pane that is closed, or +//! that no workspace refers to any more, has its file removed. Retention is +//! by relevance, not by calendar: a snapshot of a pane nobody will reopen is +//! not worth keeping for a month, or for an hour. + +use std::collections::HashSet; +use std::path::PathBuf; + +use crate::daemon::protocol::WinSize; + +/// How much of a pane's ring reaches the disk, per pane. +/// +/// A screenful of dense output is on the order of 20 KiB once escape sequences +/// are counted, so this is "the last several screens" rather than "everything". +/// The in-memory ring stays at its own, much larger, cap: this bound is about +/// what is worth persisting, not what is worth keeping while running. +pub const SNAPSHOT_CAP: usize = 256 * 1024; + +/// How often the writer looks for panes whose ring has moved. +pub const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + +const MAGIC: &[u8; 8] = b"TTY7SB\x01\x00"; + +/// One geometry-homogeneous run of pane output, the unit the replay ring is +/// segmented into: replaying a segment means telling the client the size those +/// bytes were written at, then handing it the bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Segment { + pub size: WinSize, + pub bytes: Vec, +} + +/// Drop bytes from the oldest segments until the total fits `cap`. +/// +/// Trimming from the front is what makes a truncated snapshot still make sense: +/// terminal output is only interpretable forwards, so the tail can stand on its +/// own in a way the head cannot. A partially eaten segment keeps its geometry — +/// the bytes that remain were still written at that size. +pub fn trim_to(segments: &mut Vec, cap: usize) { + let total: usize = segments.iter().map(|s| s.bytes.len()).sum(); + let mut over = total.saturating_sub(cap); + while over > 0 { + let Some(head) = segments.first_mut() else { + return; + }; + let drop = over.min(head.bytes.len()); + head.bytes.drain(..drop); + over -= drop; + if !head.bytes.is_empty() { + continue; + } + // The last segment is kept even when it is empty: the ring this feeds + // back into is defined by always having a tail to append to. + if segments.len() == 1 { + return; + } + segments.remove(0); + } +} + +pub fn encode(segments: &[Segment]) -> Vec { + let mut out = Vec::with_capacity( + MAGIC.len() + 4 + segments.iter().map(|s| s.bytes.len() + 12).sum::(), + ); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&(segments.len() as u32).to_le_bytes()); + for seg in segments { + out.extend_from_slice(&seg.size.cols.to_le_bytes()); + out.extend_from_slice(&seg.size.rows.to_le_bytes()); + out.extend_from_slice(&seg.size.cell_w.to_le_bytes()); + out.extend_from_slice(&seg.size.cell_h.to_le_bytes()); + out.extend_from_slice(&(seg.bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(&seg.bytes); + } + out +} + +/// `None` for anything that is not a snapshot this build wrote. +/// +/// A truncated file is the ordinary failure here — the writer renames into +/// place, but a filesystem that reordered the rename against the data, or a +/// half-written file from an older scheme, both land as a short read. There is +/// nothing to salvage and nothing at stake in refusing: the pane comes back +/// blank, which is what it did before this module existed. +pub fn decode(raw: &[u8]) -> Option> { + let mut cur = raw.strip_prefix(MAGIC.as_slice())?; + let count = u32::from_le_bytes(take(&mut cur, 4)?.try_into().ok()?) as usize; + let mut segments = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + let cols = u16::from_le_bytes(take(&mut cur, 2)?.try_into().ok()?); + let rows = u16::from_le_bytes(take(&mut cur, 2)?.try_into().ok()?); + let cell_w = u16::from_le_bytes(take(&mut cur, 2)?.try_into().ok()?); + let cell_h = u16::from_le_bytes(take(&mut cur, 2)?.try_into().ok()?); + let len = u32::from_le_bytes(take(&mut cur, 4)?.try_into().ok()?) as usize; + let bytes = take(&mut cur, len)?.to_vec(); + segments.push(Segment { + size: WinSize { + cols, + rows, + cell_w, + cell_h, + }, + bytes, + }); + } + Some(segments) +} + +fn take<'a>(cur: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { + if cur.len() < n { + return None; + } + let (head, rest) = cur.split_at(n); + *cur = rest; + Some(head) +} + +/// Whether the user has asked for scrollback to outlive the daemon. +pub fn enabled() -> bool { + crate::core::config::Config::load().persist_scrollback +} + +fn dir() -> Option { + crate::core::config::config_path("scrollback") +} + +fn path_for(pane_id: u64) -> Option { + Some(dir()?.join(format!("{pane_id}.bin"))) +} + +/// Write one pane's snapshot, replacing whatever was there. +/// +/// Renamed into place so a reader never sees a half-written file, and mode 0600 +/// from creation rather than after the fact — a window in which someone else's +/// terminal output is world-readable is not one worth leaving open. +pub fn save(pane_id: u64, segments: &[Segment]) { + let Some(path) = path_for(pane_id) else { + return; + }; + let Some(parent) = path.parent().map(|p| p.to_path_buf()) else { + return; + }; + if let Err(e) = std::fs::create_dir_all(&parent) { + log::debug!("no scrollback directory ({e}); pane {pane_id} is not persisted"); + return; + } + let temp = parent.join(format!("{pane_id}.{}.tmp", std::process::id())); + if let Err(e) = write_private(&temp, &encode(segments)) { + log::debug!("could not stage pane {pane_id}'s scrollback: {e}"); + let _ = std::fs::remove_file(&temp); + return; + } + if let Err(e) = std::fs::rename(&temp, &path) { + log::debug!("could not store pane {pane_id}'s scrollback: {e}"); + let _ = std::fs::remove_file(&temp); + } +} + +#[cfg(unix)] +fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes) +} + +#[cfg(not(unix))] +fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + // Windows inherits the config directory's ACL, which is already per-user. + std::fs::write(path, bytes) +} + +pub fn load(pane_id: u64) -> Option> { + let raw = std::fs::read(path_for(pane_id)?).ok()?; + match decode(&raw) { + Some(segments) => Some(segments), + None => { + log::debug!("pane {pane_id}'s stored scrollback is not readable by this build"); + None + } + } +} + +pub fn forget(pane_id: u64) { + if let Some(path) = path_for(pane_id) { + let _ = std::fs::remove_file(path); + } +} + +/// Drop snapshots for panes that nothing refers to any more. +/// +/// Called with the set of pane ids the machine tree still names. A file outside +/// it belongs to a pane that was closed, or that lived in a workspace since +/// deleted; either way nobody can ask to restore it, so keeping it is only a +/// way to leave terminal output on disk indefinitely. +pub fn sweep(keep: &HashSet) { + let Some(dir) = dir() else { + return; + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Some(id) = name + .strip_suffix(".bin") + .and_then(|stem| stem.parse::().ok()) + else { + continue; + }; + if !keep.contains(&id) { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn size(cols: u16, rows: u16) -> WinSize { + WinSize { + cols, + rows, + cell_w: 8, + cell_h: 17, + } + } + + fn seg(cols: u16, bytes: &[u8]) -> Segment { + Segment { + size: size(cols, 24), + bytes: bytes.to_vec(), + } + } + + #[test] + fn segments_survive_the_round_trip_with_their_geometry() { + let segments = vec![seg(80, b"before the resize"), seg(120, b"after it")]; + let decoded = decode(&encode(&segments)).expect("what we wrote is readable"); + assert_eq!( + decoded, segments, + "a replayed snapshot has to say which size each run of bytes was written at, \ + or the client rewraps old output to the current width" + ); + } + + #[test] + fn a_truncated_or_foreign_file_decodes_to_nothing() { + let raw = encode(&[seg(80, b"hello")]); + assert!( + decode(&raw[..raw.len() - 2]).is_none(), + "a short read must not be mistaken for a shorter snapshot" + ); + assert!(decode(b"").is_none(), "an empty file is not a snapshot"); + assert!( + decode(b"PLAIN TEXT, NOT OURS").is_none(), + "only files this scheme wrote are read back" + ); + } + + #[test] + fn trimming_keeps_the_tail_and_the_geometry_of_what_it_keeps() { + let mut segments = vec![seg(80, b"0123456789"), seg(120, b"abcdefghij")]; + trim_to(&mut segments, 12); + let kept: Vec = segments.iter().flat_map(|s| s.bytes.clone()).collect(); + assert_eq!( + kept, b"89abcdefghij", + "terminal output only reads forwards, so a cap has to eat the head" + ); + assert_eq!( + segments[0].size, + size(80, 24), + "the bytes left in a partly eaten segment were still written at its size" + ); + } + + #[test] + fn trimming_below_one_segment_still_leaves_something_to_replay() { + let mut segments = vec![seg(80, b"0123456789")]; + trim_to(&mut segments, 0); + assert_eq!( + segments.len(), + 1, + "the ring always has a tail; so does this" + ); + assert!(segments[0].bytes.is_empty()); + } + + /// The same shared temp directory every other module's tests pin, by the + /// same name: the override is a process-wide `OnceLock`, so the first + /// caller decides for all of them and agreeing on the path is what keeps + /// that harmless. + fn pin_config_dir() { + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + crate::core::config::set_config_dir(dir); + } + + #[test] + fn a_stored_screen_comes_back_and_can_be_dropped() { + pin_config_dir(); + let pane = 90_001; + save(pane, &[seg(80, b"what the pane had on it")]); + assert_eq!( + load(pane).expect("a saved screen is readable"), + vec![seg(80, b"what the pane had on it")], + ); + forget(pane); + assert!( + load(pane).is_none(), + "a pane the user closed leaves nothing behind to restore" + ); + } + + #[test] + fn saving_twice_replaces_rather_than_appends() { + pin_config_dir(); + let pane = 90_002; + save(pane, &[seg(80, b"first")]); + save(pane, &[seg(80, b"second")]); + assert_eq!( + load(pane).expect("still readable"), + vec![seg(80, b"second")], + "each write is the pane's current screen, not another slice of history" + ); + forget(pane); + } + + #[test] + fn the_sweep_keeps_only_panes_something_can_still_ask_for() { + pin_config_dir(); + let (kept, dropped) = (90_003, 90_004); + save(kept, &[seg(80, b"in a workspace")]); + save(dropped, &[seg(80, b"in no workspace")]); + sweep(&HashSet::from([kept])); + assert!( + load(kept).is_some(), + "a pane a tree still names is restorable" + ); + assert!( + load(dropped).is_none(), + "nobody can ask to restore a pane no tree refers to, so its output must not sit on disk" + ); + forget(kept); + } + + #[cfg(unix)] + #[test] + fn a_stored_screen_is_not_readable_by_anyone_else() { + use std::os::unix::fs::PermissionsExt as _; + pin_config_dir(); + let pane = 90_005; + save(pane, &[seg(80, b"a token someone echoed")]); + let mode = std::fs::metadata(path_for(pane).expect("a path under the config dir")) + .expect("the file exists") + .permissions() + .mode(); + assert_eq!( + mode & 0o077, + 0, + "this file is a copy of someone's terminal; group and other get nothing" + ); + forget(pane); + } + + #[test] + fn an_empty_snapshot_round_trips() { + assert_eq!( + decode(&encode(&[])).expect("still a valid snapshot"), + Vec::new(), + "a pane that has printed nothing is not a corrupt file" + ); + } +} diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 5bbe1156..facc12a0 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -69,6 +69,10 @@ impl Registry { } } + fn all(&self) -> Vec> { + self.panes.lock().unwrap().values().cloned().collect() + } + fn list(&self) -> Vec { self.panes .lock() @@ -135,6 +139,128 @@ fn spawn_orphan_sweep(registry: Arc) { } } +/// Pane ids that something can still ask to see again: every pane a workspace +/// tree names, plus every pane this daemon is running. +/// +/// The registry half matters for a pane spawned since the tree was last +/// written — without it a sweep landing in that window would delete the +/// snapshot of a pane that is very much alive, and the writer would put it +/// back seconds later. +fn restorable_pane_ids(registry: &Registry) -> std::collections::HashSet { + let mut ids: std::collections::HashSet = + registry.list().into_iter().map(|p| p.pane_id).collect(); + if let Some(store) = crate::core::machine::observed_store() { + ids.extend( + store + .machine() + .workspaces + .iter() + .flat_map(|w| w.tabs.iter()) + .flat_map(|t| t.root.pane_ids()), + ); + } + ids +} + +/// Keep each pane's stored screen roughly current. +/// +/// Only panes whose ring has moved are written, so an idle machine does no IO +/// at all, and the busy pane that most needs a fresh copy is the one that gets +/// it. Turning the setting off mid-run is honoured here too: the next tick +/// clears the directory rather than leaving terminal output on disk that the +/// user has just said they do not want stored. +fn spawn_scrollback_writer(registry: Arc) { + let spawned = std::thread::Builder::new() + .name("tty7-scrollback".into()) + .spawn(move || { + let mut marks: HashMap = HashMap::new(); + let mut storing = crate::daemon::scrollback::enabled(); + loop { + std::thread::sleep(crate::daemon::scrollback::SNAPSHOT_INTERVAL); + let enabled = crate::daemon::scrollback::enabled(); + if !enabled { + if storing { + log::info!("scrollback persistence turned off; dropping what was stored"); + crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); + marks.clear(); + storing = false; + } + continue; + } + storing = true; + for pane in registry.all() { + let (segments, mark) = pane.scrollback_snapshot(); + if marks.get(&pane.id) == Some(&mark) { + continue; + } + crate::daemon::scrollback::save(pane.id, &segments); + marks.insert(pane.id, mark); + } + crate::daemon::scrollback::sweep(&restorable_pane_ids(®istry)); + marks.retain(|id, _| registry.get(*id).is_some()); + } + }); + if let Err(e) = spawned { + log::warn!("could not start the scrollback writer: {e}"); + } +} + +/// Write every pane's screen one last time, on the way out of a shutdown this +/// process was told about. +/// +/// The periodic writer covers the deaths nobody gets to prepare for; this +/// covers the ones we do, and makes the copy exact rather than up to +/// [`SNAPSHOT_INTERVAL`](crate::daemon::scrollback::SNAPSHOT_INTERVAL) stale. +fn store_scrollback_now(registry: &Registry) { + if !crate::daemon::scrollback::enabled() { + return; + } + for pane in registry.all() { + let (segments, _) = pane.scrollback_snapshot(); + crate::daemon::scrollback::save(pane.id, &segments); + } +} + +/// Turn a client's restore request into the screen its new pane opens with. +/// +/// The snapshot is dropped as it is handed out. It has been folded into a live +/// pane's ring, which is where it will be persisted from now on — under that +/// pane's own id — and a copy left behind could only ever be used to restore +/// the same screen into a second pane. +fn restored_screen( + request: crate::daemon::protocol::RestoreFrom, +) -> Option { + if !crate::daemon::scrollback::enabled() { + return None; + } + let segments = crate::daemon::scrollback::load(request.pane_id)?; + crate::daemon::scrollback::forget(request.pane_id); + if segments.is_empty() { + return None; + } + log::info!( + "pane {} is gone; its last screen is restored into a fresh pane", + request.pane_id + ); + Some(crate::daemon::pane::Restore { + segments, + banner: request.banner, + }) +} + +/// Close a pane for good: stop it, drop it from the registry, and drop the copy +/// of its screen. +/// +/// A stored screen exists so a pane can survive a death nobody chose. A pane +/// the user closed is not that; keeping its output on disk afterwards would +/// only be a way for it to turn up in some later restore. +fn kill_pane(registry: &Registry, pane_id: u64) { + if let Some(pane) = registry.remove(pane_id) { + pane.kill(); + } + crate::daemon::scrollback::forget(pane_id); +} + fn ssh_connection_for( registry: &Registry, pane_id: u64, @@ -280,6 +406,16 @@ fn run_with(registry: Arc) -> anyhow::Result<()> { } spawn_orphan_sweep(registry.clone()); + // Before the writer starts: a daemon that has just come up owns no panes, + // so everything on disk belongs to panes the machine tree either still + // names — those are the ones a window is about to ask to restore — or has + // forgotten, and the latter are nobody's to restore any more. + if crate::daemon::scrollback::enabled() { + crate::daemon::scrollback::sweep(&restorable_pane_ids(®istry)); + } else { + crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); + } + spawn_scrollback_writer(registry.clone()); for stream in listener.incoming() { match stream { @@ -321,6 +457,10 @@ fn serve_sigterm(registry: Arc) { let mut sig: libc::c_int = 0; if unsafe { libc::sigwait(&set, &mut sig) } == 0 { log::info!("daemon shutting down on SIGTERM"); + // Ahead of the kill: `drain_and_kill` hangs up every pty, and a + // pane whose shell has already been reaped has nothing left to + // photograph. + store_scrollback_now(®istry); registry.drain_and_kill(); on_shutdown(); std::process::exit(0); @@ -364,8 +504,10 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { shell, owner, workspace, + restore, } => { let id = registry.alloc_id(); + let restore = restore.and_then(restored_screen); let on_dead = { let registry = registry.clone(); move || { @@ -377,17 +519,18 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .ok(); } }; - let pane = match DaemonPane::spawn(id, cwd, size, shell, owner, workspace, on_dead) { - Ok(p) => p, - Err(e) => { - let mut w = write_stream; - // The daemon's own error is already a sentence; a second - // "spawn failed:" in front of it only pads the one the - // window ends up showing. - let _ = DaemonMsg::Error(format!("{e}")).encode(&mut w); - return Err(e); - } - }; + let pane = + match DaemonPane::spawn(id, cwd, size, shell, owner, workspace, restore, on_dead) { + Ok(p) => p, + Err(e) => { + let mut w = write_stream; + // The daemon's own error is already a sentence; a second + // "spawn failed:" in front of it only pads the one the + // window ends up showing. + let _ = DaemonMsg::Error(format!("{e}")).encode(&mut w); + return Err(e); + } + }; registry.insert(pane.clone()); { @@ -484,9 +627,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { } ClientMsg::Kill { pane_id } => { - if let Some(pane) = registry.remove(pane_id) { - pane.kill(); - } + kill_pane(®istry, pane_id); Ok(()) } @@ -797,9 +938,8 @@ fn run_stream( if pane_id == id { killed = true; break 'conn; - } else if let Some(other) = registry.remove(pane_id) { - other.kill(); } + kill_pane(®istry, pane_id); } _ => {} } @@ -822,9 +962,7 @@ fn run_stream( let _ = writer.join(); if killed { - if let Some(p) = registry.remove(id) { - p.kill(); - } + kill_pane(®istry, id); } else if reclaimable { registry.remove(id); } diff --git a/crates/tty7-server/tests/routed_pane.rs b/crates/tty7-server/tests/routed_pane.rs index 1682d2bf..c74a4b73 100644 --- a/crates/tty7-server/tests/routed_pane.rs +++ b/crates/tty7-server/tests/routed_pane.rs @@ -110,6 +110,7 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { shell: Some(plain_shell()), owner: None, workspace: None, + restore: None, } .encode(&mut sock) .unwrap(); @@ -178,6 +179,7 @@ fn a_routed_kill_reaches_the_pane_it_names() { shell: Some(plain_shell()), owner: None, workspace: None, + restore: None, } .encode(&mut sock) .unwrap(); diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 7917d5cf..51ba3362 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -22,9 +22,9 @@ use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest, - ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, SftpEntry, SftpJobProgress, SftpOp, - SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize, WorkspaceOp, - WorkspaceRequest, + ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, RestoreFrom, SftpEntry, + SftpJobProgress, SftpOp, SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, + WinSize, WorkspaceOp, WorkspaceRequest, }; use crate::daemon::transport::{self, Stream}; use gpui::EntityId; @@ -223,7 +223,16 @@ impl RemoteTerminal { cwd: Option, shell: Option, ) -> anyhow::Result<(Self, u64)> { - Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell, None) + Self::spawn_on( + &PaneRoute::Local, + size, + cell_w, + cell_h, + cwd, + shell, + None, + None, + ) } pub fn spawn_on( @@ -234,11 +243,13 @@ impl RemoteTerminal { cwd: Option, shell: Option, owner: Option, + restore: Option, ) -> anyhow::Result<(Self, u64)> { let retry_cwd = cwd.clone(); let retry_shell = shell.clone(); let retry_owner = owner.clone(); - match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell, owner) { + let retry_restore = restore.clone(); + match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell, owner, restore) { Ok(term) => Ok(term), Err(first_err) if daemon_not_listening(&first_err) => { if let Err(start_err) = crate::daemon::spawn::ensure_running() { @@ -246,7 +257,7 @@ impl RemoteTerminal { "daemon not running ({first_err}); starting one failed: {start_err}" )); } - Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner) + Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner, retry_restore) .map_err(|second_err| { anyhow::anyhow!( "daemon not running ({first_err}); started one but Spawn still failed: {second_err}" @@ -261,7 +272,7 @@ impl RemoteTerminal { "daemon disconnected before Spawn reply ({first_err}); restart failed: {restart_err}" )); } - Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner).map_err(|second_err| { + Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner, retry_restore).map_err(|second_err| { anyhow::anyhow!( "daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}" ) @@ -271,6 +282,7 @@ impl RemoteTerminal { } } + #[allow(clippy::too_many_arguments)] fn spawn_once( route: &PaneRoute, size: TermSize, @@ -279,6 +291,7 @@ impl RemoteTerminal { cwd: Option, shell: Option, owner: Option, + restore: Option, ) -> anyhow::Result<(Self, u64)> { let mut stream = connect_routed(route)?; let win = win_size(size, cell_w, cell_h); @@ -293,12 +306,25 @@ impl RemoteTerminal { ) }); + // Only the local daemon's feature list is known here; a routed spawn + // reaches a server whose build we have not asked about, and one that + // predates the field would silently drop it. Sending it anyway would + // cost nothing but would make the log claim a restore that never + // happened, so the local case is the only one that asks. + let restore = restore.filter(|_| { + route.is_local() + && crate::daemon::spawn::local_daemon_supports( + crate::daemon::protocol::FEATURE_RESTORE_SCROLLBACK, + ) + }); + ClientMsg::Spawn { cwd, size: win, shell, owner, workspace, + restore, } .encode(&mut stream)?; let pane_id = match DaemonMsg::read(&mut stream)? { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5e5a3933..6d18d1c2 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -856,6 +856,18 @@ impl TerminalView { let (terminal, pane_id, shell_spec) = match attached { Some(parts) => parts, None => { + // The pane this one stands in for is gone, but its screen may + // not be: if the daemon kept a copy, the new pane opens showing + // it, under a line saying the shell below is new. Asking costs + // nothing when there is no copy — the daemon answers by + // spawning the blank pane it would have spawned anyway. + let restore = restore_pane.map(|pane_id| crate::daemon::protocol::RestoreFrom { + pane_id, + banner: Some( + crate::ui::i18n::t(crate::ui::i18n::L10nKey::PaneRestoredScreenBanner) + .to_string(), + ), + }); let (terminal, id) = RemoteTerminal::spawn_on( &route, TermSize::new(80, 24), @@ -864,6 +876,7 @@ impl TerminalView { working_directory, shell.clone(), owner.map(|id| id.to_string()), + restore, )?; (terminal, id, shell) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 52f4ccb0..975d62fc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2491,6 +2491,14 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.restore_session = on); } + /// The daemon reads this from the config file on its own — it is the one + /// holding the output — so there is nothing to tell it here. Turning it off + /// also removes what was already stored, which the daemon does on its next + /// pass rather than leaving the bytes behind. + pub(crate) fn set_persist_scrollback(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.persist_scrollback = on); + } + pub(crate) fn set_show_tray_icon(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.show_tray_icon = on); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index dc4ea7e1..166aeb6e 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1340,6 +1340,17 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::Replace => "Replace", L10nKey::SftpErrorInvalidOctalMode => "invalid octal mode", + L10nKey::PaneRestoredScreenBanner => { + "restored screen — this shell is new, nothing above it is still running" + } + L10nKey::SettingsPersistScrollback => "Keep pane output on disk", + L10nKey::SettingsPersistScrollbackDescription => { + "If the background service dies without warning — a crash, or a reboot — panes come \ + back showing what was on them instead of blank. The processes are gone either way; \ + this restores the picture. It writes a capped tail of every pane's output to disk, \ + including anything printed there: tokens, the output of `env`, an agent's \ + transcript. Off means that output only ever lives in memory." + } L10nKey::PanelMoreChangedFiles => { "… and {count} more changed files — run git diff to see them." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index b265b8e9..a6a37a26 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1387,6 +1387,17 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::Replace => "置き換える", L10nKey::SftpErrorInvalidOctalMode => "無効な 8 進数モードです", + L10nKey::PaneRestoredScreenBanner => { + "復元された画面 — 以下は新しいシェルで、これより上のものは動いていません" + } + L10nKey::SettingsPersistScrollback => "ペインの出力をディスクに残す", + L10nKey::SettingsPersistScrollbackDescription => { + "バックグラウンドサービスが引き継ぎの間もなく落ちた場合(クラッシュや再起動)、\ + ペインは空ではなく、そこにあった内容を表示して戻ります。プロセスはいずれにせよ失われ、\ + ここで戻るのは画面だけです。各ペインの出力の末尾を上限つきでディスクに書き込みます。\ + そこに表示されたもの(トークン、`env` の出力、エージェントの記録)も含みます。\ + オフなら、その出力はメモリ上にしか存在しません。" + } L10nKey::PanelMoreChangedFiles => { "… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 09cddf06..7d509b7b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1107,6 +1107,9 @@ l10n_keys! { SftpReplaceBody, Replace, SftpErrorInvalidOctalMode, + PaneRestoredScreenBanner, + SettingsPersistScrollback, + SettingsPersistScrollbackDescription, } pub fn set_locale(gui_language: &str) { diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index f3ad545f..5a20b48f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1273,6 +1273,15 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SftpReplaceBody => "{names} 在这个文件夹里已经存在,上传会覆盖它们。", L10nKey::Replace => "覆盖", L10nKey::SftpErrorInvalidOctalMode => "无效的八进制模式", + L10nKey::PaneRestoredScreenBanner => { + "已恢复的画面 —— 下面是新的 shell,上面的内容都已不在运行" + } + L10nKey::SettingsPersistScrollback => "把面板输出留在磁盘上", + L10nKey::SettingsPersistScrollbackDescription => { + "后台服务如果没来得及交接就没了(崩溃、重启机器),面板回来时会显示原先的内容,而不是一片空白。\ + 进程无论如何都救不回来,这里恢复的只是画面。它会把每个面板输出的末尾(有上限)写到磁盘上,\ + 包括那里打印过的一切:token、`env` 的输出、agent 的对话记录。关掉则这些输出只存在于内存里。" + } L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 git diff 查看。", L10nKey::PanelUntracked => "{count} 个未跟踪文件", L10nKey::AppMenuAbout => "关于 tty7", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index f294375c..589b156f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -4732,6 +4732,7 @@ impl Tty7App { NewTabPosition::End => 1, }; let restore_session = cfg.restore_session; + let persist_scrollback = cfg.persist_scrollback; let remember_window_size = cfg.remember_window_size; let show_tray_icon = cfg.show_tray_icon; let tab_bar_idx = match cfg.tab_bar_position { @@ -4792,6 +4793,10 @@ impl Tty7App { .checked(restore_session) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx))) .into_any_element(); + let persist_scrollback_switch = crate::ui::theme::switch("wt-persist-scrollback", cx) + .checked(persist_scrollback) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_persist_scrollback(*on, cx))) + .into_any_element(); let remember_window_switch = crate::ui::theme::switch("wt-remember-window", cx) .checked(remember_window_size) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) @@ -4885,6 +4890,12 @@ impl Tty7App { restore_switch, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsPersistScrollback), + t(L10nKey::SettingsPersistScrollbackDescription), + persist_scrollback_switch, + cx, + )) .child(self.settings_row( t(L10nKey::SettingsShowTrayIcon), t(L10nKey::SettingsShowTrayIconDesc),