From e00cbb41c2b504d8b0bc05a914777bdb1365d138 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:20:19 +0800 Subject: [PATCH] feat(daemon): keep a pane's screen across a death nobody chose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A daemon that crashes, is `kill -9`'d, or goes down with the machine takes every pane's replay ring with it, and the window comes back to a row of blank shells. The processes cannot be saved that way — nothing written to a file brings a process back — but the picture can. The daemon now keeps a capped tail of each pane's ring under the config directory, and a client whose `Attach` found nothing can ask, on the `Spawn` that replaces it, for the dead pane's screen. The new pane opens showing it, under a rule that says the shell below is new. - periodic and dirty-only: a ring that has not moved is not rewritten, so an idle machine does no IO at all. Write-through would be an enormous amount of write amplification for a few seconds of freshness. - capped at 256 KiB per pane, far below the ring's 8 MiB: the value of scrollback decays with distance from the bottom, and every byte here is a byte of someone's terminal on disk. - off by default. The ring holds whatever the pane printed, including echoed tokens, `env` output and agent transcripts; in memory that dies with the daemon, and writing it down is the whole feature and the whole cost. Files are 0600, and turning the setting off deletes what was kept. - dropped by relevance, not by calendar: a pane the user closed, or one no workspace names any more, has its file removed on the next sweep. Restored bytes are replayed at the geometry they were written at, and are preceded by resets — leave the alternate screen, show the cursor, restore autowrap, clear SGR — because a snapshot is cut at the front and can begin in the middle of any of them. --- crates/tty7-core/src/client/pane.rs | 4 + crates/tty7-core/src/core/config.rs | 12 + crates/tty7-core/src/daemon/mod.rs | 1 + crates/tty7-core/src/daemon/pane.rs | 211 ++++++++++- crates/tty7-core/src/daemon/protocol.rs | 53 +++ crates/tty7-core/src/daemon/scrollback.rs | 410 ++++++++++++++++++++++ crates/tty7-core/src/daemon/server.rs | 176 +++++++++- crates/tty7-server/tests/routed_pane.rs | 2 + src/terminal/remote.rs | 40 ++- src/terminal/view.rs | 13 + src/ui/app.rs | 8 + src/ui/i18n/en.rs | 11 + src/ui/i18n/ja.rs | 11 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 9 + src/ui/settings.rs | 11 + 16 files changed, 948 insertions(+), 27 deletions(-) create mode 100644 crates/tty7-core/src/daemon/scrollback.rs 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),