diff --git a/Cargo.lock b/Cargo.lock index b42cd1c..740afb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -220,6 +255,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -338,6 +382,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -378,6 +433,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -496,6 +557,12 @@ dependencies = [ "libm", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -505,6 +572,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -545,6 +621,35 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -595,8 +700,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] @@ -938,10 +1043,31 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1095,6 +1221,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "image-webp" version = "0.2.4" @@ -1115,6 +1250,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1483,6 +1627,7 @@ dependencies = [ name = "noa-app" version = "0.2.5" dependencies = [ + "aes-gcm", "anyhow", "crossbeam-channel", "dirs", @@ -1508,6 +1653,7 @@ dependencies = [ "pollster", "qrcode", "raw-window-handle", + "security-framework", "serde", "serde_json", "unicode-width", @@ -2093,6 +2239,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2226,6 +2383,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -2234,7 +2397,7 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2244,7 +2407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2256,6 +2419,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "range-alloc" version = "0.1.5" @@ -2450,6 +2619,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2517,7 +2709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2657,6 +2849,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -2880,6 +3078,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 37f22aa..141dc24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ unicode-width = "0.2" png = "0.18" qrcode = { version = "0.14", default-features = false } flate2 = "1" +aes-gcm = "0.11" +security-framework = "3" zune-jpeg = "0.5" image-webp = "0.2" diff --git a/crates/noa-app/Cargo.toml b/crates/noa-app/Cargo.toml index fb3b8ad..d971437 100644 --- a/crates/noa-app/Cargo.toml +++ b/crates/noa-app/Cargo.toml @@ -33,11 +33,13 @@ image-webp.workspace = true dirs.workspace = true unicode-width.workspace = true libc.workspace = true +aes-gcm.workspace = true +security-framework.workspace = true [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.19", default-features = false } objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSString", "NSURL", "NSArray", "NSData", "NSTimeZone", "NSAppleEventManager", "NSAppleEventDescriptor"] } +objc2-foundation = { version = "0.3", features = ["NSString", "NSURL", "NSArray", "NSData", "NSValue", "NSTimeZone", "NSAppleEventManager", "NSAppleEventDescriptor"] } objc2-app-kit = { version = "0.3", default-features = false, features = [ "std", "NSPasteboard", diff --git a/crates/noa-app/src/app.rs b/crates/noa-app/src/app.rs index b7d8881..8569617 100644 --- a/crates/noa-app/src/app.rs +++ b/crates/noa-app/src/app.rs @@ -88,6 +88,7 @@ mod quick_terminal; mod remote_ui; mod render; mod scratch_terminal; +mod scrollback_record; mod session_restore; mod sidebar; mod split_ops; @@ -392,6 +393,33 @@ pub struct App { /// happen on the caller. Its `Drop` (as an `App` field) flushes the last /// queued state to disk, covering the quit path. session_persister: crate::session_persist::SessionPersister, + /// Writes per-pane scrollback snapshots off the main thread + /// (`scrollback-persist`). Separate from `session_persister` because + /// snapshots are far larger and fire on different triggers — quit and idle + /// checkpoints, not every structural change. Its `Drop` flushes. + /// `None` when no data directory resolves. Persistence is then simply + /// unavailable — the read and collect paths already bail on that, and + /// falling back to the process's working directory would write terminal + /// output somewhere nothing can restore or reclaim it from, after chmodding + /// a directory noa did not create. + scrollback_persister: Option, + /// Feeds `scrollback_persist::mint_key`, so two panes minted in the same + /// clock tick still get distinct snapshot keys. + scrollback_key_counter: u64, + /// Whether this run has already warned that scrollback is being persisted. + scrollback_persist_announced: bool, + /// Snapshots read and decoded ahead of the restore loop, keyed by snapshot + /// key. Drained as panes are restored; empty outside launch. + pending_records: std::collections::HashMap, + /// When the current run of un-checkpointed output began. Anchors the + /// checkpoint ceiling so a pane that never goes quiet is still captured. + scrollback_dirty_since: Option, + /// When the idle scrollback checkpoint should next fire, or `None` when + /// nothing has changed since the last one. + scrollback_checkpoint_deadline: Option, + /// When the last checkpoint ran, bounding how long sustained output can + /// postpone the next one. + last_scrollback_checkpoint: Option, /// The installed AppleScript / Apple Event bridge (applescript R-2), kept /// alive for the app's lifetime (dropping it removes the handlers). `None` /// until installed, when `macos-applescript` is false, or off macOS. @@ -788,6 +816,14 @@ impl App { session_store: SessionStore::new(), branch_poll: Some(crate::branch_poll::spawn(proxy_for_branch_poll)), session_persister: crate::session_persist::SessionPersister::spawn(), + scrollback_persister: noa_config::scrollback_dir() + .map(crate::scrollback_persist::ScrollbackPersister::spawn), + scrollback_key_counter: 0, + scrollback_dirty_since: None, + scrollback_persist_announced: false, + pending_records: std::collections::HashMap::new(), + scrollback_checkpoint_deadline: None, + last_scrollback_checkpoint: None, sidebar_visible_gate, sidebar_preview_lines_gate, sidebar_visible_groups: HashSet::new(), diff --git a/crates/noa-app/src/app/commands.rs b/crates/noa-app/src/app/commands.rs index 700e3f9..b93f780 100644 --- a/crates/noa-app/src/app/commands.rs +++ b/crates/noa-app/src/app/commands.rs @@ -174,6 +174,8 @@ impl App { AppCommand::AttachRemote => self.begin_attach_remote(), AppCommand::SendSelectionToPane => self.open_send_selection_picker(), AppCommand::ExportScrollback => self.export_scrollback_to_temp_file(), + AppCommand::DiscardRestoredHistory => self.discard_focused_pane_record(), + AppCommand::CheckpointScrollback => self.checkpoint_scrollback_now(), AppCommand::PipeScrollbackToPager => self.pipe_scrollback_to_pager(event_loop), AppCommand::Terminal(action) => self.handle_terminal_action(action), AppCommand::FontSize(action) => self.handle_font_size_action(action), diff --git a/crates/noa-app/src/app/config.rs b/crates/noa-app/src/app/config.rs index 6180c27..3d0e9e9 100644 --- a/crates/noa-app/src/app/config.rs +++ b/crates/noa-app/src/app/config.rs @@ -108,6 +108,20 @@ pub struct AppConfig { /// `window-save-state`: whether the window/tab/split session is saved on /// exit and restored on launch. `never` disables both. pub window_save_state: noa_config::WindowSaveState, + /// `scrollback-persist`: whether each pane's scrollback tail is saved on + /// exit and restored on launch, alongside the topology. + pub scrollback_persist: noa_config::ScrollbackPersist, + /// `scrollback-persist-limit`: per-pane cap on captured scrollback, in + /// bytes of encoded payload. + pub scrollback_persist_limit: usize, + /// `scrollback-persist-total-limit`: cap on the snapshot directory's total + /// on-disk size, enforced at launch. + pub scrollback_persist_total_limit: usize, + /// `scrollback-persist-max-age-days`: snapshot expiry, enforced at launch + /// (`0` never expires). + pub scrollback_persist_max_age_days: u64, + /// `scrollback-persist-encrypt`: seal snapshots with a keychain-held key. + pub scrollback_persist_encrypt: bool, /// `macos-option-as-alt`: which Option key(s) the macOS window layer /// rewrites as terminal Alt. pub macos_option_as_alt: noa_config::MacosOptionAsAlt, @@ -293,6 +307,11 @@ impl AppConfig { scrollback_limit: config.scrollback_limit, image_storage_limit: config.image_storage_limit, window_save_state: config.window_save_state, + scrollback_persist: config.scrollback_persist, + scrollback_persist_limit: config.scrollback_persist_limit, + scrollback_persist_total_limit: config.scrollback_persist_total_limit, + scrollback_persist_max_age_days: config.scrollback_persist_max_age_days, + scrollback_persist_encrypt: config.scrollback_persist_encrypt, macos_option_as_alt: config.macos_option_as_alt, macos_titlebar_style: config.macos_titlebar_style, macos_non_native_fullscreen: config.macos_non_native_fullscreen, diff --git a/crates/noa-app/src/app/config_reload.rs b/crates/noa-app/src/app/config_reload.rs index abc260b..6bf95e9 100644 --- a/crates/noa-app/src/app/config_reload.rs +++ b/crates/noa-app/src/app/config_reload.rs @@ -214,8 +214,22 @@ impl App { previous.quick_terminal_hotkey != applied.quick_terminal_hotkey; let hotkeys_changed = quick_terminal_hotkey_changed; + let scrollback_persist_disabled = previous.scrollback_persist.persists() + && !(applied.scrollback_persist.persists() && applied.window_save_state.restores()); + self.config = applied; + if scrollback_persist_disabled { + // Turning it off means "stop keeping this", not "stop keeping it + // the next time you happen to launch" — drop what is already on + // disk now (`docs/specs/scrollback-persistence.md` §4.6). + self.purge_scrollback_snapshots(); + } + // Re-arm the announcement so a later re-enable warns again. + if !self.config.scrollback_persist.persists() { + self.scrollback_persist_announced = false; + } + if padding_changed { self.padding = resolve_grid_padding(self.config.window_padding_x, self.config.window_padding_y); diff --git a/crates/noa-app/src/app/event_loop.rs b/crates/noa-app/src/app/event_loop.rs index d2c22f3..96f84fc 100644 --- a/crates/noa-app/src/app/event_loop.rs +++ b/crates/noa-app/src/app/event_loop.rs @@ -111,6 +111,10 @@ impl ApplicationHandler for App { // freshest topology/cwd/focus. The all-windows-closed path leaves the // last file written by `persist_session` intact (this is a no-op when // `windows` is empty), matching "restore the last session". + // Capture before `persist_session`: the topology document records each + // pane's snapshot key, and a key is minted here for panes that have + // not been checkpointed yet. + self.capture_scrollback_snapshots(false); self.persist_session(); self.shutdown_remote_requests(); } @@ -254,6 +258,9 @@ impl ApplicationHandler for App { // Every pty-driven redraw pushes the post-burst memory trim // out; it fires once, MEMORY_TRIM_QUIESCENCE after the burst. self.arm_memory_trim(); + // Output means this pane's record is stale; the next idle + // checkpoint re-captures it. + self.mark_scrollback_dirty(window_id, pane_id); // Pty output on the focused surface is activity for the // `cursor-stop-blinking-after` idle stop: it restarts the // idle clock (resuming blink if it had settled solid) @@ -1066,6 +1073,7 @@ impl ApplicationHandler for App { let kitty_anim_deadline = self.tick_kitty_animations(); let memory_trim_deadline = self.tick_memory_trim(); let bg_refresh_wake_deadline = self.tick_bg_refresh_wake(); + let scrollback_checkpoint_deadline = self.tick_scrollback_checkpoint(); let deadline = [ blink_deadline, resize_throttle_deadline, @@ -1081,6 +1089,7 @@ impl ApplicationHandler for App { kitty_anim_deadline, memory_trim_deadline, bg_refresh_wake_deadline, + scrollback_checkpoint_deadline, ] .into_iter() .flatten() diff --git a/crates/noa-app/src/app/helpers/dispatch.rs b/crates/noa-app/src/app/helpers/dispatch.rs index 3978f41..e519aa8 100644 --- a/crates/noa-app/src/app/helpers/dispatch.rs +++ b/crates/noa-app/src/app/helpers/dispatch.rs @@ -336,6 +336,8 @@ pub(crate) fn command_scope(command: AppCommand) -> CommandScope { | AppCommand::Paste | AppCommand::SendSelectionToPane | AppCommand::ExportScrollback + | AppCommand::DiscardRestoredHistory + | AppCommand::CheckpointScrollback | AppCommand::PipeScrollbackToPager | AppCommand::Terminal(_) | AppCommand::FontSize(_) @@ -436,6 +438,8 @@ pub(crate) fn overview_command_scope(command: AppCommand) -> CommandScope { | AppCommand::Paste | AppCommand::SendSelectionToPane | AppCommand::ExportScrollback + | AppCommand::DiscardRestoredHistory + | AppCommand::CheckpointScrollback | AppCommand::PipeScrollbackToPager | AppCommand::Terminal(_) | AppCommand::FontSize(_) diff --git a/crates/noa-app/src/app/input_ops/theme_settings.rs b/crates/noa-app/src/app/input_ops/theme_settings.rs index 5c6ef52..5cf033f 100644 --- a/crates/noa-app/src/app/input_ops/theme_settings.rs +++ b/crates/noa-app/src/app/input_ops/theme_settings.rs @@ -245,6 +245,7 @@ impl App { self.config.scratch_terminal_size.cols, self.config.scratch_terminal_size.rows, ), + scrollback_persist: self.config.scrollback_persist, }; self.theme_settings = Some(ThemeSettingsSession { window_id, @@ -1208,7 +1209,8 @@ impl App { | RowDraft::ServerEnable(_) | RowDraft::ServerPort(_) | RowDraft::ServerBind(_) - | RowDraft::ServerScopes(_) => {} + | RowDraft::ServerScopes(_) + | RowDraft::ScrollbackPersist(_) => {} // `scratch-terminal-key` is the same shape as the group // above: `App::apply_reloaded_config`'s // `scratch_terminal_key_changed` diff is what actually @@ -1768,6 +1770,7 @@ mod commit_theme_settings_tests { server_status: "Stopped".to_string(), scratch_terminal_key: "cmd+shift+t".to_string(), scratch_terminal_size: (100, 25), + scrollback_persist: noa_config::ScrollbackPersist::default(), theme_pair: None, carryover: None, favorites: std::sync::Arc::new(std::collections::HashSet::new()), @@ -1920,6 +1923,7 @@ mod commit_theme_settings_tests { server_status: "Stopped".to_string(), scratch_terminal_key: "cmd+shift+t".to_string(), scratch_terminal_size: (100, 25), + scrollback_persist: noa_config::ScrollbackPersist::default(), theme_pair: None, carryover: None, favorites: std::sync::Arc::new(std::collections::HashSet::new()), diff --git a/crates/noa-app/src/app/lifecycle.rs b/crates/noa-app/src/app/lifecycle.rs index a8e2699..14b0093 100644 --- a/crates/noa-app/src/app/lifecycle.rs +++ b/crates/noa-app/src/app/lifecycle.rs @@ -1064,6 +1064,9 @@ impl App { } pub(super) fn close_tab(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId) { + // Before the surfaces are torn down and their keys are gone: a closed + // tab's record must not outlive the tab. + self.discard_window_records(window_id); self.end_copy_mode_for_window(window_id); // The Overview overlay lives inside its host window; closing the host // tears the overlay down with it (before `close_tab_outcome`, so the @@ -1363,9 +1366,18 @@ impl App { return; } + let closed_key = state + .surfaces + .get(&pane_id) + .and_then(|surface| surface.scrollback_key.clone()); if let Some(mut surface) = state.surfaces.remove(&pane_id) { surface.shutdown(); } + if let Some(key) = closed_key + && let Some(persister) = self.scrollback_persister.as_ref() + { + persister.discard(key); + } if self.search_prompt.as_ref().is_some_and(|session| { session.window_id == window_id && session.pane_id == pane_id }) { diff --git a/crates/noa-app/src/app/render.rs b/crates/noa-app/src/app/render.rs index 3a9271d..23b5ecb 100644 --- a/crates/noa-app/src/app/render.rs +++ b/crates/noa-app/src/app/render.rs @@ -102,6 +102,9 @@ impl App { continue; }; let mut term = surface.terminal.lock(); + // Read while the lock is held; the record-region validity check + // below runs after it is released. + let coordinate_generation = term.grid_coordinate_generation(); let active = term.active(); let cursor = active.cursor; let (active_cols, active_rows) = (active.cols, active.rows); @@ -168,6 +171,15 @@ impl App { pane_owns_keyboard_focus(window_id, pane_id, self.os_focused, state.focused_pane); snapshot.cursor_blink_visible = self.cursor_blink_visible; snapshot.hover_link = surface.hover_link; + // Restored history is marked, but only while those session-absolute + // indices still name the rows they were computed for: a reflow or a + // scrollback clear renumbers the space, and the alternate screen is + // a different space entirely (its rows would collide with the + // record's range and paint a gutter over a live full-screen app). + snapshot.record_rows = (!snapshot.active_is_alt + && surface.record_generation == coordinate_generation) + .then(|| surface.record_rows.clone()) + .flatten(); snapshots.push((pane_id, surface.rect, snapshot)); } let panes = snapshots @@ -554,6 +566,9 @@ impl App { continue; }; let mut term = surface.terminal.lock(); + // Read while the lock is held; the record-region validity check + // below runs after it is released. + let coordinate_generation = term.grid_coordinate_generation(); let copy_mode_state = (copy_mode_pane == Some(pane_id)).then(|| { &mut self .copy_mode @@ -685,6 +700,15 @@ impl App { snapshot.cursor_blink_visible = self.cursor_blink_visible; patch_copy_mode_cursor(&mut snapshot, pane_copy_cursor); snapshot.hover_link = surface.hover_link; + // Restored history is marked, but only while those session-absolute + // indices still name the rows they were computed for: a reflow or a + // scrollback clear renumbers the space, and the alternate screen is + // a different space entirely (its rows would collide with the + // record's range and paint a gutter over a live full-screen app). + snapshot.record_rows = (!snapshot.active_is_alt + && surface.record_generation == coordinate_generation) + .then(|| surface.record_rows.clone()) + .flatten(); // Neither the palette nor the confirm dialog draws in the pane // cell pass — both are composited as rounded modal cards after // the panes (H). Leave `snapshot.command_palette` and diff --git a/crates/noa-app/src/app/scrollback_record.rs b/crates/noa-app/src/app/scrollback_record.rs new file mode 100644 index 0000000..2bc3e9d --- /dev/null +++ b/crates/noa-app/src/app/scrollback_record.rs @@ -0,0 +1,545 @@ +use super::*; + +use std::collections::HashSet; + +use crate::record_view; +use crate::scrollback_persist as store; + +/// How long a pane must be quiet before an idle checkpoint captures it. +/// Long enough that a burst of output settles first, short enough that a +/// crash rarely costs more than the last few seconds. +pub(super) const CHECKPOINT_QUIESCENCE: std::time::Duration = std::time::Duration::from_secs(5); + +/// Ceiling on the gap between checkpoints. Quiescence alone would never fire +/// under sustained output — exactly the long-running build whose tail the user +/// most wants back — so a flood still gets checkpointed this often. +pub(super) const CHECKPOINT_MAX_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); + +/// Floor on how soon the ceiling may fire, so a burst arriving after a long +/// idle still gets a moment to settle instead of being captured mid-stream. +const CHECKPOINT_MIN_GRACE: std::time::Duration = std::time::Duration::from_secs(1); + +/// Scrollback persistence (`scrollback-persist`): capture each pane's tail to +/// disk, restore it on launch as a marked record region, and keep the snapshot +/// directory inside its budgets. +/// +/// Spec: `docs/specs/scrollback-persistence.md`. +impl App { + /// Whether scrollback should be captured at all. + /// + /// Gated on `window-save-state` as well as `scrollback-persist`: the key + /// that makes a snapshot reachable is written by `persist_session`, which + /// is a no-op while session state is disabled. Capturing anyway would write + /// terminal output to disk that nothing can ever restore — cost with no + /// benefit, which is the worst trade available for this feature. + pub(super) fn scrollback_persist_enabled(&self) -> bool { + self.config.scrollback_persist.persists() + && self.config.window_save_state.restores() + && self.scrollback_persister.is_some() + } + + /// Say once, out loud, that terminal output is now being written to disk. + /// + /// The Settings row carries the caveat, but the documented way to turn this + /// on is editing the config file, and that path had no acknowledgement at + /// all (spec §9). Someone enabling it should not have to infer from a + /// directory listing that their credentials are now at rest. + pub(super) fn announce_scrollback_persistence(&mut self) { + if !self.scrollback_persist_enabled() || self.scrollback_persist_announced { + return; + } + self.scrollback_persist_announced = true; + log::warn!( + "scrollback-persist is on: each pane's scrollback tail is written to {} \ + (0600, excluded from backups, unencrypted). Set scrollback-persist = never to stop \ + and delete what is stored.", + noa_config::scrollback_dir() + .map(|dir| dir.display().to_string()) + .unwrap_or_else(|| "".to_string()) + ); + } + + /// Mint this pane's snapshot key if it does not have one yet, and return + /// it. Keys are stable for the pane's life so repeated checkpoints + /// overwrite one file. + fn scrollback_key_for(&mut self, window_id: WindowId, pane: PaneId) -> Option { + let counter = { + self.scrollback_key_counter = self.scrollback_key_counter.wrapping_add(1); + self.scrollback_key_counter + }; + let surface = self.windows.get_mut(&window_id)?.surfaces.get_mut(&pane)?; + if surface.scrollback_key.is_none() { + surface.scrollback_key = Some(store::mint_key(counter)); + } + surface.scrollback_key.clone() + } + + /// Every pane eligible for persistence, as `(window, pane)`. + /// + /// Walks `window_order` rather than `windows`, which excludes the scratch + /// terminal and the quick terminal for free — they are deliberately kept + /// out of that list, and a deliberately disposable popup is the last thing + /// that should leave its output on disk. Remote panes are skipped too: + /// their contents belong to the machine serving them. + fn persistable_panes(&self) -> Vec<(WindowId, PaneId)> { + let mut out = Vec::new(); + for window_id in &self.window_order { + let Some(state) = self.windows.get(window_id) else { + continue; + }; + for (pane, surface) in &state.surfaces { + if surface.is_remote() { + continue; + } + out.push((*window_id, *pane)); + } + } + out + } + + /// Encode and queue a snapshot for every eligible pane. + /// + /// `dirty_only` skips panes that produced no output since their last + /// capture — the idle checkpoint's normal mode. Quit passes `false` so the + /// final state of every pane lands even if it has been quiet. + pub(super) fn capture_scrollback_snapshots(&mut self, dirty_only: bool) { + if !self.scrollback_persist_enabled() { + return; + } + let limit = self.config.scrollback_persist_limit; + if limit == 0 { + // Not "skip this round": a zero budget is the user saying to retain + // nothing, and leaving the previous file in place would restore + // output captured before they said so. + self.purge_scrollback_snapshots(); + return; + } + let saved_at = store::now_unix(); + + for (window_id, pane) in self.persistable_panes() { + if dirty_only + && !self + .windows + .get(&window_id) + .and_then(|state| state.surfaces.get(&pane)) + .is_some_and(|surface| surface.scrollback_dirty) + { + continue; + } + let Some(key) = self.scrollback_key_for(window_id, pane) else { + continue; + }; + let Some(surface) = self + .windows + .get_mut(&window_id) + .and_then(|state| state.surfaces.get_mut(&pane)) + else { + continue; + }; + // Only the row clone happens under the lock; interning and deflate + // run on the persist worker. The io thread blocks on this same + // mutex, so anything expensive here stalls pty drain. + let input = { + let terminal = surface.terminal.lock(); + // A stale index would exclude an unrelated *live* row from the + // record instead of the separator. + let annotation = (surface.record_generation + == terminal.grid_coordinate_generation()) + .then_some(surface.annotation_row) + .flatten(); + terminal.scrollback_snapshot_input(limit, annotation) + }; + surface.scrollback_dirty = false; + let superseded = surface.superseded_scrollback_key.take(); + let Some(persister) = self.scrollback_persister.as_ref() else { + continue; + }; + match input { + Some(input) => persister.save( + key, + input, + saved_at, + limit, + self.config.scrollback_persist_encrypt, + ), + // A pane with nothing to show must not restore last week's + // output: drop any snapshot it previously wrote. + None => persister.discard(key), + } + // This pane's own file now holds everything the restored one did. + if let Some(superseded) = superseded { + persister.discard(superseded); + } + } + self.last_scrollback_checkpoint = Some(Instant::now()); + } + + /// Command entry point: capture every pane now. + /// + /// Pairs the capture with `persist_session` for the same reason the timer + /// does — a key minted here but absent from `session.json` is an orphan the + /// next launch's collector deletes, which is precisely the crash this + /// command is invoked to survive. + pub(super) fn checkpoint_scrollback_now(&mut self) { + self.capture_scrollback_snapshots(false); + self.persist_session(); + } + + /// Note that `pane` produced output, so the next checkpoint captures it. + pub(super) fn mark_scrollback_dirty(&mut self, window_id: WindowId, pane: PaneId) { + if !self.scrollback_persist_enabled() { + return; + } + if let Some(surface) = self + .windows + .get_mut(&window_id) + .and_then(|state| state.surfaces.get_mut(&pane)) + { + surface.scrollback_dirty = true; + } + self.announce_scrollback_persistence(); + self.scrollback_dirty_since.get_or_insert_with(Instant::now); + self.arm_scrollback_checkpoint(); + } + + /// (Re-)arm the idle checkpoint: normally [`CHECKPOINT_QUIESCENCE`] after + /// the last output, but never later than [`CHECKPOINT_MAX_INTERVAL`] past + /// the previous checkpoint, so sustained output cannot starve it. + fn arm_scrollback_checkpoint(&mut self) { + let now = Instant::now(); + let quiescent = now + CHECKPOINT_QUIESCENCE; + // Anchor the ceiling on the start of the current dirty streak, not on + // the last checkpoint: before the very first one there is no last + // checkpoint, and a pane emitting faster than the quiescence window + // would otherwise push its deadline forever and never be captured at + // all — exactly the long first build whose tail matters most. + let anchor = *self + .scrollback_dirty_since + .get_or_insert(self.last_scrollback_checkpoint.unwrap_or(now)); + // Clamp from below as well: after a long idle the ceiling is already in + // the past, and firing on the first byte of a new burst is the mid-burst + // stall the quiescence window exists to avoid. + let ceiling = (anchor + CHECKPOINT_MAX_INTERVAL).max(now + CHECKPOINT_MIN_GRACE); + self.scrollback_checkpoint_deadline = Some(quiescent.min(ceiling)); + } + + /// Fire the idle checkpoint when due. Returns the next deadline for + /// `about_to_wait`'s control-flow calculation, mirroring the other tickers. + pub(super) fn tick_scrollback_checkpoint(&mut self) -> Option { + let deadline = self.scrollback_checkpoint_deadline?; + if Instant::now() < deadline { + return Some(deadline); + } + self.scrollback_checkpoint_deadline = None; + self.scrollback_dirty_since = None; + self.capture_scrollback_snapshots(true); + // A checkpoint can mint a pane's first key. Without re-writing the + // topology, a crash would leave `session.json` claiming that pane has + // no snapshot and the collector would delete the file we just wrote — + // exactly the crash the checkpoint exists to survive. + self.persist_session(); + None + } + + /// Read and decode every referenced snapshot up front, in parallel. + /// + /// Restore runs on the winit thread during launch, and doing file I/O plus + /// inflate plus decode inline once per pane puts all of it on the critical + /// path to first paint. The work is pure bytes-to-rows, so it fans out; only + /// the prepend has to stay on the main thread. + pub(super) fn preload_scrollback_records(&mut self, keys: &HashSet) { + self.pending_records.clear(); + if keys.is_empty() || !self.scrollback_persist_enabled() { + return; + } + let Some(dir) = noa_config::scrollback_dir() else { + return; + }; + // A body larger than the pane budget was not written by a noa honoring + // the same limit; the slack covers the side tables the budget bounds + // only indirectly. + let ceiling = (self.config.scrollback_persist_limit as u64).saturating_mul(4); + let keys: Vec<&String> = keys.iter().collect(); + + let decoded: Vec<(String, noa_grid::ScrollbackSnapshot)> = std::thread::scope(|scope| { + let handles: Vec<_> = keys + .iter() + .map(|key| { + let dir = dir.clone(); + let key = (*key).clone(); + scope.spawn(move || { + let bytes = store::read(&dir, &key)?; + // Branch on the container, not on the config: a file + // written before encryption was turned on still reads, + // and one written before it was turned off is not lost. + let bytes = if crate::scrollback_crypt::is_sealed(&bytes) { + crate::scrollback_crypt::open(&bytes)? + } else { + bytes + }; + Some((key, noa_grid::snapshot::decode_within(&bytes, ceiling)?)) + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|handle| handle.join().ok().flatten()) + .collect() + }); + self.pending_records = decoded.into_iter().collect(); + } + + /// Delete snapshots no saved session references, plus anything expired or + /// over budget. Run at launch *before* restore, so a record the user asked + /// to expire is never shown and then deleted behind them. + pub(super) fn collect_scrollback_snapshots(&self, referenced: HashSet) { + let Some(dir) = noa_config::scrollback_dir() else { + return; + }; + if !dir.exists() { + return; + } + // With persistence off, nothing is referenced and the whole directory + // drains on the next launch. + let (total_limit, max_age) = if self.scrollback_persist_enabled() { + ( + self.config.scrollback_persist_total_limit as u64, + self.config.scrollback_persist_max_age_days, + ) + } else { + (0, 0) + }; + store::collect(&dir, &referenced, total_limit, max_age); + } + + /// Push a restored record into a freshly spawned pane, followed by the + /// separator that marks where live output begins. + /// + /// `key` is the leaf's saved snapshot key, if it had one. When there is no + /// record to show — persistence off, no key, a missing or corrupt file — + /// the pane instead gets the Stage 0 notice, because a restored layout + /// with an empty pane is a silent broken promise. + pub(super) fn restore_pane_record( + &mut self, + window_id: WindowId, + pane: PaneId, + key: Option, + ) { + let Some(surface) = self + .windows + .get(&window_id) + .and_then(|state| state.surfaces.get(&pane)) + else { + return; + }; + if surface.is_remote() { + return; + } + let cols = surface.grid_size.cols; + + let snapshot = key + .as_deref() + .filter(|_| self.scrollback_persist_enabled()) + .and_then(|key| self.pending_records.remove(key)); + + let (mut history, hyperlinks, saved_at) = match snapshot { + Some(snapshot) => (snapshot.rows, snapshot.hyperlinks, Some(snapshot.saved_at)), + None => (Vec::new(), Vec::new(), None), + }; + match saved_at { + Some(saved_at) => history.push(record_view::separator_row( + saved_at, + crate::localtime::local_offset_seconds(), + cols, + )), + None => history.push(record_view::not_persisted_notice_row(cols)), + } + + let Some(surface) = self + .windows + .get_mut(&window_id) + .and_then(|state| state.surfaces.get_mut(&pane)) + else { + return; + }; + let record_rows = { + let mut terminal = surface.terminal.lock(); + let inserted = terminal.restore_scrollback_snapshot(noa_grid::ScrollbackSnapshot { + cols, + saved_at: saved_at.unwrap_or(0), + rows: history, + // Carried through so the restored cells' OSC 8 links resolve + // in *this* terminal's registry; dropping the table here + // would silently unlink every restored link. + hyperlinks, + }); + (inserted > 0).then(|| { + let start = terminal.active_oldest_row(); + start..start + inserted + }) + }; + // The notice is a live annotation about *this* launch, not recovered + // history — marking it as record would claim a record exists. + surface.record_rows = saved_at.and(record_rows.clone()); + // Both the separator and the notice are the last row inserted. + surface.annotation_row = record_rows.map(|rows| rows.end - 1); + // Stamped *after* the insert, which bumps the generation itself. + surface.record_generation = surface.terminal.lock().grid_coordinate_generation(); + // Restoring is not output; without this the pane would be captured + // again immediately, rewriting the file it was just restored from. + surface.scrollback_dirty = false; + // Deliberately *not* adopting the saved key: two noa instances + // restoring the same session would otherwise claim the same file and + // overwrite each other. The pane writes to a fresh key from here; the + // file it was restored from is dropped once that first write lands. + surface.scrollback_key = None; + surface.superseded_scrollback_key = key.filter(|key| store::is_valid_key(key)); + } + + /// Command entry point: discard the focused pane's restored record. + pub(super) fn discard_focused_pane_record(&mut self) { + let Some(window_id) = self.focused else { + return; + }; + let Some(pane) = self.windows.get(&window_id).map(|state| state.focused_pane) else { + return; + }; + if self.discard_pane_record(window_id, pane) { + self.request_window_redraw(window_id); + } + } + + /// Drop the restored record from `pane`, leaving live output untouched. + pub(super) fn discard_pane_record(&mut self, window_id: WindowId, pane: PaneId) -> bool { + let Some(surface) = self + .windows + .get_mut(&window_id) + .and_then(|state| state.surfaces.get_mut(&pane)) + else { + return false; + }; + let Some(record) = surface.record_rows.take() else { + surface.annotation_row = None; + return false; + }; + surface.annotation_row = None; + { + let mut terminal = surface.terminal.lock(); + if surface.record_generation != terminal.grid_coordinate_generation() { + // The rows those indices named are gone; dropping that many rows + // off the front now would take live history instead. + return false; + } + terminal.discard_history_prefix(record.end.saturating_sub(record.start)); + } + if let Some(key) = surface.scrollback_key.clone() + && let Some(persister) = self.scrollback_persister.as_ref() + { + persister.discard(key); + } + true + } + + /// Drop the snapshots of every pane in `window_id`, called when a tab or + /// window is torn down. + /// + /// Without this a closed tab's record survives on disk for the rest of the + /// session and until the next launch — indefinitely if noa is never started + /// again. Closing a tab is the most direct way a user says "I am done with + /// that output"; the file should not outlive the pane. + pub(super) fn discard_window_records(&mut self, window_id: WindowId) { + let keys: Vec = self + .windows + .get(&window_id) + .into_iter() + .flat_map(|state| state.surfaces.values()) + .filter_map(|surface| surface.scrollback_key.clone()) + .collect(); + self.discard_snapshot_keys(keys); + } + + fn discard_snapshot_keys(&mut self, keys: Vec) { + if keys.is_empty() { + return; + } + if let Some(persister) = self.scrollback_persister.as_ref() { + for key in keys { + persister.discard(key); + } + } + } + + /// Delete every record this session owns, now rather than at the next + /// launch's collector. + /// + /// Someone who reacts to the privacy implication by turning the setting off + /// means "stop keeping this", not "stop keeping it the next time you happen + /// to start". Called on the `Tail -> Never` transition and when the budget + /// is set to zero. + pub(super) fn purge_scrollback_snapshots(&mut self) { + let keys: Vec = self + .windows + .values() + .flat_map(|state| state.surfaces.values()) + .filter_map(|surface| surface.scrollback_key.clone()) + .collect(); + if let Some(persister) = self.scrollback_persister.as_ref() { + for key in keys { + persister.discard(key); + } + } + for state in self.windows.values_mut() { + for surface in state.surfaces.values_mut() { + surface.scrollback_key = None; + surface.scrollback_dirty = false; + } + } + // Drop anything left from earlier runs too — nothing is referenced now. + if let Some(dir) = noa_config::scrollback_dir() + && dir.exists() + { + store::collect(&dir, &HashSet::new(), 0, 0); + } + self.persist_session(); + } + + /// Forget a record region once eviction has consumed it, so a pane that + /// has scrolled past its restored history stops drawing a gutter for rows + /// that are no longer there. + pub(super) fn prune_record_regions(&mut self) { + for state in self.windows.values_mut() { + for surface in state.surfaces.values_mut() { + if surface.record_rows.is_none() && surface.annotation_row.is_none() { + continue; + } + let (oldest, generation) = { + let terminal = surface.terminal.lock(); + ( + terminal.active_oldest_row(), + terminal.grid_coordinate_generation(), + ) + }; + if surface.record_generation != generation { + // A reflow or a scrollback clear renumbered every row: these + // indices no longer name anything. + surface.record_rows = None; + surface.annotation_row = None; + continue; + } + // The annotation is tracked even without a record (the Stage 0 + // notice has no record behind it), and a stale absolute index + // would skip an unrelated live row from the next capture. + if surface.annotation_row.is_some_and(|row| oldest > row) { + surface.annotation_row = None; + } + if let Some(record) = surface.record_rows.clone() { + if oldest >= record.end { + surface.record_rows = None; + } else if oldest > record.start { + surface.record_rows = Some(oldest..record.end); + } + } + } + } + } +} diff --git a/crates/noa-app/src/app/session_restore.rs b/crates/noa-app/src/app/session_restore.rs index 469090b..132a064 100644 --- a/crates/noa-app/src/app/session_restore.rs +++ b/crates/noa-app/src/app/session_restore.rs @@ -80,6 +80,18 @@ impl App { match tree { SplitTree::Leaf { pane } => session::PaneNode::Leaf { cwd: self.pane_cwd(window_id, *pane), + // Only claim a snapshot while persistence is on: with the key + // absent, the launch collector treats the file as an orphan + // and the directory drains itself. + scrollback: self + .scrollback_persist_enabled() + .then(|| { + self.windows + .get(&window_id) + .and_then(|state| state.surfaces.get(pane)) + .and_then(|surface| surface.scrollback_key.clone()) + }) + .flatten(), remote: self .windows .get(&window_id) @@ -127,13 +139,23 @@ impl App { /// missing/malformed/empty file is a silent no-op — startup is never /// blocked by session state. pub(super) fn restore_session_if_enabled(&mut self, event_loop: &ActiveEventLoop) { + let saved = noa_config::session_state_path().and_then(|path| session::load(&path)); + // Collect before any early return: snapshots outlive the setting that + // wrote them, so turning restore (or persistence) off must still drain + // the directory rather than stranding records on disk forever. + let referenced = saved + .as_ref() + .map(referenced_scrollback_keys) + .unwrap_or_default(); + self.collect_scrollback_snapshots(referenced.clone()); + // Fan the reads and inflates out before the restore loop, so the winit + // thread pays one pane's worth of latency rather than every pane's. + self.preload_scrollback_records(&referenced); + if !self.config.window_save_state.restores() || self.config.cli_grid_override { return; } - let Some(path) = noa_config::session_state_path() else { - return; - }; - let Some(state) = session::load(&path) else { + let Some(state) = saved else { return; }; if state.windows.is_empty() { @@ -142,6 +164,8 @@ impl App { self.restoring = true; self.restore_session(event_loop, &state); self.restoring = false; + // Anything left belongs to a pane that failed to spawn. + self.pending_records.clear(); } fn restore_session(&mut self, event_loop: &ActiveEventLoop, state: &session::SessionState) { @@ -183,6 +207,16 @@ impl App { if let Some(state) = self.windows.get_mut(&window_id) { state.title_override = tab.title.clone(); } + // The tab's initial surface *is* the tree's first leaf, so its + // record loads here; `materialize_tab` handles the rest once + // their surfaces exist. + if let Some(root_pane) = self.windows.get(&window_id).map(|s| s.focused_pane) { + self.restore_pane_record( + window_id, + root_pane, + tab.split.first_leaf_scrollback(), + ); + } self.materialize_tab(window_id, tab); } if let Some(first) = tab_ids.first() { @@ -326,6 +360,12 @@ impl App { for (pane_id, identity) in remote_cards { self.register_remote_session_card(window_id, pane_id, &identity); } + for leaf in &leaves { + if leaf.is_root || leaf.remote.is_some() { + continue; + } + self.restore_pane_record(window_id, leaf.pane, leaf.scrollback.clone()); + } self.relayout_and_resize_window(window_id); } @@ -371,6 +411,9 @@ struct LeafSpec { pane: PaneId, cwd: Option, remote: Option, + /// The leaf's persisted scrollback key, restored into the pane once its + /// surface exists. + scrollback: Option, is_root: bool, } @@ -383,12 +426,17 @@ fn build_split_tree( leaves: &mut Vec, ) -> SplitTree { match node { - session::PaneNode::Leaf { cwd, remote } => { + session::PaneNode::Leaf { + cwd, + remote, + scrollback, + } => { let (pane, is_root) = minter.mint(); leaves.push(LeafSpec { pane, cwd: cwd.clone(), remote: remote.clone(), + scrollback: scrollback.clone(), is_root, }); SplitTree::leaf(pane) @@ -411,6 +459,31 @@ fn build_split_tree( } } +/// Every snapshot key a saved session claims. Anything else in the snapshot +/// directory is an orphan the collector may drop. +fn referenced_scrollback_keys(state: &session::SessionState) -> std::collections::HashSet { + fn walk(node: &session::PaneNode, out: &mut std::collections::HashSet) { + match node { + session::PaneNode::Leaf { scrollback, .. } => { + if let Some(key) = scrollback { + out.insert(key.clone()); + } + } + session::PaneNode::Split { first, second, .. } => { + walk(first, out); + walk(second, out); + } + } + } + let mut out = std::collections::HashSet::new(); + for window in &state.windows { + for tab in &window.tabs { + walk(&tab.split, &mut out); + } + } + out +} + fn collect_leaf_ids(tree: &SplitTree, out: &mut Vec) { match tree { SplitTree::Leaf { pane } => out.push(*pane), @@ -469,10 +542,12 @@ mod tests { first: Box::new(session::PaneNode::Leaf { cwd: None, remote: Some(remote.clone()), + scrollback: None, }), second: Box::new(session::PaneNode::Leaf { cwd: Some("/local".to_string()), remote: None, + scrollback: None, }), }; let root = PaneId::new(7); diff --git a/crates/noa-app/src/app/state.rs b/crates/noa-app/src/app/state.rs index fe15459..d7ac321 100644 --- a/crates/noa-app/src/app/state.rs +++ b/crates/noa-app/src/app/state.rs @@ -800,6 +800,7 @@ mod theme_settings_session_tests { server_status: "Stopped".to_string(), scratch_terminal_key: "cmd+shift+t".to_string(), scratch_terminal_size: (100, 25), + scrollback_persist: noa_config::ScrollbackPersist::default(), theme_pair: None, carryover: None, favorites: std::sync::Arc::new(std::collections::HashSet::new()), @@ -1056,6 +1057,45 @@ pub(super) struct Surface { pub(super) hover_link: Option, /// The Session Overview mirror's read-only publish slot (Fix B, REQ-NF-6). pub(super) overview_snapshot: Arc>>>, + /// Key of this pane's persisted scrollback snapshot, when + /// `scrollback-persist` is on. Minted once and then stable for the pane's + /// life so checkpoints overwrite one file rather than littering the + /// directory, and so a restored pane keeps writing to the file it was + /// restored from. `None` while persistence is off. + pub(super) scrollback_key: Option, + /// Session-absolute rows this pane restored from a snapshot — the record + /// region, drawn with a gutter so restored history is not mistaken for + /// live output. Shrinks as scrollback eviction eats into it and is cleared + /// once the whole region has scrolled out. + pub(super) record_rows: Option>, + /// Session-absolute row of the synthetic annotation this pane was restored + /// with — the record separator, or the Stage 0 "not saved" notice. Excluded + /// from capture: it is chrome the app wrote, and re-capturing it would + /// leave one more behind in the history on every relaunch. + pub(super) annotation_row: Option, + /// The key this pane was restored *from*, still on disk. + /// + /// Restoring mints a fresh key rather than adopting the saved one (two noa + /// instances restoring the same session would otherwise share a file), so + /// the old snapshot becomes redundant the moment this pane's own capture + /// lands. It is deleted then rather than at restore, so a crash in between + /// still has a record to come back to, and rather than at the next launch's + /// orphan sweep, so a plaintext copy does not outlive its usefulness. + pub(super) superseded_scrollback_key: Option, + /// The terminal's coordinate generation when `record_rows`/`annotation_row` + /// were computed. + /// + /// Both are session-absolute row indices, and a column-count reflow or a + /// scrollback clear renumbers that space wholesale — the terminal bumps its + /// generation exactly when that happens, and deliberately does *not* bump it + /// for ordinary eviction (which preserves numbering). Comparing against it + /// is therefore the complete staleness test: without it the gutter paints + /// over live rows, capture's `skip_row` deletes a real row from the record, + /// and `Discard restored history` drops live history off the front. + pub(super) record_generation: u64, + /// Whether this pane produced output since its last checkpoint. Keeps the + /// idle checkpoint from re-encoding panes that have not changed. + pub(super) scrollback_dirty: bool, /// Previous frame's snapshot rows + viewport identity, handed back after /// each redraw so `FrameSnapshot::from_terminal_recycle` can reuse row/cell /// allocations and skip clean-row copies when the viewport is unchanged. @@ -1214,6 +1254,12 @@ impl Surface { rect, hover_link: None, overview_snapshot, + scrollback_key: None, + record_rows: None, + annotation_row: None, + superseded_scrollback_key: None, + record_generation: 0, + scrollback_dirty: false, snapshot_recycle: noa_render::FrameSnapshotRecycle::default(), kitty_animation_flag, cursor_blink_state: CursorBlinkState::default(), diff --git a/crates/noa-app/src/app/timers.rs b/crates/noa-app/src/app/timers.rs index 73f6d9f..5a804a7 100644 --- a/crates/noa-app/src/app/timers.rs +++ b/crates/noa-app/src/app/timers.rs @@ -181,6 +181,10 @@ impl App { } } crate::memory::release_reclaimable_memory(); + // Eviction during the burst may have eaten into a restored record; + // re-narrow the gutter ranges here, where every terminal is already + // being locked and the panes are quiescent, rather than per frame. + self.prune_record_regions(); None } diff --git a/crates/noa-app/src/cli.rs b/crates/noa-app/src/cli.rs index bb1ec6e..8f82651 100644 --- a/crates/noa-app/src/cli.rs +++ b/crates/noa-app/src/cli.rs @@ -364,6 +364,34 @@ fn show_config_output(config: &StartupConfig) -> String { noa_config::WindowSaveState::Always => "always", }, ); + push_line( + &mut out, + "scrollback-persist", + match config.scrollback_persist { + noa_config::ScrollbackPersist::Never => "never", + noa_config::ScrollbackPersist::Tail => "tail", + }, + ); + push_line( + &mut out, + "scrollback-persist-limit", + &config.scrollback_persist_limit.to_string(), + ); + push_line( + &mut out, + "scrollback-persist-total-limit", + &config.scrollback_persist_total_limit.to_string(), + ); + push_line( + &mut out, + "scrollback-persist-max-age-days", + &config.scrollback_persist_max_age_days.to_string(), + ); + push_line( + &mut out, + "scrollback-persist-encrypt", + &config.scrollback_persist_encrypt.to_string(), + ); push_line( &mut out, "macos-option-as-alt", diff --git a/crates/noa-app/src/command_palette.rs b/crates/noa-app/src/command_palette.rs index 4c569a2..90b7f8e 100644 --- a/crates/noa-app/src/command_palette.rs +++ b/crates/noa-app/src/command_palette.rs @@ -36,6 +36,8 @@ pub(crate) fn command_palette_title(command: AppCommand) -> &'static str { AppCommand::AttachRemote => "Attach Remote", AppCommand::SendSelectionToPane => "Send Selection to Pane", AppCommand::ExportScrollback => "Export Scrollback to File", + AppCommand::DiscardRestoredHistory => "Discard Restored History", + AppCommand::CheckpointScrollback => "Checkpoint Scrollback Now", AppCommand::PipeScrollbackToPager => "Pipe Scrollback to Pager", AppCommand::Terminal(TerminalAction::Clear) => "Clear Screen", AppCommand::Terminal(TerminalAction::ClearScrollback) => "Clear Scrollback", @@ -143,6 +145,8 @@ pub(crate) fn command_palette_entries() -> &'static [AppCommand] { AppCommand::AttachRemote, AppCommand::SendSelectionToPane, AppCommand::ExportScrollback, + AppCommand::DiscardRestoredHistory, + AppCommand::CheckpointScrollback, AppCommand::PipeScrollbackToPager, AppCommand::Terminal(TerminalAction::Clear), AppCommand::Terminal(TerminalAction::ClearScrollback), @@ -350,7 +354,10 @@ pub(crate) fn command_category(command: AppCommand) -> CommandCategory { | AppCommand::Paste | AppCommand::SendSelectionToPane | AppCommand::Terminal(TerminalAction::SelectAll) => CommandCategory::Clipboard, - AppCommand::ExportScrollback | AppCommand::PipeScrollbackToPager => CommandCategory::Scroll, + AppCommand::ExportScrollback + | AppCommand::PipeScrollbackToPager + | AppCommand::DiscardRestoredHistory + | AppCommand::CheckpointScrollback => CommandCategory::Scroll, AppCommand::Terminal(TerminalAction::Clear) | AppCommand::Terminal(TerminalAction::ClearScrollback) | AppCommand::FontSize(_) diff --git a/crates/noa-app/src/commands/command.rs b/crates/noa-app/src/commands/command.rs index cc39765..5b2947c 100644 --- a/crates/noa-app/src/commands/command.rs +++ b/crates/noa-app/src/commands/command.rs @@ -26,6 +26,12 @@ pub enum AppCommand { SendSelectionToPane, ExportScrollback, PipeScrollbackToPager, + /// Drop the record restored from a persisted snapshot, leaving the live + /// session untouched (`scrollback-persist`). + DiscardRestoredHistory, + /// Write this pane's scrollback snapshot now instead of waiting for the + /// next idle checkpoint (`scrollback-persist`). + CheckpointScrollback, Terminal(TerminalAction), FontSize(FontSizeAction), Search(SearchAction), @@ -213,6 +219,7 @@ impl AppCommand { AppCommand::SendSelectionToPane => Self::SEND_SELECTION_TO_PANE_MENU_ID, AppCommand::ExportScrollback => Self::EXPORT_SCROLLBACK_MENU_ID, AppCommand::PipeScrollbackToPager => Self::PIPE_SCROLLBACK_TO_PAGER_MENU_ID, + AppCommand::DiscardRestoredHistory | AppCommand::CheckpointScrollback => "", AppCommand::Terminal(TerminalAction::Clear) => Self::TERMINAL_CLEAR_MENU_ID, AppCommand::Terminal(TerminalAction::ClearScrollback) => { Self::TERMINAL_CLEAR_SCROLLBACK_MENU_ID @@ -372,6 +379,8 @@ impl AppCommand { Self::SendSelectionToPane => "pane.send-selection", Self::ExportScrollback => "terminal.export-scrollback", Self::PipeScrollbackToPager => "terminal.pipe-scrollback-to-pager", + Self::DiscardRestoredHistory => "scrollback.discard-restored", + Self::CheckpointScrollback => "scrollback.checkpoint", Self::Terminal(TerminalAction::Clear) => "terminal.clear", Self::Terminal(TerminalAction::ClearScrollback) => "terminal.clear-scrollback", Self::Terminal(TerminalAction::SelectAll) => "terminal.select-all", @@ -454,6 +463,8 @@ impl AppCommand { "remote.attach" => Some(Self::AttachRemote), "pane.send-selection" => Some(Self::SendSelectionToPane), "terminal.export-scrollback" => Some(Self::ExportScrollback), + "scrollback.discard-restored" => Some(Self::DiscardRestoredHistory), + "scrollback.checkpoint" => Some(Self::CheckpointScrollback), "terminal.pipe-scrollback-to-pager" => Some(Self::PipeScrollbackToPager), "terminal.clear" => Some(Self::Terminal(TerminalAction::Clear)), "terminal.clear-scrollback" => Some(Self::Terminal(TerminalAction::ClearScrollback)), diff --git a/crates/noa-app/src/lib.rs b/crates/noa-app/src/lib.rs index 00bb34e..a0a63ba 100644 --- a/crates/noa-app/src/lib.rs +++ b/crates/noa-app/src/lib.rs @@ -33,7 +33,10 @@ mod memory; mod mouse; mod notification; mod process_monitor; +mod record_view; mod remote_attach; +mod scrollback_crypt; +mod scrollback_persist; mod search_prompt; mod secure_input; mod session; diff --git a/crates/noa-app/src/macos_overlay/tests.rs b/crates/noa-app/src/macos_overlay/tests.rs index 7cd1d6e..6a0de6e 100644 --- a/crates/noa-app/src/macos_overlay/tests.rs +++ b/crates/noa-app/src/macos_overlay/tests.rs @@ -48,6 +48,7 @@ fn settings_init() -> ThemeSettingsInit { server_status: "Stopped".to_string(), scratch_terminal_key: "cmd+shift+t".to_string(), scratch_terminal_size: (100, 25), + scrollback_persist: noa_config::ScrollbackPersist::default(), theme_pair: None, carryover: None, favorites: std::sync::Arc::new(std::collections::HashSet::new()), @@ -214,6 +215,7 @@ fn test_theme_settings_init() -> ThemeSettingsInit { server_status: "Stopped".to_string(), scratch_terminal_key: "cmd+shift+t".to_string(), scratch_terminal_size: (100, 25), + scrollback_persist: noa_config::ScrollbackPersist::default(), theme_pair: None, carryover: None, favorites: std::sync::Arc::new(std::collections::HashSet::new()), diff --git a/crates/noa-app/src/record_view.rs b/crates/noa-app/src/record_view.rs new file mode 100644 index 0000000..b5ebc91 --- /dev/null +++ b/crates/noa-app/src/record_view.rs @@ -0,0 +1,178 @@ +//! The synthetic rows that make a restored pane legible: the record/live +//! separator, and the notice shown when a pane's layout came back but its +//! contents did not. +//! +//! Spec: `docs/specs/scrollback-persistence.md` §5 and Stage 0. +//! +//! ## Why these are rows and not an overlay +//! +//! Both of these are content, not chrome. They have to scroll with the +//! history they annotate, survive being copied out with it, and be findable by +//! search — an overlay pinned to the viewport would claim the boundary sits +//! wherever the user happens to have scrolled. Being rows also means the +//! renderer needs to know nothing about them. +//! +//! They are pushed as *history*, never fed through the parser, so nothing here +//! can move the cursor or be mistaken for program output. + +use noa_core::{CellAttrs, Color}; +use noa_grid::{Cell, Row}; + +use crate::session_store::civil_from_unix_secs; + +/// Build a full-width row of `text`, padded to `cols`, in `attrs`. +/// +/// Text longer than the row is truncated rather than wrapped: these lines are +/// annotations, and a soft-wrapped annotation would read as two records. +fn annotation_row(text: &str, cols: u16, attrs: CellAttrs) -> Row { + let width = usize::from(cols); + let mut cells: Vec = text + .chars() + .take(width) + .map(|ch| Cell { + ch, + fg: Color::Default, + attrs, + ..Cell::default() + }) + .collect(); + cells.resize(width, Cell::default()); + Row::from_cells(cells, false, false) +} + +/// `2026-07-28 14:03` in the viewer's local time. +fn stamp(saved_at: u64, local_offset_seconds: i64) -> String { + let clock = civil_from_unix_secs(saved_at as i64 + local_offset_seconds); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + clock.year, clock.month, clock.day, clock.hour, clock.minute + ) +} + +/// Pad `label` out to `cols` with a horizontal rule on both sides, so the +/// boundary reads as a line across the pane rather than a stray sentence. +fn ruled(label: &str, cols: u16) -> String { + let width = usize::from(cols); + let label = format!(" {label} "); + // A bare sentence with no rule reads as output rather than as a boundary, + // so keep at least one rule character on each side; only a pane too narrow + // for even that falls back to the label alone. + if label.chars().count() + 2 > width { + return label.trim().to_string(); + } + let remaining = width - label.chars().count(); + let left = remaining / 2; + let right = remaining - left; + format!( + "{}{label}{}", + "\u{2500}".repeat(left), + "\u{2500}".repeat(right) + ) +} + +/// The boundary row between restored history and the live session. +/// +/// This is the whole answer to "is what I am reading live?": everything above +/// it was recorded at the stamped time and is not coming back, everything +/// below is this session. +pub fn separator_row(saved_at: u64, local_offset_seconds: i64, cols: u16) -> Row { + annotation_row( + &ruled( + &format!( + "record · saved {} · live below", + stamp(saved_at, local_offset_seconds) + ), + cols, + ), + cols, + CellAttrs::FAINT, + ) +} + +/// The Stage 0 notice: shown in a pane whose layout was restored but whose +/// contents were not. +/// +/// Restoring the tabs and splits without saying anything about the contents is +/// what makes an empty restored pane feel like a loss rather than a setting — +/// the layout coming back is itself a promise that the output did too. This is +/// the row that keeps that promise honest, and it names the key that would +/// change the answer. +pub fn not_persisted_notice_row(cols: u16) -> Row { + annotation_row( + &ruled( + "layout restored · previous output was not saved · set scrollback-persist = tail to keep it", + cols, + ), + cols, + CellAttrs::FAINT, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_of(row: &Row) -> String { + row.cells.iter().map(|cell| cell.ch).collect() + } + + #[test] + fn the_separator_names_the_time_it_was_saved() { + // 2023-11-14 22:13:20 UTC. + let row = separator_row(1_700_000_000, 0, 80); + let text = text_of(&row); + assert!(text.contains("2023-11-14 22:13"), "{text:?}"); + assert!(text.contains("record"), "{text:?}"); + assert!(text.contains("live below"), "{text:?}"); + } + + #[test] + fn the_separator_honors_the_local_utc_offset() { + let utc = text_of(&separator_row(1_700_000_000, 0, 80)); + let jst = text_of(&separator_row(1_700_000_000, 9 * 3600, 80)); + assert!(utc.contains("22:13"), "{utc:?}"); + assert!(jst.contains("2023-11-15 07:13"), "{jst:?}"); + } + + #[test] + fn annotation_rows_fill_exactly_one_grid_width() { + for cols in [8u16, 40, 80, 200] { + assert_eq!(separator_row(0, 0, cols).cells.len(), usize::from(cols)); + assert_eq!( + not_persisted_notice_row(cols).cells.len(), + usize::from(cols) + ); + } + } + + #[test] + fn an_annotation_never_soft_wraps() { + // A wrapped annotation would join the line below it into one logical + // line for copy, search and reflow. + assert!(!separator_row(0, 0, 20).wrapped); + assert!(!not_persisted_notice_row(20).wrapped); + } + + #[test] + fn a_narrow_pane_truncates_rather_than_overflowing() { + let row = not_persisted_notice_row(10); + assert_eq!(row.cells.len(), 10); + let text = text_of(&row); + assert!(text.starts_with("layout"), "{text:?}"); + } + + #[test] + fn the_notice_names_the_key_that_changes_the_answer() { + let text = text_of(¬_persisted_notice_row(120)); + assert!( + text.contains("scrollback-persist = tail"), + "the notice is only useful if it says what to do: {text:?}" + ); + } + + #[test] + fn annotations_are_faint_so_they_read_as_chrome_not_output() { + let row = separator_row(0, 0, 40); + assert!(row.cells[0].attrs.contains(CellAttrs::FAINT)); + } +} diff --git a/crates/noa-app/src/scrollback_crypt.rs b/crates/noa-app/src/scrollback_crypt.rs new file mode 100644 index 0000000..2315c63 --- /dev/null +++ b/crates/noa-app/src/scrollback_crypt.rs @@ -0,0 +1,259 @@ +//! Encryption for persisted scrollback snapshots (`scrollback-persist-encrypt`). +//! +//! Spec: `docs/specs/scrollback-persistence.md` §4.6 Stage 2. +//! +//! ## Why this lives here and not in `noa-grid` +//! +//! `noa-grid` is the platform-agnostic state model; it has no business knowing +//! about keychains. The snapshot format it produces is treated here as an +//! opaque plaintext and wrapped in its own container, so the two layers stay +//! independent: a snapshot decodes the same whether or not it was ever sealed, +//! and turning encryption on or off does not change the inner format at all. +//! +//! ## Container +//! +//! ```text +//! magic 6 b"NOAEN\0" +//! version 2 u16 +//! nonce 12 random per write +//! body … AES-256-GCM(plaintext, aad = magic || version) +//! ``` +//! +//! The AAD binds the header so a file cannot be replayed under a different +//! version. Nothing about the plaintext leaks except its length. +//! +//! ## Key +//! +//! One 256-bit key per user, generated on first use and stored as a generic +//! password in the login keychain, marked non-syncable so it never reaches +//! iCloud. Losing the keychain entry means losing the records — which is the +//! honest trade for at-rest protection, and the reason this is opt-in. + +const MAGIC: &[u8; 6] = b"NOAEN\0"; +const VERSION: u16 = 1; +const NONCE_LEN: usize = 12; +const HEADER_LEN: usize = 8; +const KEY_LEN: usize = 32; + +#[cfg(target_os = "macos")] +const KEYCHAIN_SERVICE: &str = "noa"; +#[cfg(target_os = "macos")] +const KEYCHAIN_ACCOUNT: &str = "scrollback-persist-key"; + +/// Whether `bytes` is a sealed container (rather than a bare snapshot). +/// +/// Read paths use this instead of the config value: a file written before +/// encryption was turned on is still readable afterwards, and one written +/// before it was turned off does not become garbage. +pub fn is_sealed(bytes: &[u8]) -> bool { + bytes.len() >= HEADER_LEN && &bytes[..MAGIC.len()] == MAGIC +} + +/// Fetch the snapshot key, creating and storing one on first use. +/// +/// `None` when the keychain is unavailable or refuses — callers must then +/// decline to write rather than fall back to plaintext, since the user asked +/// for encryption specifically. +#[cfg(target_os = "macos")] +fn key() -> Option<[u8; KEY_LEN]> { + use security_framework::passwords::{get_generic_password, set_generic_password_options}; + use security_framework::passwords_options::PasswordOptions; + + if let Ok(existing) = get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + && let Ok(key) = <[u8; KEY_LEN]>::try_from(existing.as_slice()) + { + return Some(key); + } + + let mut fresh = [0u8; KEY_LEN]; + security_framework::random::SecRandom::default() + .copy_bytes(&mut fresh) + .ok()?; + + let mut options = PasswordOptions::new_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + // Never sync to iCloud Keychain: a record of one machine's terminal output + // has no business appearing on another. + options.set_access_synchronized(Some(false)); + options.set_label("noa persisted scrollback key"); + match set_generic_password_options(&fresh, options) { + Ok(()) => Some(fresh), + Err(err) => { + log::warn!("could not store the scrollback encryption key: {err}"); + None + } + } +} + +#[cfg(not(target_os = "macos"))] +fn key() -> Option<[u8; KEY_LEN]> { + None +} + +/// Wrap `plaintext` in a sealed container. `None` when no key is available; +/// the caller must then skip the write rather than store it in the clear. +pub fn seal(plaintext: &[u8]) -> Option> { + let mut nonce = [0u8; NONCE_LEN]; + random_bytes(&mut nonce)?; + seal_with(&key()?, plaintext, &nonce) +} + +/// The container half of [`seal`], with the key and nonce supplied. +/// +/// Split out so the format is testable without touching a keychain: a unit +/// test that called [`seal`] would mint and store a real key on the developer's +/// machine as a side effect. +fn seal_with( + key: &[u8; KEY_LEN], + plaintext: &[u8], + nonce_bytes: &[u8; NONCE_LEN], +) -> Option> { + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::{Aes256Gcm, Nonce}; + + let cipher = Aes256Gcm::new_from_slice(key).ok()?; + let nonce = Nonce::try_from(&nonce_bytes[..]).ok()?; + + let mut header = Vec::with_capacity(HEADER_LEN); + header.extend_from_slice(MAGIC); + header.extend_from_slice(&VERSION.to_le_bytes()); + + let sealed = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad: &header, + }, + ) + .ok()?; + + let mut out = Vec::with_capacity(HEADER_LEN + NONCE_LEN + sealed.len()); + out.extend_from_slice(&header); + out.extend_from_slice(nonce_bytes); + out.extend_from_slice(&sealed); + Some(out) +} + +/// Unwrap a sealed container. `None` for anything malformed, truncated, +/// tampered with, or encrypted under a key this machine no longer has — the +/// same "degrade to no record" contract the snapshot format itself follows. +pub fn open(bytes: &[u8]) -> Option> { + if !is_sealed(bytes) { + return None; + } + open_with(&key()?, bytes) +} + +/// The container half of [`open`], with the key supplied (see [`seal_with`]). +fn open_with(key: &[u8; KEY_LEN], bytes: &[u8]) -> Option> { + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::{Aes256Gcm, Nonce}; + + if !is_sealed(bytes) { + return None; + } + let version = u16::from_le_bytes(bytes.get(6..8)?.try_into().ok()?); + if version != VERSION { + return None; + } + let nonce_bytes = bytes.get(HEADER_LEN..HEADER_LEN + NONCE_LEN)?; + let ciphertext = bytes.get(HEADER_LEN + NONCE_LEN..)?; + + let cipher = Aes256Gcm::new_from_slice(key).ok()?; + cipher + .decrypt( + &Nonce::try_from(nonce_bytes).ok()?, + Payload { + msg: ciphertext, + aad: &bytes[..HEADER_LEN], + }, + ) + .ok() +} + +#[cfg(target_os = "macos")] +fn random_bytes(buf: &mut [u8]) -> Option<()> { + security_framework::random::SecRandom::default() + .copy_bytes(buf) + .ok() +} + +#[cfg(not(target_os = "macos"))] +fn random_bytes(_buf: &mut [u8]) -> Option<()> { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_snapshot_is_not_mistaken_for_a_sealed_one() { + // Read paths branch on the container, not on the config value, so a + // file written before encryption was enabled must not be fed to the + // decryptor. + assert!(!is_sealed(b"NOASB\0\x01\x00")); + assert!(!is_sealed(b"")); + assert!(!is_sealed(b"NOAE")); + assert!(is_sealed(b"NOAEN\0\x01\x00")); + } + + /// Tests never call [`seal`]/[`open`] directly: those mint and store a real + /// key in the developer's login keychain as a side effect. The container is + /// exercised through the `*_with` split instead. + const TEST_KEY: [u8; KEY_LEN] = [7u8; KEY_LEN]; + + fn seal_for_test(plaintext: &[u8]) -> Vec { + seal_with(&TEST_KEY, plaintext, &[3u8; NONCE_LEN]).expect("sealing with a supplied key") + } + + #[test] + fn a_sealed_container_roundtrips_and_hides_its_plaintext() { + let sealed = seal_for_test(b"secret output"); + + assert!(is_sealed(&sealed)); + assert!( + !sealed.windows(13).any(|w| w == b"secret output"), + "the plaintext must not appear in the container" + ); + assert_eq!( + open_with(&TEST_KEY, &sealed).as_deref(), + Some(&b"secret output"[..]) + ); + } + + #[test] + fn a_tampered_container_fails_closed() { + let sealed = seal_for_test(b"secret output"); + + let mut body = sealed.clone(); + let last = body.len() - 1; + body[last] ^= 0xff; + assert!( + open_with(&TEST_KEY, &body).is_none(), + "GCM must reject a modified body" + ); + + // The header is authenticated as AAD, so editing it must fail too. + let mut version = sealed.clone(); + version[6] = 9; + assert!(open_with(&TEST_KEY, &version).is_none()); + + let mut nonce = sealed.clone(); + nonce[HEADER_LEN] ^= 0xff; + assert!(open_with(&TEST_KEY, &nonce).is_none()); + + assert!(open_with(&TEST_KEY, &sealed[..HEADER_LEN + 4]).is_none()); + } + + #[test] + fn the_wrong_key_cannot_open_a_container() { + let sealed = seal_for_test(b"secret output"); + assert!(open_with(&[9u8; KEY_LEN], &sealed).is_none()); + } + + #[test] + fn opening_a_bare_snapshot_returns_none() { + assert!(open(b"NOASB\0\x01\x00whatever").is_none()); + } +} diff --git a/crates/noa-app/src/scrollback_persist.rs b/crates/noa-app/src/scrollback_persist.rs new file mode 100644 index 0000000..a9d3365 --- /dev/null +++ b/crates/noa-app/src/scrollback_persist.rs @@ -0,0 +1,687 @@ +//! On-disk store for persisted scrollback snapshots, and the worker that +//! writes them off the main thread. +//! +//! Spec: `docs/specs/scrollback-persistence.md`. Ghostty has no analog — it +//! restores window topology but never terminal contents. +//! +//! ## Why this is not the session-state writer +//! +//! `session_persist::SessionPersister` writes `session.json` on *every* +//! structural change (new tab, split, close). Snapshots cannot share that +//! trigger: a megabyte of terminal output per pane is not something to +//! re-encode each time a split moves. They are written on clean quit and on +//! idle checkpoints instead, which is why this is a second worker with its own +//! queue rather than another field on the session state. +//! +//! ## Storage posture +//! +//! Scrollback routinely contains material that has never touched a disk +//! before — exported credentials, tokens echoed by CLIs, remote URLs with +//! embedded PATs. Persisting it changes what a stolen laptop yields, which is +//! why the feature is opt-in and why the baseline here is not negotiable: +//! the directory is `0700`, files are `0600`, and the directory is excluded +//! from Time Machine so a record cannot leak onward through a backup. Nothing +//! is created at all while `scrollback-persist = never`. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::thread::JoinHandle; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Snapshot file extension. Files that do not carry it are left alone by the +/// collector — this directory is noa's, but being destructive on a path the +/// user could have put something else in is not worth the tidiness. +const EXTENSION: &str = "nsb"; + +/// Extension of the temp file an atomic write renames from. Swept by the +/// collector: it holds the same plaintext the snapshot does, so an interrupted +/// write must not leave terminal output behind that nothing ever reclaims. +const TEMP_EXTENSION: &str = "tmp"; + +/// Length of a snapshot key, in hex characters. +const KEY_LEN: usize = 16; + +/// Seconds in a day, for `scrollback-persist-max-age-days`. +const SECONDS_PER_DAY: u64 = 86_400; + +pub fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0) +} + +/// Whether `key` is a well-formed snapshot key. +/// +/// Keys arrive from `session.json`, which is a plain file the user (or +/// anything running as them) can edit. They are interpolated into a path, so +/// an unvalidated key is a path-traversal primitive: `../../../.ssh/id_rsa` +/// would make the collector delete, and the writer overwrite, an arbitrary +/// file. Exactly [`KEY_LEN`] lowercase hex characters can express neither a +/// separator nor a `..`. +pub fn is_valid_key(key: &str) -> bool { + key.len() == KEY_LEN + && key + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Mint a fresh snapshot key. Uniqueness only has to hold against the other +/// keys alive in this session and the ones left on disk by previous runs, so +/// the wall clock mixed with a per-process counter is sufficient; the +/// collector removes anything a live session does not claim anyway. +pub fn mint_key(counter: u64) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos() as u64) + .unwrap_or(0); + // Cheap 64-bit mix (splitmix64 finalizer) so consecutive keys do not share + // a long common prefix, which would make them annoying to tell apart in a + // directory listing. + let mut z = nanos.wrapping_add(counter.wrapping_mul(0x9e37_79b9_7f4a_7c15)); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^= z >> 31; + format!("{z:016x}") +} + +pub fn snapshot_path(dir: &Path, key: &str) -> Option { + is_valid_key(key).then(|| dir.join(format!("{key}.{EXTENSION}"))) +} + +/// Create the snapshot directory `0700` and mark it as backup-excluded. +pub fn ensure_dir(dir: &Path) -> io::Result<()> { + fs::create_dir_all(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + // Unconditionally, not just on creation: the call is best-effort and its + // result is discarded, so a single transient failure would otherwise leave + // every snapshot from then on inside Time Machine — and a directory + // restored from a backup, or created by an older build, would never be + // marked at all. Re-setting an attribute that is already set is free + // relative to the snapshot write that follows. + exclude_from_backup(dir); + Ok(()) +} + +/// Ask Foundation to keep `dir` out of Time Machine and iCloud backups. +/// +/// Best-effort: a failure here means the records are backed up like any other +/// file in Application Support, which is the status quo for every other app, +/// not a reason to refuse to persist. +#[cfg(target_os = "macos")] +fn exclude_from_backup(dir: &Path) { + use objc2_foundation::{NSNumber, NSString, NSURL, NSURLIsExcludedFromBackupKey}; + + let Some(path) = dir.to_str() else { + return; + }; + unsafe { + let url = NSURL::fileURLWithPath(&NSString::from_str(path)); + let value = NSNumber::numberWithBool(true); + let _ = url.setResourceValue_forKey_error(Some(&value), NSURLIsExcludedFromBackupKey); + } +} + +#[cfg(not(target_os = "macos"))] +fn exclude_from_backup(_dir: &Path) {} + +/// Write `bytes` to `key`'s snapshot file, atomically and `0600`. +pub fn write(dir: &Path, key: &str, bytes: &[u8]) -> io::Result<()> { + let Some(path) = snapshot_path(dir, key) else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid scrollback snapshot key", + )); + }; + ensure_dir(dir)?; + + let temp = path.with_extension(TEMP_EXTENSION); + { + use io::Write as _; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + } + // `create` honors the mode only when it actually creates the file; a + // leftover temp from a crashed write would keep its old permissions. + fs::set_permissions(&temp, fs::Permissions::from_mode(0o600))?; + fs::rename(&temp, &path) +} + +/// Read `key`'s snapshot. `None` for a missing, unreadable, or invalid key — +/// a snapshot is a convenience, and a bad one degrades to "no record". +pub fn read(dir: &Path, key: &str) -> Option> { + fs::read(snapshot_path(dir, key)?).ok() +} + +pub fn remove(dir: &Path, key: &str) { + if let Some(path) = snapshot_path(dir, key) { + let _ = fs::remove_file(&path); + let _ = fs::remove_file(path.with_extension(TEMP_EXTENSION)); + } +} + +/// One snapshot file the collector is considering. +struct Entry { + path: PathBuf, + key: String, + modified: u64, + size: u64, +} + +/// Delete leftover temp files from interrupted writes. +/// +/// They are ours by construction (`<16-hex>.tmp` beside the snapshots) and hold +/// the same plaintext, so leaving them means `scrollback-persist = never` does +/// not actually drain the directory. +fn sweep_temp_files(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some(TEMP_EXTENSION) { + continue; + } + if path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(is_valid_key) + { + let _ = fs::remove_file(path); + } + } +} + +fn list(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some(EXTENSION) { + continue; + } + let Some(key) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if !is_valid_key(key) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + let modified = meta + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + out.push(Entry { + key: key.to_string(), + path, + modified, + size: meta.len(), + }); + } + out +} + +/// Drop snapshots that no live session claims, that have expired, or that push +/// the directory over its total budget. Run at launch, before restore, so a +/// record the user asked to expire is never shown and then deleted. +/// +/// `max_age_days == 0` disables expiry; `total_limit == 0` keeps nothing. +pub fn collect(dir: &Path, referenced: &HashSet, total_limit: u64, max_age_days: u64) { + sweep_temp_files(dir); + let mut entries = list(dir); + let now = now_unix(); + + entries.retain(|entry| { + let orphaned = !referenced.contains(&entry.key); + let expired = max_age_days > 0 + && now.saturating_sub(entry.modified) > max_age_days.saturating_mul(SECONDS_PER_DAY); + if orphaned || expired { + let _ = fs::remove_file(&entry.path); + return false; + } + true + }); + + let mut total: u64 = entries.iter().map(|entry| entry.size).sum(); + if total <= total_limit { + return; + } + // Oldest first: the pane a user has not touched in the longest is the one + // whose record they are least likely to be coming back for. + entries.sort_by_key(|entry| entry.modified); + for entry in &entries { + if total <= total_limit { + break; + } + if fs::remove_file(&entry.path).is_ok() { + total = total.saturating_sub(entry.size); + } + } +} + +// --------------------------------------------------------------------------- +// Off-main-thread writer +// --------------------------------------------------------------------------- + +enum Job { + /// Rows lifted from a terminal, still to be encoded. Interning and deflate + /// happen here rather than under the terminal lock on the main thread. + Write { + key: String, + input: noa_grid::ScrollbackSnapshotInput, + saved_at: u64, + max_bytes: usize, + encrypt: bool, + }, + Remove { + key: String, + }, +} + +/// Serializes snapshot writes onto one background thread. +/// +/// Capture has to happen on the main thread (it reads the shared `Terminal`), +/// but the encode result is just bytes, so the disk write moves here. A burst +/// — a checkpoint firing across ten panes, or quit capturing all of them — +/// coalesces per key: only the newest bytes for a pane are ever written. +pub struct ScrollbackPersister { + tx: Option>, + worker: Option>, +} + +impl ScrollbackPersister { + pub fn spawn(dir: PathBuf) -> Self { + let (tx, rx) = crossbeam_channel::unbounded::(); + let worker_dir = dir; + let worker = std::thread::Builder::new() + .name("scrollback-persist".to_string()) + .spawn(move || { + while let Ok(job) = rx.recv() { + // Coalesce whatever else is queued, keeping the newest + // action per key. + let mut pending: HashMap = HashMap::new(); + let key = match &job { + Job::Write { key, .. } | Job::Remove { key } => key.clone(), + }; + pending.insert(key, job); + while let Ok(next) = rx.try_recv() { + let key = match &next { + Job::Write { key, .. } | Job::Remove { key } => key.clone(), + }; + pending.insert(key, next); + } + for job in pending.into_values() { + match job { + Job::Write { + key, + input, + saved_at, + max_bytes, + encrypt, + } => { + let encoded = noa_grid::snapshot::encode_tail( + &input.rows, + input.cols, + saved_at, + &input.hyperlinks, + max_bytes, + ); + let encoded = match (encoded, encrypt) { + (Some(bytes), false) => Some(bytes), + (Some(bytes), true) => { + match crate::scrollback_crypt::seal(&bytes) { + Some(sealed) => Some(sealed), + // Encryption was asked for and is + // unavailable: refuse rather than + // quietly store the record in the + // clear. + None => { + log::warn!( + "scrollback-persist-encrypt is on but no key \ + is available; snapshot {key} not written" + ); + None + } + } + } + (None, _) => None, + }; + match encoded { + Some(bytes) => { + if let Err(err) = write(&worker_dir, &key, &bytes) { + log::warn!( + "failed to save scrollback snapshot {key}: {err}" + ); + } + } + // Nothing worth keeping (or nothing safe to + // keep): drop whatever the pane wrote earlier. + None => remove(&worker_dir, &key), + } + } + Job::Remove { key } => remove(&worker_dir, &key), + } + } + } + }) + .expect("failed to spawn the scrollback-persist thread"); + Self { + tx: Some(tx), + worker: Some(worker), + } + } + + /// Queue `input` for `key`, to be encoded and written on the worker. + /// Never blocks. + pub fn save( + &self, + key: String, + input: noa_grid::ScrollbackSnapshotInput, + saved_at: u64, + max_bytes: usize, + encrypt: bool, + ) { + if let Some(tx) = self.tx.as_ref() { + let _ = tx.send(Job::Write { + key, + input, + saved_at, + max_bytes, + encrypt, + }); + } + } + + /// Queue deletion of `key`'s snapshot. Never blocks. + pub fn discard(&self, key: String) { + if let Some(tx) = self.tx.as_ref() { + let _ = tx.send(Job::Remove { key }); + } + } + + /// Flush everything queued and stop the worker. Later calls are ignored. + pub fn flush(&mut self) { + self.tx.take(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +impl Drop for ScrollbackPersister { + fn drop(&mut self) { + self.flush(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "noa-scrollback-{name}-{}-{}", + std::process::id(), + now_unix() + )); + let _ = fs::remove_dir_all(&dir); + dir + } + + #[test] + fn a_key_that_could_escape_the_directory_is_rejected() { + // session.json is user-writable, so these are reachable inputs. + for key in [ + "../../../etc/passwd", + "..", + "/etc/passwd", + "a/b", + "0123456789abcde", // too short + "0123456789abcdef0", // too long + "0123456789ABCDEF", // uppercase + "0123456789abcdeg", // not hex + "", + ] { + assert!(!is_valid_key(key), "{key:?} must be rejected"); + assert!(snapshot_path(Path::new("/tmp"), key).is_none(), "{key:?}"); + } + assert!(is_valid_key(&mint_key(0))); + assert!(is_valid_key("0123456789abcdef")); + } + + #[test] + fn writing_an_invalid_key_fails_instead_of_touching_a_path() { + let dir = temp_dir("invalid-key"); + assert!(write(&dir, "../escape", b"x").is_err()); + assert!(!dir.exists(), "a rejected key must not even create the dir"); + } + + #[test] + fn minted_keys_are_distinct() { + let keys: HashSet = (0..1000).map(mint_key).collect(); + assert_eq!(keys.len(), 1000); + } + + #[test] + fn a_snapshot_roundtrips_through_the_directory_with_locked_down_modes() { + let dir = temp_dir("roundtrip"); + let key = mint_key(1); + write(&dir, &key, b"payload").expect("write succeeds"); + + assert_eq!(read(&dir, &key).as_deref(), Some(&b"payload"[..])); + let file_mode = fs::metadata(snapshot_path(&dir, &key).unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(file_mode, 0o600, "snapshots must not be world-readable"); + assert_eq!(dir_mode, 0o700); + + remove(&dir, &key); + assert!(read(&dir, &key).is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn reading_a_missing_snapshot_is_none_not_an_error() { + let dir = temp_dir("missing"); + assert!(read(&dir, &mint_key(2)).is_none()); + } + + #[test] + fn the_collector_drops_snapshots_no_session_claims() { + let dir = temp_dir("orphans"); + let kept = mint_key(3); + let orphan = mint_key(4); + write(&dir, &kept, b"kept").unwrap(); + write(&dir, &orphan, b"orphan").unwrap(); + + collect(&dir, &HashSet::from([kept.clone()]), u64::MAX, 0); + + assert!(read(&dir, &kept).is_some()); + assert!(read(&dir, &orphan).is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_collector_enforces_the_total_budget_oldest_first() { + let dir = temp_dir("budget"); + let old = mint_key(5); + let new = mint_key(6); + write(&dir, &old, &vec![0u8; 400]).unwrap(); + write(&dir, &new, &vec![0u8; 400]).unwrap(); + // Backdate `old` so the ordering is deterministic rather than relying + // on two writes landing in different filesystem timestamp ticks. + let old_path = snapshot_path(&dir, &old).unwrap(); + let stale = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); + fs::File::open(&old_path) + .unwrap() + .set_modified(stale) + .unwrap(); + + let referenced = HashSet::from([old.clone(), new.clone()]); + collect(&dir, &referenced, 500, 0); + + assert!(read(&dir, &old).is_none(), "the oldest goes first"); + assert!(read(&dir, &new).is_some()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_collector_expires_by_age_and_zero_days_never_expires() { + let dir = temp_dir("age"); + let key = mint_key(7); + write(&dir, &key, b"old").unwrap(); + let stale = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); + fs::File::open(snapshot_path(&dir, &key).unwrap()) + .unwrap() + .set_modified(stale) + .unwrap(); + let referenced = HashSet::from([key.clone()]); + + collect(&dir, &referenced, u64::MAX, 0); + assert!(read(&dir, &key).is_some(), "0 days means never expire"); + + collect(&dir, &referenced, u64::MAX, 7); + assert!(read(&dir, &key).is_none()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_collector_sweeps_interrupted_temp_writes() { + // A `.tmp` holds the same plaintext the snapshot does, so leaving it + // means turning the feature off does not actually drain the directory. + let dir = temp_dir("temp-sweep"); + ensure_dir(&dir).unwrap(); + let key = mint_key(21); + let temp = dir.join(format!("{key}.tmp")); + fs::write(&temp, b"interrupted plaintext").unwrap(); + + collect(&dir, &HashSet::new(), 0, 0); + + assert!(!temp.exists(), "the temp file must be reclaimed"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_collector_leaves_foreign_files_alone() { + let dir = temp_dir("foreign"); + ensure_dir(&dir).unwrap(); + let foreign = dir.join("notes.txt"); + fs::write(&foreign, b"not ours").unwrap(); + + collect(&dir, &HashSet::new(), 0, 1); + + assert!(foreign.exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn bytes_written_by_the_store_decode_as_a_grid_snapshot() { + // The store is format-agnostic, so nothing else pins the two halves + // together: this is the seam where a framing change in `noa-grid` + // would otherwise surface only as an empty record at runtime. + use noa_core::GridSize; + let mut terminal = noa_grid::Terminal::new(GridSize::new(40, 4)); + let mut stream = noa_vt::Stream::new(); + stream.feed(b"error: something broke\r\n", &mut terminal); + let encoded = terminal + .scrollback_snapshot_bytes(1 << 20, 1_700_000_000, None) + .expect("a terminal with output encodes"); + + let dir = temp_dir("seam"); + let key = mint_key(42); + write(&dir, &key, &encoded).expect("write succeeds"); + + let read_back = read(&dir, &key).expect("the file is there"); + let decoded = noa_grid::snapshot::decode(&read_back).expect("it decodes"); + assert_eq!(decoded.saved_at, 1_700_000_000); + let text: String = decoded.rows[0].cells.iter().map(|cell| cell.ch).collect(); + assert!(text.starts_with("error: something broke"), "{text:?}"); + let _ = fs::remove_dir_all(&dir); + } + + fn input_of(text: &str) -> noa_grid::ScrollbackSnapshotInput { + use noa_core::GridSize; + let mut terminal = noa_grid::Terminal::new(GridSize::new(40, 4)); + let mut stream = noa_vt::Stream::new(); + stream.feed(format!("{text}\r\n").as_bytes(), &mut terminal); + terminal + .scrollback_snapshot_input(1 << 20, None) + .expect("a terminal with output has rows") + } + + #[test] + fn dropping_the_persister_flushes_the_newest_state_per_key() { + let dir = temp_dir("persister"); + let key = mint_key(8); + let persister = ScrollbackPersister::spawn(dir.clone()); + for generation in 0..20u8 { + persister.save( + key.clone(), + input_of(&format!("gen{generation}")), + 0, + 1 << 20, + false, + ); + } + drop(persister); + + let bytes = read(&dir, &key).expect("the file was written"); + let decoded = noa_grid::snapshot::decode(&bytes).expect("decodes"); + let text: String = decoded.rows[0].cells.iter().map(|cell| cell.ch).collect(); + assert!(text.starts_with("gen19"), "newest state wins: {text:?}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn encoding_happens_on_the_worker_not_the_caller() { + // `save` takes rows, not bytes: the interning and deflate that used to + // run under the terminal lock now run here. + let dir = temp_dir("worker-encode"); + let key = mint_key(20); + let mut persister = ScrollbackPersister::spawn(dir.clone()); + persister.save( + key.clone(), + input_of("encoded by the worker"), + 4242, + 1 << 20, + false, + ); + persister.flush(); + + let decoded = + noa_grid::snapshot::decode(&read(&dir, &key).expect("written")).expect("decodes"); + assert_eq!(decoded.saved_at, 4242); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn a_queued_discard_wins_over_an_earlier_write_for_the_same_key() { + let dir = temp_dir("discard"); + let key = mint_key(9); + let mut persister = ScrollbackPersister::spawn(dir.clone()); + persister.save(key.clone(), input_of("doomed"), 0, 1 << 20, false); + persister.discard(key.clone()); + persister.flush(); + + assert!(read(&dir, &key).is_none()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/noa-app/src/session.rs b/crates/noa-app/src/session.rs index a0e6424..43e4695 100644 --- a/crates/noa-app/src/session.rs +++ b/crates/noa-app/src/session.rs @@ -11,11 +11,12 @@ use std::fs; use std::path::Path; -/// Current schema version. Version 2 adds per-leaf remote metadata. The -/// reader intentionally accepts v1 as well, but the writer always emits v2 -/// so an older binary rejects a remote-aware session instead of silently -/// respawning its leaves as local shells. -pub const SESSION_VERSION: u32 = 2; +/// Current schema version. Version 2 adds per-leaf remote metadata; version 3 +/// adds the per-leaf persisted-scrollback key. The reader intentionally +/// accepts older versions as well, but the writer always emits the current +/// one so an older binary rejects a session it would misinterpret instead of +/// silently respawning its leaves with the wrong shape. +pub const SESSION_VERSION: u32 = 3; const MIN_SESSION_VERSION: u32 = 1; /// The whole persisted session: every logical window, plus which one had OS @@ -69,6 +70,13 @@ pub enum PaneNode { /// Present only for a remote Client Mode leaf. Credentials and /// transient connection state are deliberately never persisted. remote: Option, + /// Key of this leaf's persisted scrollback snapshot, when + /// `scrollback-persist` captured one. Names a file under + /// `noa-config::scrollback_dir()`; the contents live there rather + /// than inline because this document is rewritten on every + /// structural change, and a megabyte of terminal output is not + /// something to rewrite on each split. + scrollback: Option, }, Split { orientation: Orientation, @@ -112,6 +120,16 @@ impl PaneNode { } } + /// Persisted scrollback key of the left-most leaf, when it has one. That + /// leaf keeps hosting the tab's initial surface on restore, so its record + /// is loaded there rather than through the split-materialization path. + pub fn first_leaf_scrollback(&self) -> Option { + match self { + PaneNode::Leaf { scrollback, .. } => scrollback.clone(), + PaneNode::Split { first, .. } => first.first_leaf_scrollback(), + } + } + /// Number of leaf panes in this subtree. pub fn leaf_count(&self) -> usize { match self { @@ -191,7 +209,11 @@ fn serialize_tab(out: &mut String, tab: &TabSession) { fn serialize_node(out: &mut String, node: &PaneNode) { out.push('{'); match node { - PaneNode::Leaf { cwd, remote } => { + PaneNode::Leaf { + cwd, + remote, + scrollback, + } => { push_key(out, "type"); push_string(out, "leaf"); out.push(','); @@ -206,6 +228,12 @@ fn serialize_node(out: &mut String, node: &PaneNode) { Some(remote) => serialize_remote(out, remote), None => out.push_str("null"), } + out.push(','); + push_key(out, "scrollback"); + match scrollback { + Some(key) => push_string(out, key), + None => out.push_str("null"), + } } PaneNode::Split { orientation, @@ -388,7 +416,19 @@ fn parse_node(value: &json::Value, version: u32) -> Option { Some(remote) => Some(parse_remote(remote)?), } }; - Some(PaneNode::Leaf { cwd, remote }) + let scrollback = if version < 3 { + None + } else { + match object.field("scrollback") { + None | Some(json::Value::Null) => None, + Some(key) => Some(key.as_str()?.to_string()), + } + }; + Some(PaneNode::Leaf { + cwd, + remote, + scrollback, + }) } "split" => { let orientation = match object.field("orientation")?.as_str()? { @@ -716,6 +756,7 @@ mod tests { split: PaneNode::Leaf { cwd: Some("/home/user".to_string()), remote: None, + scrollback: None, }, }], }, @@ -732,6 +773,7 @@ mod tests { first: Box::new(PaneNode::Leaf { cwd: Some("/a".to_string()), remote: None, + scrollback: None, }), second: Box::new(PaneNode::Split { orientation: Orientation::Vertical, @@ -739,10 +781,12 @@ mod tests { first: Box::new(PaneNode::Leaf { cwd: None, remote: None, + scrollback: None, }), second: Box::new(PaneNode::Leaf { cwd: Some("/b/c".to_string()), remote: None, + scrollback: None, }), }), }, @@ -753,6 +797,7 @@ mod tests { split: PaneNode::Leaf { cwd: None, remote: None, + scrollback: None, }, }, ], @@ -781,15 +826,18 @@ mod tests { first: Box::new(PaneNode::Leaf { cwd: Some("/one".to_string()), remote: None, + scrollback: None, }), second: Box::new(PaneNode::Leaf { cwd: Some("/two".to_string()), remote: None, + scrollback: None, }), }), second: Box::new(PaneNode::Leaf { cwd: None, remote: None, + scrollback: None, }), }; assert_eq!(split.leaf_count(), 3); @@ -823,6 +871,7 @@ mod tests { split: PaneNode::Leaf { cwd: Some("/path/with \"quote\"\tand\\slash".to_string()), remote: None, + scrollback: None, }, }], }], @@ -865,17 +914,76 @@ mod tests { pane_id: 42, cached_title: Some("remote shell".to_string()), }), + scrollback: None, }, }], }], }; let text = serialize(&state); - assert!(text.contains("\"version\":2")); + assert!(text.contains("\"version\":3")); assert!(!text.contains("token")); assert_eq!(parse(&text), Some(state)); } + #[test] + fn a_leaf_scrollback_key_roundtrips() { + let state = SessionState { + focused_window: Some(0), + windows: vec![WindowSession { + frame: None, + focused_tab: 0, + tabs: vec![TabSession { + focused_leaf: 0, + title: None, + split: PaneNode::Split { + orientation: Orientation::Horizontal, + ratio: 0.5, + first: Box::new(PaneNode::Leaf { + cwd: Some("/a".to_string()), + remote: None, + scrollback: Some("3f1c8a02b7d94e56".to_string()), + }), + second: Box::new(PaneNode::Leaf { + cwd: None, + remote: None, + scrollback: None, + }), + }, + }], + }], + }; + + let text = serialize(&state); + assert!(text.contains("\"scrollback\":\"3f1c8a02b7d94e56\"")); + assert_eq!(parse(&text), Some(state.clone())); + assert_eq!( + state.windows[0].tabs[0] + .split + .first_leaf_scrollback() + .as_deref(), + Some("3f1c8a02b7d94e56"), + "the first leaf's key is what the restored root pane loads" + ); + } + + #[test] + fn a_v2_session_parses_with_no_record_rather_than_being_rejected() { + // A file written by a build that predates scrollback persistence must + // still restore its topology; it simply has no record to show. + let v2 = r#"{"version":2,"focused_window":0,"windows":[{"frame":null, + "focused_tab":0,"tabs":[{"focused_leaf":0,"title":null, + "split":{"type":"leaf","cwd":"/a","remote":null}}]}]}"#; + let parsed = parse(v2).expect("a v2 session still parses"); + assert!(matches!( + parsed.windows[0].tabs[0].split, + PaneNode::Leaf { + scrollback: None, + .. + } + )); + } + #[test] fn malformed_v2_remote_metadata_never_becomes_a_local_leaf() { for remote in [ diff --git a/crates/noa-app/src/split_tree/tree/commands.rs b/crates/noa-app/src/split_tree/tree/commands.rs index 841bc52..4162930 100644 --- a/crates/noa-app/src/split_tree/tree/commands.rs +++ b/crates/noa-app/src/split_tree/tree/commands.rs @@ -24,6 +24,8 @@ pub fn resolve_pane_command_target( | AppCommand::Paste | AppCommand::SendSelectionToPane | AppCommand::ExportScrollback + | AppCommand::DiscardRestoredHistory + | AppCommand::CheckpointScrollback | AppCommand::PipeScrollbackToPager | AppCommand::Terminal(_) | AppCommand::FontSize(_) diff --git a/crates/noa-app/src/theme_settings/rows.rs b/crates/noa-app/src/theme_settings/rows.rs index a5b24a5..a08fe48 100644 --- a/crates/noa-app/src/theme_settings/rows.rs +++ b/crates/noa-app/src/theme_settings/rows.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use noa_config::{ BackgroundImageFit, BackgroundImagePosition, CursorShape, GlassLevel, MacosOptionAsAlt, - MacosTitlebarStyle, + MacosTitlebarStyle, ScrollbackPersist, }; /// Which half of the (now-split) overlay owns ↑↓/←→ navigation. A session's @@ -162,10 +162,25 @@ pub(crate) enum SettingsRowKind { /// scratch popup toggle reads the live config value fresh, exactly like /// `QuickTerminalHeight`) rather than restart-only. ScratchTerminalSize, + /// `scrollback-persist`: whether each pane's scrollback tail is written + /// to disk on exit and restored on launch (`docs/specs/scrollback-persistence.md`). + /// A plain two-state flip like `ConfirmQuit` (`adjust` toggles it), but + /// reload-exempt like `ScrollbackLimit`/`CursorStyleBlink` rather than + /// live: the value is read fresh only when a pane is *created* + /// (`App::scrollback_persist_enabled`), so an edit here has no running + /// pane to apply to — it takes effect for panes opened after the + /// commit, picked up by `ConfigWatcher`'s normal reload like the rest of + /// that group, no special `commit_theme_settings` mirroring needed. + /// The initial draft is seeded from the live config + /// (`ThemeSettingsInit::scrollback_persist`), not from the default. A + /// row that reports `Off` while output is in fact being written to disk + /// would be worse than no row at all — this one exists so a user can see + /// whether their terminal is recording. + ScrollbackPersist, } impl SettingsRowKind { - pub(crate) const COUNT: usize = 33; + pub(crate) const COUNT: usize = 34; pub(crate) const ALL: [SettingsRowKind; Self::COUNT] = [ Self::FontSize, Self::BackgroundOpacity, @@ -200,6 +215,7 @@ impl SettingsRowKind { Self::ServerTokenCopy, Self::ScratchTerminalKey, Self::ScratchTerminalSize, + Self::ScrollbackPersist, ]; /// R-8: the fixed live/commit-only classification, one row's kind at a @@ -264,6 +280,7 @@ impl SettingsRowKind { Self::ServerTokenCopy => "Server Token", Self::ScratchTerminalKey => "Scratch Terminal Key", Self::ScratchTerminalSize => "Scratch Terminal Size", + Self::ScrollbackPersist => "Persist Scrollback", } } @@ -340,6 +357,9 @@ impl SettingsRowKind { Self::ScratchTerminalSize => { "Scratch terminal popup grid size, in columns x rows. Applies on save." } + Self::ScrollbackPersist => { + "Keep each pane's scrollback tail on disk across restarts — writes terminal output to disk, so it's opt-in. Applies on save." + } } } } @@ -474,6 +494,8 @@ pub(crate) enum RowDraft { ScratchTerminalKey(String), /// `(cols, rows)` for [`SettingsRowKind::ScratchTerminalSize`]. ScratchTerminalSize(u16, u16), + /// `scrollback-persist` for [`SettingsRowKind::ScrollbackPersist`]. + ScrollbackPersist(ScrollbackPersist), } /// [`RowDraft::ServerTokenCopy`]'s three faces — deliberately holds no @@ -583,6 +605,10 @@ impl RowDraft { } } RowDraft::ScratchTerminalSize(cols, rows) => format!("{cols}x{rows}"), + RowDraft::ScrollbackPersist(mode) => match mode { + ScrollbackPersist::Never => "Off".to_string(), + ScrollbackPersist::Tail => "Record Tail".to_string(), + }, } } @@ -680,6 +706,7 @@ impl RowDraft { d.scratch_terminal_size.cols, d.scratch_terminal_size.rows, ), + SettingsRowKind::ScrollbackPersist => RowDraft::ScrollbackPersist(d.scrollback_persist), } } } @@ -928,6 +955,8 @@ pub(crate) struct ThemeSettingsInit { /// `display_value` already treats an empty draft as "None"). pub(crate) scratch_terminal_key: String, pub(crate) scratch_terminal_size: (u16, u16), + /// `scrollback-persist`: whether panes save and restore their scrollback. + pub(crate) scrollback_persist: noa_config::ScrollbackPersist, } /// [`SettingsRowKind::ServerStatus`]'s display text (E) for a given diff --git a/crates/noa-app/src/theme_settings/state.rs b/crates/noa-app/src/theme_settings/state.rs index e769661..34589db 100644 --- a/crates/noa-app/src/theme_settings/state.rs +++ b/crates/noa-app/src/theme_settings/state.rs @@ -7,7 +7,7 @@ use std::time::Instant; use noa_config::{ BackgroundImageFit, BackgroundImagePosition, CursorShape, GlassLevel, MacosOptionAsAlt, - MacosTitlebarStyle, + MacosTitlebarStyle, ScrollbackPersist, }; use crate::command_palette::fuzzy_match; @@ -464,6 +464,10 @@ impl ThemeSettings { ), touched: false, }, + SettingsRow { + draft: RowDraft::ScrollbackPersist(init.scrollback_persist), + touched: false, + }, ], !init.window_created_transparent, // A fresh session has nothing to restore: `glassmorphism` @@ -1604,6 +1608,20 @@ impl ThemeSettings { } RowEffect::None } + // Two-state flip, same shape as `ConfirmQuit` — no runtime apply + // path from `adjust` itself (see this kind's doc comment). + SettingsRowKind::ScrollbackPersist => { + let RowDraft::ScrollbackPersist(current) = self.rows[idx].draft else { + return RowEffect::None; + }; + let new = match current { + ScrollbackPersist::Never => ScrollbackPersist::Tail, + ScrollbackPersist::Tail => ScrollbackPersist::Never, + }; + self.rows[idx].draft = RowDraft::ScrollbackPersist(new); + self.rows[idx].touched = true; + RowEffect::None + } } } @@ -2270,6 +2288,12 @@ impl ThemeSettings { format!("{cols}x{rows}"), )); } + RowDraft::ScrollbackPersist(mode) => { + updates.push(( + "scrollback-persist".to_string(), + scrollback_persist_config_value(*mode).to_string(), + )); + } } } updates @@ -2574,6 +2598,12 @@ fn is_reload_exempt(row: SettingsRowKind) -> bool { // "picked up without a restart" shape as the group above. | SettingsRowKind::ScratchTerminalKey | SettingsRowKind::ScratchTerminalSize + // `scrollback-persist`: read fresh only when a pane is created, + // so like the group above there is no running state for this + // row to apply to directly — `ConfigWatcher`'s normal reload + // picks up the written value for the next pane, same shape as + // `ScrollbackLimit`/`CursorStyleBlink`/`MinimumContrast`. + | SettingsRowKind::ScrollbackPersist ) } @@ -2612,6 +2642,15 @@ fn macos_option_as_alt_config_value(mode: MacosOptionAsAlt) -> &'static str { } } +/// `scrollback-persist` config value for `mode` (inverse of +/// `parse_scrollback_persist`). +fn scrollback_persist_config_value(mode: ScrollbackPersist) -> &'static str { + match mode { + ScrollbackPersist::Never => "never", + ScrollbackPersist::Tail => "tail", + } +} + /// [`ThemeSettings::view_fingerprint`]'s per-`RowDraft` half — the /// discriminant itself is hashed by the caller (once, per row), so this /// only needs each variant's inner value. `f32` fields go through @@ -2664,6 +2703,7 @@ fn hash_row_draft_value(draft: &RowDraft, hasher: &mut impl Hasher) { cols.hash(hasher); rows.hash(hasher); } + RowDraft::ScrollbackPersist(mode) => scrollback_persist_config_value(*mode).hash(hasher), } } diff --git a/crates/noa-app/src/theme_settings/tests.rs b/crates/noa-app/src/theme_settings/tests.rs index 7386fc0..3eea469 100644 --- a/crates/noa-app/src/theme_settings/tests.rs +++ b/crates/noa-app/src/theme_settings/tests.rs @@ -60,6 +60,7 @@ fn init() -> ThemeSettingsInit { server_status: "Stopped".to_string(), scratch_terminal_key: "cmd+shift+t".to_string(), scratch_terminal_size: (100, 25), + scrollback_persist: noa_config::ScrollbackPersist::default(), theme_pair: None, carryover: None, favorites: std::sync::Arc::new(std::collections::HashSet::new()), @@ -1097,6 +1098,73 @@ fn confirm_quit_row_toggles_and_commits_without_restart_note() { ); } +// The row toggles like `ConfirmQuit` (a plain two-state flip), but commits +// `scrollback-persist`'s `never`/`tail` config-file strings rather than +// `true`/`false` — asserts both directions round-trip through +// `commit_updates` correctly. +#[test] +fn the_scrollback_persist_row_shows_what_the_config_actually_says() { + // A privacy toggle that reports Off while output is being written to disk + // is worse than no toggle: seed the row from the live config, never from + // the type default. + let mut init = settings_init(); + init.scrollback_persist = noa_config::ScrollbackPersist::Tail; + let settings = ThemeSettings::open(init); + assert_eq!( + settings.rows()[row_index(SettingsRowKind::ScrollbackPersist)].draft, + RowDraft::ScrollbackPersist(noa_config::ScrollbackPersist::Tail), + "an already-recording config must not display as Off" + ); + assert!( + settings + .commit_updates() + .iter() + .all(|(key, _)| key != "scrollback-persist"), + "an untouched row must not rewrite the key" + ); +} + +#[test] +fn scrollback_persist_row_toggles_and_commits_never_or_tail() { + let mut settings = ThemeSettings::open(settings_init()); + move_to_row(&mut settings, SettingsRowKind::ScrollbackPersist); + assert_eq!( + settings.rows()[row_index(SettingsRowKind::ScrollbackPersist)].draft, + RowDraft::ScrollbackPersist(noa_config::ScrollbackPersist::Never) + ); + + let effect = settings.adjust(1, Instant::now()); + assert_eq!(effect, RowEffect::None); + assert_eq!( + settings.rows()[row_index(SettingsRowKind::ScrollbackPersist)].draft, + RowDraft::ScrollbackPersist(noa_config::ScrollbackPersist::Tail) + ); + assert!(!settings.restart_note(SettingsRowKind::ScrollbackPersist)); + assert_eq!( + settings.liveness(SettingsRowKind::ScrollbackPersist), + Liveness::OnSave + ); + + let updates = settings.commit_updates(); + assert_eq!( + updates.iter().find(|(k, _)| k == "scrollback-persist"), + Some(&("scrollback-persist".to_string(), "tail".to_string())) + ); + + // Flips back to `never` on a second toggle. + let effect = settings.adjust(1, Instant::now()); + assert_eq!(effect, RowEffect::None); + assert_eq!( + settings.rows()[row_index(SettingsRowKind::ScrollbackPersist)].draft, + RowDraft::ScrollbackPersist(noa_config::ScrollbackPersist::Never) + ); + let updates = settings.commit_updates(); + assert_eq!( + updates.iter().find(|(k, _)| k == "scrollback-persist"), + Some(&("scrollback-persist".to_string(), "never".to_string())) + ); +} + #[test] fn send_selection_send_enter_row_toggles_and_commits_without_restart_note() { let mut settings = ThemeSettings::open(settings_init()); @@ -2477,6 +2545,10 @@ fn default_for_maps_every_row_kind_to_its_documented_startup_default() { RowDraft::default_for(SettingsRowKind::ServerStatus), RowDraft::ServerStatus("Stopped".to_string()) ); + assert_eq!( + RowDraft::default_for(SettingsRowKind::ScrollbackPersist), + RowDraft::ScrollbackPersist(noa_config::ScrollbackPersist::Never) + ); } // R-9: `SettingsRowKind::COUNT` is type-enforced at 20 (16 + the 4 new @@ -2488,12 +2560,13 @@ fn default_for_maps_every_row_kind_to_its_documented_startup_default() { // address row (server-bind) brings it to 26 (+1), the sidebar-width row // brings it to 27 (+1), the sidebar-font-size row brings it to 28 (+1), // the send-selection-send-enter row brings it to 29 (+1), the Remote -// App QR action brings it to 30 (+1), and the `glassmorphism` row brings -// the array to its current length (+1 on top of the scratch-terminal rows). +// App QR action brings it to 30 (+1), the `glassmorphism` row brings it to +// 33 (+1 on top of the scratch-terminal rows), and the `scrollback-persist` +// row brings the array to its current length (+1). #[test] fn settings_row_kind_count_includes_remote_app_qr_action() { - assert_eq!(SettingsRowKind::COUNT, 33); - assert_eq!(SettingsRowKind::ALL.len(), 33); + assert_eq!(SettingsRowKind::COUNT, 34); + assert_eq!(SettingsRowKind::ALL.len(), 34); } // settings-panel-server-status: the status row is read-only (mirrors diff --git a/crates/noa-config/src/lib.rs b/crates/noa-config/src/lib.rs index 5d101c3..b891b9a 100644 --- a/crates/noa-config/src/lib.rs +++ b/crates/noa-config/src/lib.rs @@ -27,6 +27,15 @@ pub const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000_000; /// `image-storage-limit` default: 320 MB of decoded image data, matching /// Ghostty/Kitty's per-terminal graphics storage budget. pub const DEFAULT_IMAGE_STORAGE_LIMIT: usize = 320_000_000; +/// `scrollback-persist-limit` default: 1 MiB of *encoded* scrollback per pane. +/// The budget is measured before deflate, so the file on disk is smaller. +pub const DEFAULT_SCROLLBACK_PERSIST_LIMIT: usize = 1 << 20; +/// `scrollback-persist-total-limit` default: 64 MiB of persisted scrollback +/// across every pane, enforced against actual on-disk file sizes. +pub const DEFAULT_SCROLLBACK_PERSIST_TOTAL_LIMIT: usize = 64 << 20; +/// `scrollback-persist-max-age-days` default: persisted scrollback older than +/// a week is dropped at launch. `0` disables expiry. +pub const DEFAULT_SCROLLBACK_PERSIST_MAX_AGE_DAYS: u64 = 7; /// `minimum-contrast` default: 1.0 means no automatic adjustment, matching /// Ghostty's contrast-ratio scale where 1 permits identical colors. pub const DEFAULT_MINIMUM_CONTRAST: f32 = 1.0; @@ -339,6 +348,28 @@ impl WindowSaveState { } } +/// `scrollback-persist`: whether each pane's scrollback tail is written to +/// disk on exit and restored on launch. noa-specific key (no Ghostty analog — +/// Ghostty restores topology only, which is why the default is `never`: +/// persisting terminal output changes the threat model, so it is opt-in). +/// See `docs/specs/scrollback-persistence.md`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ScrollbackPersist { + /// Never write or read persisted scrollback (Ghostty-parity behavior). + #[default] + Never, + /// Persist the tail of each pane's scrollback, capped by + /// `scrollback-persist-limit`. + Tail, +} + +impl ScrollbackPersist { + /// Whether scrollback should be captured on exit and restored on launch. + pub fn persists(self) -> bool { + matches!(self, ScrollbackPersist::Tail) + } +} + /// One `keybind = ...` directive from config. The config crate stores chord /// and action text verbatim; `noa-app` owns chord parsing and action lookup. #[derive(Debug, Clone, PartialEq, Eq)] @@ -709,6 +740,26 @@ pub struct StartupConfig { /// `window-save-state`: whether the window/tab/split session is persisted /// and restored across launches. Default restores. pub window_save_state: WindowSaveState, + /// `scrollback-persist`: whether each pane's scrollback tail is persisted + /// alongside the session topology. noa-specific key (no Ghostty analog); + /// defaults to `never` so noa's observable behavior matches Ghostty until + /// the user opts in. + pub scrollback_persist: ScrollbackPersist, + /// `scrollback-persist-limit`: per-pane cap on persisted scrollback, in + /// bytes of *encoded* payload (measured before deflate). noa-specific key. + pub scrollback_persist_limit: usize, + /// `scrollback-persist-total-limit`: cap on the total on-disk size of all + /// persisted scrollback; the oldest panes are dropped first at launch. + /// noa-specific key. + pub scrollback_persist_total_limit: usize, + /// `scrollback-persist-max-age-days`: persisted scrollback older than this + /// is discarded at launch (`0` never expires). noa-specific key. + pub scrollback_persist_max_age_days: u64, + /// `scrollback-persist-encrypt`: encrypt snapshots with a key held in the + /// login keychain. noa-specific key; off by default because it makes the + /// records unreadable once the keychain entry is gone, which is a real cost + /// to accept deliberately rather than by default. + pub scrollback_persist_encrypt: bool, /// `macos-option-as-alt`: which Option key(s) should be rewritten as /// terminal Alt by the macOS window layer. Default preserves existing /// platform text behavior. @@ -886,6 +937,11 @@ impl Default for StartupConfig { scrollback_limit: DEFAULT_SCROLLBACK_LIMIT, image_storage_limit: DEFAULT_IMAGE_STORAGE_LIMIT, window_save_state: WindowSaveState::default(), + scrollback_persist: ScrollbackPersist::default(), + scrollback_persist_limit: DEFAULT_SCROLLBACK_PERSIST_LIMIT, + scrollback_persist_total_limit: DEFAULT_SCROLLBACK_PERSIST_TOTAL_LIMIT, + scrollback_persist_max_age_days: DEFAULT_SCROLLBACK_PERSIST_MAX_AGE_DAYS, + scrollback_persist_encrypt: false, macos_option_as_alt: MacosOptionAsAlt::default(), macos_titlebar_style: MacosTitlebarStyle::default(), macos_non_native_fullscreen: false, @@ -961,6 +1017,11 @@ pub struct ConfigOverrides { pub scrollback_limit: Option, pub image_storage_limit: Option, pub window_save_state: Option, + pub scrollback_persist: Option, + pub scrollback_persist_limit: Option, + pub scrollback_persist_total_limit: Option, + pub scrollback_persist_max_age_days: Option, + pub scrollback_persist_encrypt: Option, pub macos_option_as_alt: Option, pub macos_titlebar_style: Option, pub macos_non_native_fullscreen: Option, @@ -1047,6 +1108,20 @@ macro_rules! impl_redacted_config_debug { .field("scrollback_limit", &self.scrollback_limit) .field("image_storage_limit", &self.image_storage_limit) .field("window_save_state", &self.window_save_state) + .field("scrollback_persist", &self.scrollback_persist) + .field("scrollback_persist_limit", &self.scrollback_persist_limit) + .field( + "scrollback_persist_total_limit", + &self.scrollback_persist_total_limit, + ) + .field( + "scrollback_persist_max_age_days", + &self.scrollback_persist_max_age_days, + ) + .field( + "scrollback_persist_encrypt", + &self.scrollback_persist_encrypt, + ) .field("macos_option_as_alt", &self.macos_option_as_alt) .field("macos_titlebar_style", &self.macos_titlebar_style) .field( @@ -1164,6 +1239,21 @@ impl ConfigOverrides { .image_storage_limit .or(self.image_storage_limit), window_save_state: higher_priority.window_save_state.or(self.window_save_state), + scrollback_persist: higher_priority + .scrollback_persist + .or(self.scrollback_persist), + scrollback_persist_limit: higher_priority + .scrollback_persist_limit + .or(self.scrollback_persist_limit), + scrollback_persist_total_limit: higher_priority + .scrollback_persist_total_limit + .or(self.scrollback_persist_total_limit), + scrollback_persist_max_age_days: higher_priority + .scrollback_persist_max_age_days + .or(self.scrollback_persist_max_age_days), + scrollback_persist_encrypt: higher_priority + .scrollback_persist_encrypt + .or(self.scrollback_persist_encrypt), macos_option_as_alt: higher_priority .macos_option_as_alt .or(self.macos_option_as_alt), @@ -1296,6 +1386,19 @@ impl ConfigOverrides { scrollback_limit: self.scrollback_limit.unwrap_or(base.scrollback_limit), image_storage_limit: self.image_storage_limit.unwrap_or(base.image_storage_limit), window_save_state: self.window_save_state.unwrap_or(base.window_save_state), + scrollback_persist: self.scrollback_persist.unwrap_or(base.scrollback_persist), + scrollback_persist_limit: self + .scrollback_persist_limit + .unwrap_or(base.scrollback_persist_limit), + scrollback_persist_total_limit: self + .scrollback_persist_total_limit + .unwrap_or(base.scrollback_persist_total_limit), + scrollback_persist_max_age_days: self + .scrollback_persist_max_age_days + .unwrap_or(base.scrollback_persist_max_age_days), + scrollback_persist_encrypt: self + .scrollback_persist_encrypt + .unwrap_or(base.scrollback_persist_encrypt), macos_option_as_alt: self.macos_option_as_alt.unwrap_or(base.macos_option_as_alt), macos_titlebar_style: self .macos_titlebar_style @@ -1606,6 +1709,18 @@ pub fn session_state_path_in(data_dir: &Path) -> PathBuf { data_dir.join("noa").join("session.json") } +/// Directory holding per-pane persisted scrollback snapshots +/// (`/noa/scrollback/`). Only created when `scrollback-persist` is +/// not `never` — the default leaves no trace of terminal output on disk. +/// See `docs/specs/scrollback-persistence.md`. +pub fn scrollback_dir() -> Option { + dirs::data_dir().map(|path| scrollback_dir_in(&path)) +} + +pub fn scrollback_dir_in(data_dir: &Path) -> PathBuf { + data_dir.join("noa").join("scrollback") +} + pub fn load_overrides_from_path(path: &Path) -> anyhow::Result<(ConfigOverrides, Vec)> { let source = fs::read_to_string(path) .with_context(|| format!("failed to read config file {}", path.display()))?; @@ -1707,6 +1822,11 @@ mod tests { scrollback_limit: DEFAULT_SCROLLBACK_LIMIT, image_storage_limit: DEFAULT_IMAGE_STORAGE_LIMIT, window_save_state: WindowSaveState::default(), + scrollback_persist: ScrollbackPersist::Never, + scrollback_persist_limit: DEFAULT_SCROLLBACK_PERSIST_LIMIT, + scrollback_persist_total_limit: DEFAULT_SCROLLBACK_PERSIST_TOTAL_LIMIT, + scrollback_persist_max_age_days: DEFAULT_SCROLLBACK_PERSIST_MAX_AGE_DAYS, + scrollback_persist_encrypt: false, macos_option_as_alt: MacosOptionAsAlt::default(), macos_titlebar_style: MacosTitlebarStyle::default(), macos_non_native_fullscreen: false, @@ -2030,6 +2150,74 @@ font-size = 15.5 assert!(!WindowSaveState::Never.restores()); } + #[test] + fn scrollback_persist_keys_flow_through_parse_apply_and_precedence() { + let (overrides, diagnostics) = parse_overrides( + test_path(), + "scrollback-persist = tail\n\ + scrollback-persist-limit = 4096\n\ + scrollback-persist-total-limit = 8192\n\ + scrollback-persist-max-age-days = 30", + ); + assert!(diagnostics.is_empty()); + assert_eq!(overrides.scrollback_persist, Some(ScrollbackPersist::Tail)); + assert_eq!(overrides.scrollback_persist_limit, Some(4096)); + assert_eq!(overrides.scrollback_persist_total_limit, Some(8192)); + assert_eq!(overrides.scrollback_persist_max_age_days, Some(30)); + + // Absent keys keep the opt-out default: noa persists nothing until asked. + let default = ConfigOverrides::default().apply_to(StartupConfig::default()); + assert_eq!(default.scrollback_persist, ScrollbackPersist::Never); + assert!(!default.scrollback_persist.persists()); + assert!(ScrollbackPersist::Tail.persists()); + assert_eq!( + default.scrollback_persist_limit, + DEFAULT_SCROLLBACK_PERSIST_LIMIT + ); + assert_eq!( + default.scrollback_persist_total_limit, + DEFAULT_SCROLLBACK_PERSIST_TOTAL_LIMIT + ); + assert_eq!( + default.scrollback_persist_max_age_days, + DEFAULT_SCROLLBACK_PERSIST_MAX_AGE_DAYS + ); + + // CLI wins over the file. + let file = ConfigOverrides { + scrollback_persist: Some(ScrollbackPersist::Tail), + scrollback_persist_limit: Some(1), + ..Default::default() + }; + let cli = ConfigOverrides { + scrollback_persist: Some(ScrollbackPersist::Never), + ..Default::default() + }; + let resolved = file.merge(cli).apply_to(StartupConfig::default()); + assert_eq!(resolved.scrollback_persist, ScrollbackPersist::Never); + assert_eq!(resolved.scrollback_persist_limit, 1); + } + + #[test] + fn scrollback_persist_rejects_an_unknown_mode() { + let (overrides, diagnostics) = + parse_overrides(test_path(), "scrollback-persist = everything"); + assert_eq!(overrides.scrollback_persist, None); + assert_eq!(diagnostics.len(), 1); + } + + #[test] + fn scrollback_dir_sits_beside_the_session_state_file() { + let data_dir = Path::new("/tmp/data"); + assert_eq!( + scrollback_dir_in(data_dir), + session_state_path_in(data_dir) + .parent() + .expect("session state lives in a directory") + .join("scrollback") + ); + } + #[test] fn macos_native_keys_flow_through_parse_apply_and_precedence() { let (overrides, diagnostics) = parse_overrides( diff --git a/crates/noa-config/src/parser/overrides.rs b/crates/noa-config/src/parser/overrides.rs index 199abed..b7024b4 100644 --- a/crates/noa-config/src/parser/overrides.rs +++ b/crates/noa-config/src/parser/overrides.rs @@ -53,6 +53,11 @@ pub(crate) fn build_overrides( let mut scrollback_limit = None; let mut image_storage_limit = None; let mut window_save_state = None; + let mut scrollback_persist = None; + let mut scrollback_persist_limit = None; + let mut scrollback_persist_total_limit = None; + let mut scrollback_persist_max_age_days = None; + let mut scrollback_persist_encrypt = None; let mut macos_option_as_alt = None; let mut macos_titlebar_style = None; let mut macos_non_native_fullscreen = None; @@ -251,6 +256,22 @@ pub(crate) fn build_overrides( "window-save-state" => { window_save_state = parse_window_save_state(path, directive, &mut diagnostics); } + "scrollback-persist" => { + scrollback_persist = parse_scrollback_persist(path, directive, &mut diagnostics); + } + "scrollback-persist-limit" => { + scrollback_persist_limit = parse_usize(path, directive, &mut diagnostics); + } + "scrollback-persist-total-limit" => { + scrollback_persist_total_limit = parse_usize(path, directive, &mut diagnostics); + } + "scrollback-persist-max-age-days" => { + scrollback_persist_max_age_days = parse_u64(path, directive, &mut diagnostics); + } + "scrollback-persist-encrypt" => { + scrollback_persist_encrypt = + parse_bool_directive(path, directive, &mut diagnostics); + } "macos-option-as-alt" => { macos_option_as_alt = parse_macos_option_as_alt(path, directive, &mut diagnostics); } @@ -446,6 +467,11 @@ pub(crate) fn build_overrides( scrollback_limit, image_storage_limit, window_save_state, + scrollback_persist, + scrollback_persist_limit, + scrollback_persist_total_limit, + scrollback_persist_max_age_days, + scrollback_persist_encrypt, macos_option_as_alt, macos_titlebar_style, macos_non_native_fullscreen, @@ -554,6 +580,11 @@ pub(crate) fn is_supported_scalar_key(key: &str) -> bool { | "scrollback-limit" | "image-storage-limit" | "window-save-state" + | "scrollback-persist" + | "scrollback-persist-limit" + | "scrollback-persist-total-limit" + | "scrollback-persist-max-age-days" + | "scrollback-persist-encrypt" | "macos-option-as-alt" | "macos-titlebar-style" | "macos-non-native-fullscreen" diff --git a/crates/noa-config/src/parser/tests.rs b/crates/noa-config/src/parser/tests.rs index 7e1541a..09bfb16 100644 --- a/crates/noa-config/src/parser/tests.rs +++ b/crates/noa-config/src/parser/tests.rs @@ -8,7 +8,7 @@ use crate::{ ConfigOverrides, CursorShape, FontConfig, FontFeature, FontVariation, GlassLevel, KeybindConfig, MacosOptionAsAlt, MacosTitlebarStyle, PaletteOverride, QuickTerminalPosition, QuickTerminalScreen, QuickTerminalSize, QuickTerminalSizeDim, ScratchTerminalSize, - SyntheticStyleMode, ThemeAppearancePair, WindowSaveState, + ScrollbackPersist, SyntheticStyleMode, ThemeAppearancePair, WindowSaveState, }; use super::*; @@ -998,6 +998,52 @@ fn window_save_state_is_a_supported_scalar_key_for_import() { assert!(is_supported_scalar_key("window-save-state")); } +#[test] +fn scrollback_persist_parses_each_mode() { + for (value, expected) in [ + ("never", ScrollbackPersist::Never), + ("tail", ScrollbackPersist::Tail), + ] { + let (overrides, diagnostics) = + parse_overrides(path(), &format!("scrollback-persist = {value}")); + assert_eq!(overrides.scrollback_persist, Some(expected), "{value:?}"); + assert!(diagnostics.is_empty(), "{value:?}: {diagnostics:?}"); + } +} + +#[test] +fn scrollback_persist_max_age_days_parses_zero_as_no_expiry() { + let (overrides, diagnostics) = parse_overrides(path(), "scrollback-persist-max-age-days = 0"); + assert_eq!(overrides.scrollback_persist_max_age_days, Some(0)); + assert!(diagnostics.is_empty()); +} + +#[test] +fn scrollback_persist_max_age_days_rejects_a_unit_suffix() { + // noa's config has no unit-suffix syntax anywhere; `7d` must diagnose + // rather than silently parse as 7. + let (overrides, diagnostics) = parse_overrides(path(), "scrollback-persist-max-age-days = 7d"); + assert_eq!(overrides.scrollback_persist_max_age_days, None); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .message + .contains("scrollback-persist-max-age-days") + ); +} + +#[test] +fn scrollback_persist_keys_are_supported_scalar_keys_for_import() { + for key in [ + "scrollback-persist", + "scrollback-persist-limit", + "scrollback-persist-total-limit", + "scrollback-persist-max-age-days", + ] { + assert!(is_supported_scalar_key(key), "{key}"); + } +} + #[test] fn macos_option_as_alt_parses_modes() { for (value, expected) in [ diff --git a/crates/noa-config/src/parser/values.rs b/crates/noa-config/src/parser/values.rs index c10e2de..738eaae 100644 --- a/crates/noa-config/src/parser/values.rs +++ b/crates/noa-config/src/parser/values.rs @@ -8,8 +8,8 @@ use crate::{ MAX_SIDEBAR_WIDTH, MIN_BACKGROUND_IMAGE_INTERVAL_SECS, MIN_SIDEBAR_FONT_SIZE, MIN_SIDEBAR_WIDTH, MacosOptionAsAlt, MacosTitlebarProxyIcon, MacosTitlebarStyle, PaletteOverride, QuickTerminalPosition, QuickTerminalScreen, QuickTerminalSize, - QuickTerminalSizeDim, ResizeOverlay, ScratchTerminalSize, SyntheticStyleMode, - ThemeAppearancePair, WindowSaveState, + QuickTerminalSizeDim, ResizeOverlay, ScratchTerminalSize, ScrollbackPersist, + SyntheticStyleMode, ThemeAppearancePair, WindowSaveState, }; use super::diagnostics::*; @@ -763,6 +763,41 @@ pub(super) fn parse_window_save_state( } } +pub(super) fn parse_scrollback_persist( + path: &Path, + directive: &Directive, + diagnostics: &mut Vec, +) -> Option { + let value = directive.value.as_deref()?; + match value { + "never" => Some(ScrollbackPersist::Never), + "tail" => Some(ScrollbackPersist::Tail), + other => { + diagnostics.push(invalid_value_diagnostic(path, &directive.key, other)); + None + } + } +} + +/// Parse a non-negative day count (`scrollback-persist-max-age-days`). `0` is +/// valid and disables expiry. Matches the crate's plain-integer convention for +/// time-valued keys (`cursor-stop-blinking-after`, `background-image-interval`) +/// — there is no unit-suffix syntax anywhere in noa's config. +pub(super) fn parse_u64( + path: &Path, + directive: &Directive, + diagnostics: &mut Vec, +) -> Option { + let value = directive.value.as_deref()?; + match value.parse::() { + Ok(parsed) => Some(parsed), + Err(_) => { + diagnostics.push(invalid_value_diagnostic(path, &directive.key, value)); + None + } + } +} + pub(super) fn parse_resize_overlay( path: &Path, directive: &Directive, diff --git a/crates/noa-grid/examples/dump-snapshot.rs b/crates/noa-grid/examples/dump-snapshot.rs new file mode 100644 index 0000000..70d1d10 --- /dev/null +++ b/crates/noa-grid/examples/dump-snapshot.rs @@ -0,0 +1,88 @@ +//! Decode a persisted scrollback snapshot and print what it would restore. +//! +//! `scrollback-persist` writes an opaque binary blob, so when a restored pane +//! looks wrong there is otherwise no way to tell whether the capture, the +//! file, or the restore is at fault. This reads a `.nsb` and reports the +//! header plus each row's text and pen, which answers that in one step. +//! +//! ```sh +//! cargo run -p noa-grid --example dump-snapshot -- \ +//! ~/Library/Application\ Support/noa/scrollback/.nsb +//! ``` + +use noa_core::{CellAttrs, Color}; + +fn describe(color: Color) -> String { + match color { + Color::Default => "default".to_string(), + Color::Palette(index) => format!("palette({index})"), + Color::Rgb(rgb) => format!("rgb({},{},{})", rgb.r, rgb.g, rgb.b), + } +} + +fn main() { + let Some(path) = std::env::args().nth(1) else { + eprintln!("usage: dump-snapshot "); + std::process::exit(2); + }; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("{path}: {err}"); + std::process::exit(1); + } + }; + let Some(snapshot) = noa_grid::snapshot::decode(&bytes) else { + eprintln!("{path}: not a readable snapshot ({} bytes)", bytes.len()); + std::process::exit(1); + }; + + println!( + "file {} bytes\ncols {}\nsaved_at {}\nrows {}\nlinks {}", + bytes.len(), + snapshot.cols, + snapshot.saved_at, + snapshot.rows.len(), + snapshot.hyperlinks.len() + ); + for link in &snapshot.hyperlinks { + println!(" {}", link.uri); + } + println!("--- rows ---"); + for (index, row) in snapshot.rows.iter().enumerate() { + let text: String = row.cells.iter().map(|cell| cell.ch).collect(); + let wrap = if row.wrapped { " ↩" } else { "" }; + println!("{index:>4} |{}|{wrap}", text.trim_end()); + + // Summarize the pens actually used on the row, so a colour or + // attribute lost in the round-trip is visible without a terminal. + let mut pens: Vec = Vec::new(); + for cell in row.cells.iter().filter(|cell| cell.ch != ' ') { + let mut pen = describe(cell.fg); + if cell.bg != Color::Default { + pen.push_str(&format!(" on {}", describe(cell.bg))); + } + for (flag, name) in [ + (CellAttrs::BOLD, "bold"), + (CellAttrs::FAINT, "faint"), + (CellAttrs::ITALIC, "italic"), + (CellAttrs::UNDERLINE, "underline"), + (CellAttrs::WIDE, "wide"), + ] { + if cell.attrs.contains(flag) { + pen.push('+'); + pen.push_str(name); + } + } + if cell.hyperlink.is_some() { + pen.push_str("+link"); + } + if !pens.contains(&pen) { + pens.push(pen); + } + } + if !pens.is_empty() { + println!(" └─ {}", pens.join(", ")); + } + } +} diff --git a/crates/noa-grid/src/lib.rs b/crates/noa-grid/src/lib.rs index 9dd7f75..dd7882b 100644 --- a/crates/noa-grid/src/lib.rs +++ b/crates/noa-grid/src/lib.rs @@ -22,6 +22,7 @@ mod scrollback; pub mod search; pub mod selection; mod sixel; +pub mod snapshot; pub mod tabstops; pub mod terminal; pub mod url; @@ -48,6 +49,7 @@ pub use path::{PathMatch, detect_path_at_column}; pub use screen::{KittyPlacement, Screen, VisibleKittyPlacement}; pub use search::{SearchAnchor, SearchMatch, SearchState}; pub use selection::{Selection, SelectionPoint}; +pub use snapshot::{ScrollbackSnapshot, ScrollbackSnapshotInput}; pub use tabstops::Tabstops; pub use terminal::{PromptJump, ShellIntegrationMark, ShellIntegrationMarkKind, Terminal}; pub use url::{UrlMatch, detect_url_at_column}; diff --git a/crates/noa-grid/src/screen/text.rs b/crates/noa-grid/src/screen/text.rs index c37d40c..ed63ff7 100644 --- a/crates/noa-grid/src/screen/text.rs +++ b/crates/noa-grid/src/screen/text.rs @@ -52,6 +52,68 @@ impl Screen { inserted } + /// Insert already-built `rows` as the oldest history, ahead of everything + /// currently retained. The styled counterpart of + /// [`Self::prepend_plain_text_history`]: restored scrollback + /// (`scrollback-persist`) carries real colors, attributes, hyperlinks and + /// combining tails, so it cannot go through the plain-text path, which + /// re-derives every cell at the default pen. + /// + /// Rows are rewrapped to this screen's width first, so a snapshot taken in + /// a narrower window does not restore with its old soft-wraps frozen in. + /// Returns the number of rows actually inserted. + pub(crate) fn prepend_row_history(&mut self, rows: Vec) -> usize { + if rows.is_empty() || !self.scrollback_enabled || self.cols == 0 { + return 0; + } + let rows = crate::snapshot::rewrap(rows, self.cols); + let inserted = rows.len(); + if inserted == 0 { + return 0; + } + + let evicted = self.scrollback.prepend_rows(&rows); + self.rows_evicted = self.rows_evicted.saturating_add(evicted); + for placement in &mut self.kitty_placements { + placement.anchor_abs_row = placement.anchor_abs_row.saturating_add(inserted); + } + self.selection = None; + self.search.clear(); + self.scroll_shift = 0; + self.clamp_viewport(); + inserted.saturating_sub(evicted) + } + + /// Drop the oldest `count` scrollback rows, keeping everything after them. + /// + /// Used to discard a restored record without touching the live session + /// that has accumulated since (`erase_display(Scrollback)` would take + /// both). Scrollback pages are immutable once packed and evict whole, so + /// this rebuilds the survivors rather than cutting into a page — an O(n) + /// walk of retained history, which is fine for a rare user-initiated + /// command and keeps the bulk-output path untouched. + /// + /// Session-absolute row numbers of the survivors are preserved, so + /// anything anchored to them (kitty placements, shell marks) stays put. + /// Returns the number of rows actually dropped. + pub(crate) fn discard_history_prefix(&mut self, count: usize) -> usize { + let dropped = count.min(self.scrollback.len()); + if dropped == 0 { + return 0; + } + let survivors: Vec = (dropped..self.scrollback.len()) + .filter_map(|y| self.scrollback.row(y)) + .collect(); + self.scrollback.clear(); + self.scrollback.prepend_rows(&survivors); + self.rows_evicted = self.rows_evicted.saturating_add(dropped); + self.selection = None; + self.search.clear(); + self.scroll_shift = 0; + self.clamp_viewport(); + dropped + } + fn plain_text_rows(text: &str, cols: u16) -> Vec { let mut staging = Screen::with_scrollback(cols, 1, true); staging.set_scrollback_limit_bytes(usize::MAX); diff --git a/crates/noa-grid/src/snapshot.rs b/crates/noa-grid/src/snapshot.rs new file mode 100644 index 0000000..257ffa6 --- /dev/null +++ b/crates/noa-grid/src/snapshot.rs @@ -0,0 +1,1017 @@ +//! Serialization of a scrollback tail to a self-contained byte buffer, and +//! back — the storage format behind `scrollback-persist` +//! (`docs/specs/scrollback-persistence.md`). +//! +//! Ghostty has no analog: Ghostty restores window topology but never terminal +//! contents, so this whole module is a noa extension gated behind an opt-in +//! config key. +//! +//! ## Why this does not serialize [`crate::scrollback::PagedScrollback`] +//! +//! The paged representation looks like the obvious thing to dump: it is already +//! compact and style-interned. It is not portable, because two of its ids are +//! *process*-scoped rather than page-scoped: +//! +//! - `GraphemeId` indexes a global `LazyLock` interner (`grapheme.rs`) whose +//! numbering depends on the order a given run happened to see clusters in. +//! - `HyperlinkId` indexes `Terminal::hyperlinks`, which is per-`Terminal`. +//! +//! Writing those ids to disk would produce a file that decodes to different +//! text in the next process. So the wire format resolves both to their content +//! (the cluster's bytes, the link's URI) and rebuilds ids on load, and works at +//! the materialized [`Row`]/[`Cell`] level. Style interning is redone +//! per-snapshot, which recovers most of what the paged form would have saved; +//! the rest is recovered by deflate, which is very effective on grid data. +//! +//! ## Format (little-endian throughout) +//! +//! ```text +//! magic 6 b"NOASB\0" +//! version 2 u16 +//! flags 2 u16 bit0 = body is deflate-compressed +//! cols 2 u16 grid width the rows were captured at +//! saved_at 8 u64 unix seconds, for the record-view label +//! rows 4 u32 row count +//! body … see encode_body +//! ``` + +use std::io::{Read, Write}; + +use noa_core::{CellAttrs, Color, Rgb}; + +use crate::cell::{Cell, Hyperlink, HyperlinkId, Row}; + +const MAGIC: &[u8; 6] = b"NOASB\0"; +const VERSION: u16 = 1; +const FLAG_DEFLATE: u16 = 1 << 0; +const HEADER_LEN: usize = 24; + +/// Fallback ceiling on the inflated body for callers that do not know the +/// configured budget (tests, the `dump-snapshot` example). +/// +/// [`decode_within`] takes the real `scrollback-persist-limit` instead: a +/// snapshot is written by noa, but it is a file on disk that anything can +/// rewrite, and inflating 256 MiB from a few hundred compressed KiB at launch +/// is a freeze even though it is bounded. +const DEFAULT_MAX_DECODED_BODY: u64 = 256 << 20; + +/// Encoded size of one cell in the body: `ch` + style index + grapheme index. +const CELL_ENCODED_BYTES: usize = 12; +/// Encoded per-row overhead: `wrapped` + cell count. +const ROW_ENCODED_BYTES: usize = 5; + +/// Ceiling on a single persisted hyperlink target. +/// +/// OSC 8 payloads are bounded only by the parser's 12 MiB `MAX_OSC_BYTES`, and +/// a link lives in the side table rather than in any row, so one of them can +/// carry a snapshot past its whole byte budget while the row walk sees nothing. +/// A target longer than this is not something anyone is going to click; the +/// cell's text is persisted either way, only the link is dropped. +const MAX_PERSISTED_LINK_BYTES: usize = 4096; + +/// Color tags. `Option::None` (no underline color) needs a value +/// distinct from every `Some`, hence the fourth tag. +const TAG_DEFAULT: u32 = 0; +const TAG_PALETTE: u32 = 1; +const TAG_RGB: u32 = 2; +const TAG_NONE: u32 = 3; + +/// A decoded scrollback tail: the rows themselves plus the hyperlink registry +/// their cells refer to. `Cell::hyperlink` ids are 1-based indices into +/// [`Self::hyperlinks`] and must be remapped into the target terminal's +/// registry before the rows are inserted — [`crate::Terminal::restore_scrollback_snapshot`] +/// does that. +/// The raw material for a snapshot: rows lifted out of a terminal, plus the +/// registry their cells index. Produced under the terminal lock, encoded +/// outside it. +#[derive(Clone, Debug)] +pub struct ScrollbackSnapshotInput { + pub rows: Vec, + pub cols: u16, + pub hyperlinks: Vec, +} + +#[derive(Clone, Debug)] +pub struct ScrollbackSnapshot { + /// Grid width the rows were captured at. Rows are rewrapped when the + /// restoring screen is a different width. + pub cols: u16, + /// Unix seconds at capture time, surfaced by the record-view separator. + pub saved_at: u64, + pub rows: Vec, + pub hyperlinks: Vec, +} + +fn encode_color(color: Color) -> u32 { + match color { + Color::Default => TAG_DEFAULT << 24, + Color::Palette(index) => (TAG_PALETTE << 24) | u32::from(index), + Color::Rgb(rgb) => { + (TAG_RGB << 24) | (u32::from(rgb.r) << 16) | (u32::from(rgb.g) << 8) | u32::from(rgb.b) + } + } +} + +fn decode_color(raw: u32) -> Option { + match raw >> 24 { + TAG_DEFAULT => Some(Color::Default), + TAG_PALETTE => Some(Color::Palette((raw & 0xff) as u8)), + TAG_RGB => Some(Color::Rgb(Rgb::new( + ((raw >> 16) & 0xff) as u8, + ((raw >> 8) & 0xff) as u8, + (raw & 0xff) as u8, + ))), + _ => None, + } +} + +fn encode_optional_color(color: Option) -> u32 { + match color { + None => TAG_NONE << 24, + Some(color) => encode_color(color), + } +} + +fn decode_optional_color(raw: u32) -> Option> { + if raw >> 24 == TAG_NONE { + Some(None) + } else { + decode_color(raw).map(Some) + } +} + +/// The style half of a cell — everything except the character itself. Interned +/// per snapshot so a screenful of same-pen text costs one table entry. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct PackedStyle { + fg: u32, + bg: u32, + underline: u32, + attrs: u16, + /// 1-based index into the snapshot's hyperlink table; `0` = no link. + link: u32, +} + +// --------------------------------------------------------------------------- +// Encoding +// --------------------------------------------------------------------------- + +struct BodyWriter { + out: Vec, + styles: Vec, + style_lookup: std::collections::HashMap, + graphemes: Vec, + grapheme_lookup: std::collections::HashMap, + links: Vec, + link_lookup: std::collections::HashMap, +} + +fn push_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u64(out: &mut Vec, value: u64) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_str(out: &mut Vec, value: &str) { + push_u32(out, value.len() as u32); + out.extend_from_slice(value.as_bytes()); +} + +impl BodyWriter { + fn new() -> Self { + Self { + out: Vec::new(), + styles: Vec::new(), + style_lookup: std::collections::HashMap::new(), + graphemes: Vec::new(), + grapheme_lookup: std::collections::HashMap::new(), + links: Vec::new(), + link_lookup: std::collections::HashMap::new(), + } + } + + /// Intern `id` against the source registry, returning a 1-based index into + /// the snapshot-local table. A cell pointing at an id the terminal no + /// longer knows about loses its link rather than failing the whole capture. + fn intern_link(&mut self, id: HyperlinkId, registry: &[Hyperlink]) -> u32 { + if let Some(&index) = self.link_lookup.get(&id) { + return index; + } + let Some(link) = registry.get(id.get()) else { + return 0; + }; + if link.uri.len() + link.id.as_deref().map_or(0, str::len) > MAX_PERSISTED_LINK_BYTES { + self.link_lookup.insert(id, 0); + return 0; + } + self.links.push(link.clone()); + let index = self.links.len() as u32; + self.link_lookup.insert(id, index); + index + } + + fn intern_grapheme(&mut self, tail: &str) -> u32 { + if let Some(&index) = self.grapheme_lookup.get(tail) { + return index; + } + self.graphemes.push(tail.to_owned()); + let index = self.graphemes.len() as u32; + self.grapheme_lookup.insert(tail.to_owned(), index); + index + } + + fn intern_style(&mut self, cell: &Cell, registry: &[Hyperlink]) -> u32 { + let link = cell + .hyperlink + .map(|id| self.intern_link(id, registry)) + .unwrap_or(0); + let style = PackedStyle { + fg: encode_color(cell.fg), + bg: encode_color(cell.bg), + underline: encode_optional_color(cell.underline_color), + attrs: cell.attrs.bits(), + link, + }; + if let Some(&index) = self.style_lookup.get(&style) { + return index; + } + self.styles.push(style); + let index = (self.styles.len() - 1) as u32; + self.style_lookup.insert(style, index); + index + } +} + +/// Serialize `rows` into the NOASB body: tables first, then the rows that +/// reference them. +fn encode_body(rows: &[Row], registry: &[Hyperlink]) -> Vec { + let mut writer = BodyWriter::new(); + + // Rows are encoded into a scratch buffer first: interning them is what + // populates the tables that must be written ahead of them. + let mut row_bytes: Vec = Vec::new(); + for row in rows { + let cells = trimmed_cells(row); + row_bytes.push(u8::from(row.wrapped)); + push_u32(&mut row_bytes, cells.len() as u32); + for cell in cells { + let style = writer.intern_style(cell, registry); + let combining = cell.combining(); + let grapheme = if combining.is_empty() { + 0 + } else { + writer.intern_grapheme(combining) + }; + push_u32(&mut row_bytes, cell.ch as u32); + push_u32(&mut row_bytes, style); + push_u32(&mut row_bytes, grapheme); + } + } + + let out = &mut writer.out; + push_u32(out, writer.styles.len() as u32); + for style in &writer.styles { + push_u32(out, style.fg); + push_u32(out, style.bg); + push_u32(out, style.underline); + push_u16(out, style.attrs); + push_u32(out, style.link); + } + push_u32(out, writer.links.len() as u32); + for link in &writer.links { + push_str(out, &link.uri); + match link.id.as_deref() { + Some(id) => { + push_u32(out, 1); + push_str(out, id); + } + None => push_u32(out, 0), + } + } + push_u32(out, writer.graphemes.len() as u32); + for grapheme in &writer.graphemes { + push_str(out, grapheme); + } + out.extend_from_slice(&row_bytes); + writer.out +} + +/// A row's cells with the trailing run of untouched blanks removed. A live +/// grid is mostly empty to the right of the cursor, and those cells carry no +/// information a restored record needs. +fn trimmed_cells(row: &Row) -> &[Cell] { + let blank = Cell::default(); + let end = row + .cells + .iter() + .rposition(|cell| *cell != blank) + .map_or(0, |index| index + 1); + &row.cells[..end] +} + +/// Text a persisted record shows where an image used to be. +const IMAGE_MARKER: &str = "[image]"; + +/// Replace kitty-graphics placeholder cells with a text marker. +/// +/// Placeholder cells carry a private-use scalar plus diacritics encoding a +/// row/column into an image that lives in the terminal's image store — which is +/// not persisted and would not exist in the next process anyway. Serializing +/// them verbatim restores a run of tofu; dropping them silently would erase the +/// fact that something was there. A marker says what the row held without +/// pretending the image came back. +pub(crate) fn mark_images(row: &mut Row) { + let mut index = 0; + while index < row.cells.len() { + if row.cells[index].ch != crate::kitty_placeholder::PLACEHOLDER { + index += 1; + continue; + } + let start = index; + while index < row.cells.len() + && row.cells[index].ch == crate::kitty_placeholder::PLACEHOLDER + { + index += 1; + } + let template = Cell { + attrs: row.cells[start].attrs & !(CellAttrs::WIDE | CellAttrs::WIDE_SPACER), + ..Cell::default() + }; + for (offset, slot) in row.cells[start..index].iter_mut().enumerate() { + *slot = Cell { + ch: IMAGE_MARKER.chars().nth(offset).unwrap_or(' '), + ..template + }; + slot.grapheme = None; + } + } +} + +/// Whether `row` holds nothing a restored record would show. The capture drops +/// a trailing run of these (the live grid is blank below the cursor); a leading +/// run inside the budget is kept, since it is history the program printed. +pub(crate) fn is_blank_row(row: &Row) -> bool { + trimmed_cells(row).is_empty() +} + +/// Encoded size of `row`, used to spend the caller's byte budget without +/// building the buffer twice. +pub(crate) fn encoded_row_size(row: &Row) -> usize { + ROW_ENCODED_BYTES + trimmed_cells(row).len() * CELL_ENCODED_BYTES +} + +/// Wrap a finished body in the NOASB header, deflating it. +fn frame(body: &[u8], cols: u16, saved_at: u64, rows: u32) -> Option> { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(body).ok()?; + let compressed = encoder.finish().ok()?; + + let mut out = Vec::with_capacity(HEADER_LEN + compressed.len()); + out.extend_from_slice(MAGIC); + push_u16(&mut out, VERSION); + push_u16(&mut out, FLAG_DEFLATE); + push_u16(&mut out, cols); + push_u64(&mut out, saved_at); + push_u32(&mut out, rows); + debug_assert_eq!(out.len(), HEADER_LEN); + out.extend_from_slice(&compressed); + Some(out) +} + +/// Serialize the newest rows of `rows` that fit in `max_bytes` of *encoded* +/// payload. The budget is deliberately measured before deflate: it is the +/// quantity the capture side can bound without compressing twice, and +/// compression only ever makes the file smaller than the promise. +/// +/// Returns `None` when there is nothing worth saving — an empty tail, a zero +/// budget, or a tail that is entirely blank rows. +pub fn encode_tail( + rows: &[Row], + cols: u16, + saved_at: u64, + registry: &[Hyperlink], + max_bytes: usize, +) -> Option> { + if max_bytes == 0 { + return None; + } + // Walk backwards from the newest row, spending the budget, then keep that + // suffix. A single row wider than the whole budget is still kept, so a + // tiny limit degrades to "one row" rather than to "nothing". + let mut spent = 0usize; + let mut start = rows.len(); + for (index, row) in rows.iter().enumerate().rev() { + let size = encoded_row_size(row); + if spent + size > max_bytes && start < rows.len() { + break; + } + spent += size; + start = index; + } + if rows[start..] + .iter() + .all(|row| trimmed_cells(row).is_empty()) + { + return None; + } + + // The row walk above only accounts for row bodies. The style, hyperlink and + // grapheme tables are written alongside them and are *not* bounded by cell + // count: one OSC 8 URI can reach the parser's 12 MiB ceiling on its own, so + // a link-heavy tail can blow a 1 MiB budget with a handful of rows. Encode + // for real and drop the oldest rows until it fits, so the configured limit + // is a limit rather than an estimate. + loop { + let tail = &rows[start..]; + let body = encode_body(tail, registry); + if body.len() <= max_bytes || tail.len() <= 1 { + return frame(&body, cols, saved_at, tail.len() as u32); + } + // Halve rather than step: the overshoot is usually a table entry the + // per-row estimate cannot see, so a linear walk would re-encode the + // whole tail once per row. + start += (tail.len() / 2).max(1); + if rows[start..] + .iter() + .all(|row| trimmed_cells(row).is_empty()) + { + return None; + } + } +} + +// --------------------------------------------------------------------------- +// Decoding +// --------------------------------------------------------------------------- + +struct BodyReader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> BodyReader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, len: usize) -> Option<&'a [u8]> { + let end = self.offset.checked_add(len)?; + let slice = self.bytes.get(self.offset..end)?; + self.offset = end; + Some(slice) + } + + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?)) + } + + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + + fn string(&mut self) -> Option { + let len = self.u32()? as usize; + String::from_utf8(self.take(len)?.to_vec()).ok() + } +} + +/// Parse a NOASB buffer. Every malformed, truncated, wrong-version, or +/// wrong-magic input returns `None` — a snapshot is a convenience, and the +/// caller's contract is that a bad one degrades to "no record", never to a +/// failed launch. +pub fn decode(bytes: &[u8]) -> Option { + decode_within(bytes, DEFAULT_MAX_DECODED_BODY) +} + +/// [`decode`], bounded by the caller's configured budget. +/// +/// `max_body` is a hard reject rather than a truncation: a body that does not +/// fit was not written by a noa honoring the same limit, and half a record is +/// worse than none. +pub fn decode_within(bytes: &[u8], max_body: u64) -> Option { + let header = bytes.get(..HEADER_LEN)?; + if &header[..6] != MAGIC { + return None; + } + let version = u16::from_le_bytes(header[6..8].try_into().ok()?); + if version != VERSION { + return None; + } + let flags = u16::from_le_bytes(header[8..10].try_into().ok()?); + let cols = u16::from_le_bytes(header[10..12].try_into().ok()?); + let saved_at = u64::from_le_bytes(header[12..20].try_into().ok()?); + let row_count = u32::from_le_bytes(header[20..24].try_into().ok()?) as usize; + + let raw = &bytes[HEADER_LEN..]; + let body = if flags & FLAG_DEFLATE != 0 { + let mut decoded = Vec::new(); + // `take(n + 1)`: reading exactly the ceiling cannot distinguish "fits" + // from "truncated here", and a silently truncated body decodes to a + // plausible-looking short record. + flate2::read::DeflateDecoder::new(raw) + .take(max_body.saturating_add(1)) + .read_to_end(&mut decoded) + .ok()?; + if decoded.len() as u64 > max_body { + return None; + } + decoded + } else { + if raw.len() as u64 > max_body { + return None; + } + raw.to_vec() + }; + + let mut reader = BodyReader::new(&body); + + let style_count = reader.u32()? as usize; + let mut styles = Vec::with_capacity(style_count.min(4096)); + for _ in 0..style_count { + let fg = decode_color(reader.u32()?)?; + let bg = decode_color(reader.u32()?)?; + let underline = decode_optional_color(reader.u32()?)?; + let attrs = CellAttrs::from_bits_truncate(reader.u16()?); + let link = reader.u32()?; + styles.push((fg, bg, underline, attrs, link)); + } + + let link_count = reader.u32()? as usize; + let mut hyperlinks = Vec::with_capacity(link_count.min(4096)); + for _ in 0..link_count { + let uri = reader.string()?; + let id = match reader.u32()? { + 0 => None, + _ => Some(reader.string()?), + }; + hyperlinks.push(Hyperlink { uri, id }); + } + + let grapheme_count = reader.u32()? as usize; + let mut graphemes = Vec::with_capacity(grapheme_count.min(4096)); + for _ in 0..grapheme_count { + graphemes.push(reader.string()?); + } + + let mut rows = Vec::with_capacity(row_count.min(4096)); + for _ in 0..row_count { + let wrapped = reader.take(1)?[0] != 0; + let cell_count = reader.u32()? as usize; + let mut cells = Vec::with_capacity(cell_count.min(4096)); + for _ in 0..cell_count { + let ch = char::from_u32(reader.u32()?)?; + let style_index = reader.u32()? as usize; + let grapheme_index = reader.u32()? as usize; + let &(fg, bg, underline, attrs, link) = styles.get(style_index)?; + let mut cell = Cell { + ch, + fg, + bg, + underline_color: underline, + // Snapshot-local index into `hyperlinks` (the wire value is + // 1-based so `0` can mean "no link"); remapped into the target + // terminal's registry by + // `Terminal::restore_scrollback_snapshot`. + // Bounded against the table decoded above: an id past its end + // would otherwise be handed to callers as a live registry index + // and adopt an unrelated URI. + hyperlink: link + .checked_sub(1) + .filter(|index| (*index as usize) < hyperlinks.len()) + .and_then(|index| HyperlinkId::new(index as usize)), + attrs, + grapheme: None, + }; + if grapheme_index != 0 { + cell.set_combining(graphemes.get(grapheme_index - 1)?); + } + cells.push(cell); + } + rows.push(Row::from_cells(cells, wrapped, false)); + } + + Some(ScrollbackSnapshot { + cols, + saved_at, + rows, + hyperlinks, + }) +} + +// --------------------------------------------------------------------------- +// Rewrapping +// --------------------------------------------------------------------------- + +/// Re-lay `rows` for a screen `cols` wide, preserving styles. +/// +/// A snapshot captured in an 80-column window and restored into a 200-column +/// one would otherwise show its soft-wraps frozen at the old width. Rows are +/// joined back into logical lines along their `wrapped` flags and re-split, so +/// restored history wraps like live history does. +/// +/// A wide (CJK) glyph and its spacer are never separated: when a split would +/// land between them, the lead moves to the next row and the vacated column is +/// left blank — the same choice the live reflow makes. +pub fn rewrap(rows: Vec, cols: u16) -> Vec { + if cols == 0 { + return Vec::new(); + } + let width = usize::from(cols); + if rows.iter().all(|row| row.cells.len() == width) && rows.iter().all(|row| !row.wrapped) { + return rows; + } + + let mut out = Vec::with_capacity(rows.len()); + let mut logical: Vec = Vec::new(); + for row in rows { + let continues = row.wrapped; + let trimmed = trimmed_cells(&row).len(); + logical.extend_from_slice(&row.cells[..trimmed]); + if continues { + continue; + } + emit_logical_line(&logical, width, &mut out); + logical.clear(); + } + if !logical.is_empty() { + emit_logical_line(&logical, width, &mut out); + } + out +} + +fn emit_logical_line(line: &[Cell], width: usize, out: &mut Vec) { + if line.is_empty() { + out.push(Row::from_cells(vec![Cell::default(); width], false, false)); + return; + } + let mut start = 0usize; + while start < line.len() { + let mut end = (start + width).min(line.len()); + // Never strand a wide lead from its spacer across the split — but never + // back off past `start` either: at width 1 a wide glyph would otherwise + // make `end == start`, and the loop would emit blank rows forever + // without consuming a single cell. + if end < line.len() + && end > start + 1 + && line[end].attrs.contains(CellAttrs::WIDE_SPACER) + && line[end - 1].attrs.contains(CellAttrs::WIDE) + { + end -= 1; + } + debug_assert!( + end > start, + "every iteration must consume at least one cell" + ); + let mut cells = line[start..end].to_vec(); + cells.resize(width, Cell::default()); + let wrapped = end < line.len(); + out.push(Row::from_cells(cells, wrapped, false)); + start = end; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styled(ch: char, fg: Color, attrs: CellAttrs) -> Cell { + Cell { + ch, + fg, + attrs, + ..Cell::default() + } + } + + fn row_of(text: &str, cols: usize) -> Row { + let mut cells: Vec = text + .chars() + .map(|ch| Cell { + ch, + ..Cell::default() + }) + .collect(); + cells.resize(cols, Cell::default()); + Row::from_cells(cells, false, false) + } + + #[test] + fn roundtrip_preserves_every_cell_field() { + let mut cell = styled('A', Color::Rgb(Rgb::new(1, 2, 3)), CellAttrs::BOLD); + cell.bg = Color::Palette(42); + cell.underline_color = Some(Color::Palette(7)); + cell.hyperlink = HyperlinkId::new(0); + let registry = vec![Hyperlink { + uri: "https://example.com".into(), + id: Some("anchor".into()), + }]; + let row = Row::from_cells(vec![cell, Cell::default()], true, false); + + let bytes = encode_tail(&[row], 2, 1234, ®istry, 4096).expect("non-empty tail encodes"); + let decoded = decode(&bytes).expect("roundtrip decodes"); + + assert_eq!(decoded.cols, 2); + assert_eq!(decoded.saved_at, 1234); + assert_eq!(decoded.hyperlinks, registry); + assert_eq!(decoded.rows.len(), 1); + let restored = &decoded.rows[0]; + assert!(restored.wrapped); + assert_eq!(restored.cells[0].ch, 'A'); + assert_eq!(restored.cells[0].fg, Color::Rgb(Rgb::new(1, 2, 3))); + assert_eq!(restored.cells[0].bg, Color::Palette(42)); + assert_eq!(restored.cells[0].underline_color, Some(Color::Palette(7))); + assert_eq!(restored.cells[0].attrs, CellAttrs::BOLD); + assert_eq!(restored.cells[0].hyperlink, HyperlinkId::new(0)); + } + + #[test] + fn roundtrip_preserves_a_combining_cluster() { + let mut cell = Cell { + ch: 'e', + ..Cell::default() + }; + cell.set_combining("\u{301}"); + let row = Row::from_cells(vec![cell], false, false); + + let bytes = encode_tail(&[row], 1, 0, &[], 4096).expect("encodes"); + let decoded = decode(&bytes).expect("decodes"); + assert_eq!(decoded.rows[0].cells[0].ch, 'e'); + assert_eq!(decoded.rows[0].cells[0].combining(), "\u{301}"); + } + + #[test] + fn trailing_blanks_are_dropped_but_interior_ones_survive() { + let row = row_of("a b", 40); + let bytes = encode_tail(&[row], 40, 0, &[], 4096).expect("encodes"); + let decoded = decode(&bytes).expect("decodes"); + // "a b" — the blank between the words is interior and kept; the 37 + // untouched columns after it are not. + assert_eq!(decoded.rows[0].cells.len(), 3); + assert_eq!(decoded.rows[0].cells[1].ch, ' '); + } + + #[test] + fn the_budget_keeps_the_newest_rows() { + let rows: Vec = (0..10).map(|i| row_of(&format!("row{i}"), 8)).collect(); + // Room for roughly two rows. + let budget = 2 * (ROW_ENCODED_BYTES + 4 * CELL_ENCODED_BYTES); + let bytes = encode_tail(&rows, 8, 0, &[], budget).expect("encodes"); + let decoded = decode(&bytes).expect("decodes"); + assert!(decoded.rows.len() < 10, "budget must drop older rows"); + let last: String = decoded + .rows + .last() + .unwrap() + .cells + .iter() + .map(|c| c.ch) + .collect(); + assert!( + last.starts_with("row9"), + "newest row must survive: {last:?}" + ); + } + + #[test] + fn a_row_larger_than_the_whole_budget_is_still_kept() { + let rows = vec![row_of("hello", 80)]; + let bytes = encode_tail(&rows, 80, 0, &[], 1).expect("one oversized row still encodes"); + assert_eq!(decode(&bytes).expect("decodes").rows.len(), 1); + } + + #[test] + fn an_all_blank_tail_encodes_to_nothing() { + let rows = vec![row_of("", 80), row_of("", 80)]; + assert!(encode_tail(&rows, 80, 0, &[], 4096).is_none()); + assert!(encode_tail(&[row_of("x", 80)], 80, 0, &[], 0).is_none()); + } + + #[test] + fn corrupt_input_decodes_to_none_rather_than_panicking() { + assert!(decode(b"").is_none()); + assert!(decode(b"not a snapshot at all, really").is_none()); + + let good = encode_tail(&[row_of("hi", 8)], 8, 0, &[], 4096).expect("encodes"); + // Wrong magic. + let mut wrong_magic = good.clone(); + wrong_magic[0] = b'X'; + assert!(decode(&wrong_magic).is_none()); + // Wrong version. + let mut wrong_version = good.clone(); + wrong_version[6] = 9; + assert!(decode(&wrong_version).is_none()); + // Truncated body. + assert!(decode(&good[..good.len() - 3]).is_none()); + // Header claiming more rows than the body holds. + let mut lying_header = good.clone(); + lying_header[20] = 200; + assert!(decode(&lying_header).is_none()); + } + + #[test] + fn identical_styles_share_one_table_entry() { + let cells: Vec = (0..200) + .map(|_| styled('x', Color::Palette(3), CellAttrs::ITALIC)) + .collect(); + let row = Row::from_cells(cells, false, false); + let bytes = encode_tail(&[row], 200, 0, &[], 1 << 20).expect("encodes"); + let decoded = decode(&bytes).expect("decodes"); + assert!( + decoded.rows[0] + .cells + .iter() + .all(|c| c.attrs == CellAttrs::ITALIC && c.fg == Color::Palette(3)) + ); + // 200 identically-styled cells must not cost 200 style entries; the + // whole file should stay far under the raw cell footprint. + assert!(bytes.len() < 200 * CELL_ENCODED_BYTES, "{}", bytes.len()); + } + + #[test] + fn image_placeholders_become_a_marker_rather_than_tofu() { + // The image itself is not persisted and would not exist next process; + // the placeholder scalars would restore as a run of unknown glyphs. + let placeholder = Cell { + ch: crate::kitty_placeholder::PLACEHOLDER, + attrs: CellAttrs::WIDE, + ..Cell::default() + }; + let mut row = Row::from_cells( + vec![ + Cell { + ch: 'a', + ..Cell::default() + }, + placeholder, + placeholder, + placeholder, + ], + false, + false, + ); + + mark_images(&mut row); + + let text: String = row.cells.iter().map(|cell| cell.ch).collect(); + assert_eq!(text, "a[im"); + assert!( + row.cells[1..] + .iter() + .all(|cell| !cell.attrs.contains(CellAttrs::WIDE)), + "the marker is plain text, not a wide-glyph pair" + ); + assert!( + !row.cells + .iter() + .any(|cell| cell.ch == crate::kitty_placeholder::PLACEHOLDER), + "no placeholder scalar may survive into the file" + ); + } + + #[test] + fn rewrap_rejoins_soft_wrapped_rows_at_the_new_width() { + // "abcdef" captured at width 3 → two rows, the first soft-wrapped. + let mut first = row_of("abc", 3); + first.wrapped = true; + let second = row_of("def", 3); + + let widened = rewrap(vec![first, second], 6); + assert_eq!(widened.len(), 1); + let text: String = widened[0].cells.iter().map(|c| c.ch).collect(); + assert_eq!(text, "abcdef"); + assert!(!widened[0].wrapped); + } + + #[test] + fn rewrap_splits_a_long_line_and_marks_the_continuations() { + let narrowed = rewrap(vec![row_of("abcdef", 6)], 4); + assert_eq!(narrowed.len(), 2); + assert!(narrowed[0].wrapped, "the first row continues"); + assert!(!narrowed[1].wrapped, "the last row ends the line"); + let first: String = narrowed[0].cells.iter().map(|c| c.ch).collect(); + let second: String = narrowed[1].cells.iter().map(|c| c.ch).collect(); + assert_eq!(first, "abcd"); + assert_eq!(second, "ef ", "the tail is padded with blanks"); + } + + #[test] + fn rewrap_never_separates_a_wide_glyph_from_its_spacer() { + let wide = styled('あ', Color::Default, CellAttrs::WIDE); + let spacer = styled(' ', Color::Default, CellAttrs::WIDE_SPACER); + let row = Row::from_cells( + vec![ + Cell { + ch: 'x', + ..Cell::default() + }, + wide, + spacer, + ], + false, + false, + ); + + // Width 2 would otherwise split between the lead and its spacer. + let wrapped = rewrap(vec![row], 2); + assert_eq!(wrapped.len(), 2); + assert_eq!(wrapped[0].cells[0].ch, 'x'); + assert_eq!( + wrapped[0].cells[1], + Cell::default(), + "the lead moved down, leaving the column blank" + ); + assert_eq!(wrapped[1].cells[0].ch, 'あ'); + assert!(wrapped[1].cells[1].attrs.contains(CellAttrs::WIDE_SPACER)); + } + + #[test] + fn rewrap_terminates_when_a_wide_glyph_cannot_fit_the_width() { + // Backing off to keep a wide lead with its spacer must never leave the + // split at the row start: that consumes nothing and loops forever. + let wide = styled('あ', Color::Default, CellAttrs::WIDE); + let spacer = styled(' ', Color::Default, CellAttrs::WIDE_SPACER); + let row = Row::from_cells(vec![wide, spacer], false, false); + + let out = rewrap(vec![row], 1); + + assert_eq!(out.len(), 2, "one cell per row, no more"); + assert_eq!(out[0].cells[0].ch, 'あ'); + assert!(out[1].cells[0].attrs.contains(CellAttrs::WIDE_SPACER)); + } + + #[test] + fn the_budget_counts_the_side_tables_not_just_the_rows() { + // A single huge URI lives in the link table, which the per-row size + // estimate cannot see. The encoded file must still respect the budget. + let uri = "https://example.com/".to_string() + &"a".repeat(200_000); + let registry = vec![Hyperlink { + uri: uri.clone(), + id: None, + }]; + let mut linked = Cell { + ch: 'x', + ..Cell::default() + }; + linked.hyperlink = HyperlinkId::new(0); + let rows = vec![ + row_of("plain older line", 40), + Row::from_cells(vec![linked], false, false), + ]; + + let bytes = encode_tail(&rows, 40, 0, ®istry, 4096).expect("something encodes"); + assert!( + bytes.len() <= 4096, + "encoded {} bytes against a 4096 budget", + bytes.len() + ); + let decoded = decode(&bytes).expect("decodes"); + assert!( + decoded.hyperlinks.iter().all(|link| link.uri != uri), + "an oversized link must not reach the file at all" + ); + let text: String = decoded + .rows + .last() + .expect("the linked row survives") + .cells + .iter() + .map(|cell| cell.ch) + .collect(); + assert!( + text.starts_with('x'), + "dropping the link must not drop the text: {text:?}" + ); + } + + #[test] + fn rewrap_preserves_blank_lines_between_paragraphs() { + let rows = vec![row_of("a", 4), row_of("", 4), row_of("b", 4)]; + let out = rewrap(rows, 4); + assert_eq!(out.len(), 3); + assert!( + out[1].cells.iter().all(|c| *c == Cell::default()), + "the blank line survives as a blank row" + ); + } + + #[test] + fn a_link_id_the_registry_no_longer_knows_degrades_to_no_link() { + let mut cell = Cell { + ch: 'z', + ..Cell::default() + }; + cell.hyperlink = HyperlinkId::new(99); + let row = Row::from_cells(vec![cell], false, false); + let bytes = encode_tail(&[row], 1, 0, &[], 4096).expect("encodes"); + let decoded = decode(&bytes).expect("decodes"); + assert_eq!(decoded.rows[0].cells[0].hyperlink, None); + assert!(decoded.hyperlinks.is_empty()); + } +} diff --git a/crates/noa-grid/src/terminal.rs b/crates/noa-grid/src/terminal.rs index e67f52a..9306760 100644 --- a/crates/noa-grid/src/terminal.rs +++ b/crates/noa-grid/src/terminal.rs @@ -23,6 +23,7 @@ use crate::osc::{ use crate::screen::Screen; use crate::search::SearchMatch; use crate::selection::SelectionPoint; +use crate::snapshot::{ScrollbackSnapshot, ScrollbackSnapshotInput}; use noa_core::{GridSize, Point}; use noa_vt::{EraseDisplay, SgrAttr}; @@ -425,6 +426,150 @@ impl Terminal { inserted } + /// Serialize the newest rows of the **primary** screen into a + /// self-contained snapshot buffer (`crate::snapshot`), spending at most + /// `max_bytes` of encoded payload. `saved_at` is stamped into the header + /// for the record-view label; the grid does not read clocks. + /// + /// The primary screen is captured even while the alternate screen is + /// active: the alternate screen is by definition a transient full-screen + /// app view (vim, less), and restoring it into a fresh shell would show a + /// dead frame of a program that is no longer running. What a restored + /// pane wants is the shell history underneath. + /// + /// Returns `None` when there is nothing worth saving — an empty or fully + /// blank primary screen, or a zero budget. + /// `skip_row` omits one session-absolute row from the capture. The app + /// passes the record separator it inserted at restore: that row is chrome + /// the app synthesized, not something the program printed, so re-capturing + /// it would bake it into the next record and leave one more behind on every + /// relaunch until the history is a stack of separators. + pub fn scrollback_snapshot_bytes( + &self, + max_bytes: usize, + saved_at: u64, + skip_row: Option, + ) -> Option> { + let input = self.scrollback_snapshot_input(max_bytes, skip_row)?; + crate::snapshot::encode_tail( + &input.rows, + input.cols, + saved_at, + &input.hyperlinks, + max_bytes, + ) + } + + /// The rows a snapshot would serialize, without serializing them. + /// + /// Cloning rows is a memcpy; interning styles and deflating them is not, + /// and this is called with the shared terminal locked on the main thread + /// while the io thread waits to drain the pty. Callers that can encode + /// elsewhere should take this and hand the result to + /// [`crate::snapshot::encode_tail`] off the lock. + pub fn scrollback_snapshot_input( + &self, + max_bytes: usize, + skip_row: Option, + ) -> Option { + if max_bytes == 0 { + return None; + } + let screen = &self.primary; + let skip_row = skip_row.and_then(|abs| abs.checked_sub(screen.rows_evicted())); + // The live grid is blank below the cursor; without this the record + // would restore with a screenful of empty lines after its last line. + let mut end = screen.total_rows(); + while end > 0 { + match screen.absolute_row(end - 1) { + Some(row) if crate::snapshot::is_blank_row(&row) => end -= 1, + _ => break, + } + } + + let mut collected = Vec::new(); + let mut spent = 0usize; + for y in (0..end).rev() { + if Some(y) == skip_row { + continue; + } + let Some(mut row) = screen.absolute_row(y) else { + break; + }; + crate::snapshot::mark_images(&mut row); + let size = crate::snapshot::encoded_row_size(&row); + if spent + size > max_bytes && !collected.is_empty() { + break; + } + spent += size; + collected.push(row); + } + collected.reverse(); + + if collected.is_empty() { + return None; + } + Some(ScrollbackSnapshotInput { + rows: collected, + cols: screen.cols, + hyperlinks: self.hyperlinks.clone(), + }) + } + + /// Insert a decoded snapshot as the oldest history of the primary screen, + /// remapping its snapshot-local hyperlink ids into this terminal's + /// registry. Returns the number of rows that survived retention. + /// + /// Intended for a terminal that has not started reading its pty yet, which + /// is why it takes ownership: the rows become history verbatim rather than + /// being replayed as VT input, so nothing in the snapshot can move the + /// cursor, change the title, or otherwise act on the live session. + pub fn restore_scrollback_snapshot(&mut self, snapshot: ScrollbackSnapshot) -> usize { + let ScrollbackSnapshot { + mut rows, + hyperlinks, + .. + } = snapshot; + if rows.is_empty() { + return 0; + } + + // Unconditional, including for an empty table: skipping the pass would + // leave any surviving id pointing into *this* terminal's registry, + // where it names an unrelated URI. + let remapped: Vec> = hyperlinks + .into_iter() + .map(|link| self.intern_hyperlink(link)) + .collect(); + for row in &mut rows { + for cell in &mut row.cells { + if let Some(id) = cell.hyperlink { + cell.hyperlink = remapped.get(id.get()).copied().flatten(); + } + } + } + + let inserted = self.primary.prepend_row_history(rows); + if inserted > 0 { + self.invalidate_grid_coordinate_space(); + for mark in &mut self.shell_marks { + mark.point.y = mark.point.y.saturating_add(inserted); + } + } + inserted + } + + /// Drop the oldest `count` rows of the primary screen's scrollback — the + /// restored record, without the live history that followed it. Returns the + /// number of rows dropped. + pub fn discard_history_prefix(&mut self, count: usize) -> usize { + let dropped = self.primary.discard_history_prefix(count); + if dropped > 0 { + self.invalidate_grid_coordinate_space(); + } + dropped + } + pub fn set_search_query(&mut self, query: impl Into) { self.active_mut().set_search_query(query); } @@ -737,13 +882,13 @@ impl Terminal { } } - fn set_current_hyperlink(&mut self, hyperlink: Hyperlink) { + /// Register `hyperlink` in the OSC 8 registry, deduping against what is + /// already there. `None` once the registry is full — the caller drops the + /// link rather than mislabeling a cell with someone else's URI. + fn intern_hyperlink(&mut self, hyperlink: Hyperlink) -> Option { let id = match self.hyperlink_index.get(&hyperlink) { Some(&id) => id, - None if self.hyperlinks.len() >= HYPERLINK_REGISTRY_CAP => { - self.active_mut().cursor.hyperlink = None; - return; - } + None if self.hyperlinks.len() >= HYPERLINK_REGISTRY_CAP => return None, None => { let id = self.hyperlinks.len(); self.hyperlinks.push(hyperlink.clone()); @@ -751,7 +896,11 @@ impl Terminal { id } }; - self.active_mut().cursor.hyperlink = HyperlinkId::new(id); + HyperlinkId::new(id) + } + + fn set_current_hyperlink(&mut self, hyperlink: Hyperlink) { + self.active_mut().cursor.hyperlink = self.intern_hyperlink(hyperlink); } fn clear_current_hyperlink(&mut self) { diff --git a/crates/noa-grid/src/tests/mod.rs b/crates/noa-grid/src/tests/mod.rs index 7e0d760..f40ce53 100644 --- a/crates/noa-grid/src/tests/mod.rs +++ b/crates/noa-grid/src/tests/mod.rs @@ -95,5 +95,6 @@ include!("bulk_print.rs"); include!("print_overwrite.rs"); include!("erase_set_from.rs"); include!("occupancy_watermark.rs"); +include!("scrollback_snapshot.rs"); include!("line_batch.rs"); diff --git a/crates/noa-grid/src/tests/scrollback_snapshot.rs b/crates/noa-grid/src/tests/scrollback_snapshot.rs new file mode 100644 index 0000000..23bd364 --- /dev/null +++ b/crates/noa-grid/src/tests/scrollback_snapshot.rs @@ -0,0 +1,292 @@ +// End-to-end `Terminal` ↔ snapshot round-trips: capture on one terminal, +// restore into a fresh one, the way `scrollback-persist` does across a quit. + +use crate::snapshot; + +/// Capture `source` and restore it into a brand-new `cols`×`rows` terminal, +/// the way a relaunch does. +fn restore_into(source: &Terminal, cols: u16, rows: u16, max_bytes: usize) -> Terminal { + let bytes = source + .scrollback_snapshot_bytes(max_bytes, 1_700_000_000, None) + .expect("a terminal with output produces a snapshot"); + let decoded = snapshot::decode(&bytes).expect("a snapshot noa just wrote decodes"); + let mut restored = Terminal::new(GridSize::new(cols, rows)); + restored.restore_scrollback_snapshot(decoded); + restored +} + +#[test] +fn a_captured_session_reads_back_as_history_in_a_fresh_terminal() { + let source = run_size(20, 4, b"first\r\nsecond\r\nthird\r\nfourth\r\nfifth"); + let mut restored = restore_into(&source, 20, 4, 1 << 20); + + let text = restored.scrollback_text().expect("restored history is text"); + for line in ["first", "second", "third", "fourth", "fifth"] { + assert!(text.contains(line), "{line:?} missing from {text:?}"); + } +} + +#[test] +fn restored_history_keeps_its_colors() { + // Red "boom", then a default-pen line. + let source = run_size(20, 4, b"\x1b[31mboom\x1b[0m\r\nplain\r\n"); + let restored = restore_into(&source, 20, 4, 1 << 20); + + let row = restored + .active_absolute_row(0) + .expect("the first restored row is addressable"); + assert_eq!(row.cells[0].ch, 'b'); + assert_eq!( + row.cells[0].fg, + Color::Palette(1), + "an error line restored gray would lose the only cue a beginner has" + ); +} + +#[test] +fn restored_history_keeps_bold_and_underline_attributes() { + let source = run_size(20, 3, b"\x1b[1;4mloud\x1b[0m\r\n"); + let restored = restore_into(&source, 20, 3, 1 << 20); + + let row = restored.active_absolute_row(0).expect("row 0 exists"); + assert!(row.cells[0].attrs.contains(CellAttrs::BOLD)); + assert!(row.cells[0].attrs.contains(CellAttrs::UNDERLINE)); +} + +#[test] +fn restored_history_keeps_hyperlinks_resolvable_in_the_new_registry() { + let source = run_size(20, 3, b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\\r\n"); + let restored = restore_into(&source, 20, 3, 1 << 20); + + let row = restored.active_absolute_row(0).expect("row 0 exists"); + let id = row.cells[0] + .hyperlink + .expect("the linked cell keeps a link id"); + assert_eq!( + restored.hyperlinks[id.get()].uri, "https://example.com", + "the id must resolve in the *restoring* terminal's registry" + ); +} + +#[test] +fn a_snapshot_restores_into_a_wider_terminal_without_frozen_wraps() { + // 6 columns: "abcdefgh" soft-wraps onto a second row. + let source = run_size(6, 3, b"abcdefgh\r\n"); + let mut restored = restore_into(&source, 12, 3, 1 << 20); + + let text = restored.scrollback_text().expect("restored history is text"); + assert!( + text.contains("abcdefgh"), + "the logical line must rejoin at the new width: {text:?}" + ); + let row = restored.active_absolute_row(0).expect("row 0 exists"); + assert_eq!(row.cells.len(), 12, "rows are re-laid at the new width"); + assert!(!row.wrapped, "the line now fits on one row"); +} + +#[test] +fn a_snapshot_restores_into_a_narrower_terminal_by_rewrapping() { + let source = run_size(12, 3, b"abcdefgh\r\n"); + let mut restored = restore_into(&source, 4, 3, 1 << 20); + + let text = restored.scrollback_text().expect("restored history is text"); + assert!(text.contains("abcdefgh"), "{text:?}"); + let first = restored.active_absolute_row(0).expect("row 0 exists"); + assert_eq!(first.cells.len(), 4); + assert!(first.wrapped, "the split rows stay one logical line"); +} + +#[test] +fn the_alternate_screen_is_never_captured() { + // Write shell output, enter the alternate screen, fill it, and capture + // from there: what a pane restores must be the shell history underneath, + // not a dead frame of a full-screen app. + let source = run_size(20, 4, b"shell output\r\n\x1b[?1049hFULLSCREEN APP\r\n"); + assert!(source.active_is_alt, "the fixture must be on the alt screen"); + + let mut restored = restore_into(&source, 20, 4, 1 << 20); + let text = restored.scrollback_text().expect("restored history is text"); + assert!(text.contains("shell output"), "{text:?}"); + assert!( + !text.contains("FULLSCREEN APP"), + "alt-screen contents leaked into the record: {text:?}" + ); +} + +#[test] +fn a_terminal_that_produced_nothing_has_no_snapshot() { + let source = run_size(20, 4, b""); + assert!(source.scrollback_snapshot_bytes(1 << 20, 0, None).is_none()); +} + +#[test] +fn a_zero_budget_captures_nothing() { + let source = run_size(20, 4, b"something\r\n"); + assert!(source.scrollback_snapshot_bytes(0, 0, None).is_none()); +} + +#[test] +fn a_small_budget_keeps_the_newest_lines() { + let source = run_size(20, 4, b"oldest\r\nmiddle\r\nnewest\r\n"); + // Room for roughly one row. + let mut restored = restore_into(&source, 20, 4, 80); + + let text = restored.scrollback_text().expect("restored history is text"); + assert!(text.contains("newest"), "the newest line must survive: {text:?}"); + assert!( + !text.contains("oldest"), + "the budget must drop the oldest line: {text:?}" + ); +} + +#[test] +fn restoring_leaves_the_live_grid_and_cursor_alone() { + let source = run_size(20, 4, b"history\r\n"); + let bytes = source + .scrollback_snapshot_bytes(1 << 20, 0, None) + .expect("snapshot"); + let decoded = snapshot::decode(&bytes).expect("decodes"); + + let mut restored = Terminal::new(GridSize::new(20, 4)); + let cursor_before = restored.primary.cursor.y; + let inserted = restored.restore_scrollback_snapshot(decoded); + + assert!(inserted > 0); + assert_eq!( + restored.primary.cursor.y, cursor_before, + "restored rows are history, not replayed input" + ); + assert_eq!(restored.title, "", "a snapshot cannot set the window title"); + assert!( + restored.take_pending_writes().is_empty(), + "a snapshot cannot make the terminal write to the pty" + ); +} + +#[test] +fn a_corrupt_snapshot_file_leaves_the_terminal_untouched() { + let mut restored = Terminal::new(GridSize::new(20, 4)); + assert!(snapshot::decode(b"\x00garbage\xff").is_none()); + assert_eq!(restored.scrollback_text(), None, "nothing was inserted"); +} + +#[test] +fn restoring_twice_stacks_the_older_record_first() { + let older = run_size(20, 3, b"older\r\n"); + let newer = run_size(20, 3, b"newer\r\n"); + + let mut restored = Terminal::new(GridSize::new(20, 3)); + // Newest first, then older: each prepend goes ahead of what is there, so + // the reading order ends up chronological. + for source in [&newer, &older] { + let bytes = source + .scrollback_snapshot_bytes(1 << 20, 0, None) + .expect("snapshot"); + restored.restore_scrollback_snapshot(snapshot::decode(&bytes).expect("decodes")); + } + + let text = restored.scrollback_text().expect("restored history is text"); + let older_at = text.find("older").expect("older present"); + let newer_at = text.find("newer").expect("newer present"); + assert!(older_at < newer_at, "chronological order: {text:?}"); +} + +#[test] +fn a_skipped_row_is_left_out_of_the_capture() { + // The app inserts a synthetic separator row when it restores a record. + // Capturing it would bake it into the next record, and every relaunch + // would leave one more behind until the history is a stack of separators. + let source = run_size(20, 4, b"real output\r\nSEPARATOR\r\nmore output\r\n"); + let separator_abs = source + .active_absolute_row(1) + .map(|_| 1) + .expect("row 1 exists"); + + let bytes = source + .scrollback_snapshot_bytes(1 << 20, 0, Some(separator_abs)) + .expect("snapshot"); + let decoded = snapshot::decode(&bytes).expect("decodes"); + let text: Vec = decoded + .rows + .iter() + .map(|row| row.cells.iter().map(|c| c.ch).collect::().trim_end().to_string()) + .collect(); + + assert!(text.iter().any(|line| line == "real output"), "{text:?}"); + assert!(text.iter().any(|line| line == "more output"), "{text:?}"); + assert!( + !text.iter().any(|line| line == "SEPARATOR"), + "the skipped row must not survive: {text:?}" + ); +} + +fn snapshot_text(source: &Terminal, skip: Option) -> Vec { + let bytes = source + .scrollback_snapshot_bytes(1 << 20, 0, skip) + .expect("snapshot"); + snapshot::decode(&bytes) + .expect("decodes") + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|c| c.ch) + .collect::() + .trim_end() + .to_string() + }) + .collect() +} + +#[test] +fn a_skipped_row_above_the_retained_range_is_ignored() { + let source = run_size(20, 4, b"alpha\r\nbeta\r\n"); + let text = snapshot_text(&source, Some(usize::MAX)); + assert!(text.iter().any(|line| line == "alpha"), "{text:?}"); + assert!(text.iter().any(|line| line == "beta"), "{text:?}"); +} + +#[test] +fn a_skipped_row_below_the_eviction_point_is_ignored() { + // The real stale-index case: an index from before eviction. `usize::MAX` + // exercises the "too large" branch instead, which is a different path — + // this one has to actually evict first. + // Eviction is page-granular and a page is 64 KiB, so the fixture needs + // enough rows to fill more than one page before anything is dropped. + let mut source = Terminal::new(GridSize::new(80, 3)); + source.primary.set_scrollback_limit_bytes(64 * 1024); + let mut stream = noa_vt::Stream::new(); + for line in 0..3000 { + stream.feed(format!("line{line}\r\n").as_bytes(), &mut source); + } + let evicted = source.active_oldest_row(); + assert!(evicted > 0, "the fixture must have evicted rows"); + + let text = snapshot_text(&source, Some(evicted - 1)); + + let unskipped = snapshot_text(&source, None); + assert_eq!( + text, unskipped, + "an index below the retained range must skip nothing at all" + ); + assert!( + text.iter().any(|line| line == "line2999"), + "the newest line must survive" + ); +} + +#[test] +fn a_skipped_row_landing_on_a_live_row_drops_exactly_that_row() { + // The dangerous direction: a *valid-looking* index that names live output. + // Nothing stops the caller passing one, which is why the app guards it with + // the terminal's coordinate generation. + let source = run_size(20, 5, b"keep-one\r\nvictim\r\nkeep-two\r\n"); + let text = snapshot_text(&source, Some(1)); + assert!(text.iter().any(|line| line == "keep-one"), "{text:?}"); + assert!(text.iter().any(|line| line == "keep-two"), "{text:?}"); + assert!( + !text.iter().any(|line| line == "victim"), + "skip_row is unconditional by design: {text:?}" + ); +} diff --git a/crates/noa-render/src/renderer/cell.rs b/crates/noa-render/src/renderer/cell.rs index 8d702d5..051d423 100644 --- a/crates/noa-render/src/renderer/cell.rs +++ b/crates/noa-render/src/renderer/cell.rs @@ -138,6 +138,15 @@ pub(super) fn rebuild_row_instances( cursor_visual_for(snap) }; let row_highlights = RowHighlights::new(snap, y, row.cells.len()); + // Restored-record gutter (`scrollback-persist` spec §5): this row's + // session-absolute index falls inside a persisted-snapshot range the + // caller restored, so column 0 gets an extra marker quad below — unless + // selection/search/cursor already own that cell's background (checked + // per-cell, since those apply to column 0 like any other column). + let record_row_hit = snap + .record_rows + .as_ref() + .is_some_and(|range| range.contains(&(snap.abs_row_base + usize::from(y)))); for (col_idx, cell) in row.cells.iter().enumerate() { let x = col_idx as u16; @@ -206,6 +215,28 @@ pub(super) fn rebuild_row_instances( }); } + // Restored-record gutter: one extra opaque quad at column 0, layered + // on top of the cell's own background quad above (the bg pass draws + // in push order, so a later instance wins the pixel) but never over + // selection/search-match/cursor, which already won this cell's + // background above and must stay as the user sees them. The glyph + // pass runs entirely after every pane's bg pass, so this never hides + // the column-0 glyph. + if x == 0 + && record_row_hit + && !(cursor_block_fill || selected || active_search || search_match) + { + let gutter = surface_output_rgb(theme.record_gutter(), target_format_is_srgb); + bg_instances.push(CellInstance { + glyph_pos: [0, 0], + glyph_size: [0, 0], + bearing: [0, 0], + grid_pos: [0, y], + color: to_u8_color(gutter), + flags: 0, + }); + } + let mut text_rgb = theme.contrast_adjusted_fg(text_base_rgb, bg_rgb); // SGR 2 (faint/dim): render the ink at half opacity over its own // background, i.e. `(fg + bg) / 2`. This matches Ghostty's `native` @@ -724,6 +755,7 @@ pub(super) fn rebuild_pane_cached( search: snap.search.clone(), cell_size, hover_link: snap.hover_link, + record_rows: snap.record_rows.clone(), atlas_identity, atlas_eviction_generation: final_gen, }); diff --git a/crates/noa-render/src/renderer/mod.rs b/crates/noa-render/src/renderer/mod.rs index ca1e5f1..8f57d88 100644 --- a/crates/noa-render/src/renderer/mod.rs +++ b/crates/noa-render/src/renderer/mod.rs @@ -188,6 +188,12 @@ struct FrameInvalidationKey { /// full-pane-invalidation bundle as the other pane-wide triggers rather /// than tracking affected rows individually. hover_link: Option, + /// The restored-record row range (`FrameSnapshot::record_rows`). Like + /// `hover_link`, this has no corresponding `Row::dirty` bit — it changes + /// with the caller's own restore bookkeeping, not terminal output — so a + /// change here also forces every row in the pane dirty rather than + /// tracking affected rows individually. + record_rows: Option>, /// Identity of the [`FontGrid`] atlas pair. Unlike generation counters, /// this changes whenever a new FontGrid is constructed, even if the new /// atlas starts at the same generation and size as the old one. @@ -220,6 +226,7 @@ fn frame_invalidation_key_matches( && key.atlas_eviction_generation == atlas_eviction_generation && key.selection == snap.selection && key.hover_link == snap.hover_link + && key.record_rows == snap.record_rows && key.colors == snap.colors && key.theme == *theme && key.search == snap.search diff --git a/crates/noa-render/src/renderer/tests.rs b/crates/noa-render/src/renderer/tests.rs index 85c0205..3a43c9b 100644 --- a/crates/noa-render/src/renderer/tests.rs +++ b/crates/noa-render/src/renderer/tests.rs @@ -126,5 +126,6 @@ fn baseline_snapshot(chars: [char; 3]) -> FrameSnapshot { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, } } diff --git a/crates/noa-render/src/renderer/tests/cache.rs b/crates/noa-render/src/renderer/tests/cache.rs index cda7a62..7d3f6a6 100644 --- a/crates/noa-render/src/renderer/tests/cache.rs +++ b/crates/noa-render/src/renderer/tests/cache.rs @@ -540,6 +540,20 @@ fn pane_wide_invalidation_triggers_are_covered_fm11() { ); } + // 7b. record_rows (restored-record gutter row range). Like hover_link, + // this carries no terminal damage (it changes with the caller's own + // restore bookkeeping), so it needs the same full-pane invalidation. + { + let snap_a = baseline_snapshot(['A', 'B', 'C']); + let mut snap_b = baseline_snapshot(['A', 'B', 'C']); + snap_b.record_rows = Some(0..2); + let rebuilt = rebuild_twice(110, &snap_a, &theme, &snap_b, &theme); + assert_eq!( + rebuilt, 3, + "a record_rows change must force a full pane rebuild" + ); + } + // 8. cursor movement — the narrower case: dirties exactly the two // affected rows, NOT a full-pane invalidation. { diff --git a/crates/noa-render/src/renderer/tests/cell.rs b/crates/noa-render/src/renderer/tests/cell.rs index 0b5189e..266aea2 100644 --- a/crates/noa-render/src/renderer/tests/cell.rs +++ b/crates/noa-render/src/renderer/tests/cell.rs @@ -373,3 +373,141 @@ fn hover_link_range_underlines_only_the_matching_run_on_its_row() { "only columns 1..=2 on row 0 are underlined; row 1 and the rest of row 0 are not" ); } + +/// Column-0 background quads whose color matches the theme's restored-record +/// gutter — used by the tests below to isolate the marker from the plain +/// (default, unpainted) background of the other cells in each row. +fn gutter_quads(instances: &[CellInstance], theme: &Theme) -> Vec<[u16; 2]> { + let gutter = theme.record_gutter(); + instances + .iter() + .filter(|i| i.glyph_size == [0, 0] && rgb_from_instance(i) == gutter) + .map(|i| i.grid_pos) + .collect() +} + +#[test] +fn record_rows_marks_rows_inside_the_range_with_a_gutter_quad_at_column_zero() { + let Some(mut font) = skip_font() else { return }; + let mut terminal = Terminal::new(GridSize::new(2, 3)); + terminal.primary.cursor.visible = false; + let mut snap = FrameSnapshot::from_terminal(&mut terminal); + snap.record_rows = Some(0..2); + + let theme = Theme::new(); + let mut instances = Vec::new(); + rebuild_cell_instances(&mut instances, &snap, &mut font, &theme, false); + + let mut marked = gutter_quads(&instances, &theme); + marked.sort(); + assert_eq!( + marked, + vec![[0, 0], [0, 1]], + "rows 0 and 1 (inside the 0..2 record range) get a column-0 gutter quad; \ + row 2 does not" + ); +} + +#[test] +fn no_record_rows_emits_no_gutter_quads() { + let Some(mut font) = skip_font() else { return }; + let mut terminal = Terminal::new(GridSize::new(2, 3)); + terminal.primary.cursor.visible = false; + let snap = FrameSnapshot::from_terminal(&mut terminal); + assert_eq!( + snap.record_rows, None, + "from_terminal defaults to no record range" + ); + + let theme = Theme::new(); + let mut instances = Vec::new(); + rebuild_cell_instances(&mut instances, &snap, &mut font, &theme, false); + + assert!( + gutter_quads(&instances, &theme).is_empty(), + "no record_rows set: no gutter quad should be emitted anywhere" + ); +} + +#[test] +fn row_outside_the_record_range_emits_no_gutter_quad() { + let Some(mut font) = skip_font() else { return }; + let mut terminal = Terminal::new(GridSize::new(2, 3)); + terminal.primary.cursor.visible = false; + let mut snap = FrameSnapshot::from_terminal(&mut terminal); + // Only row 1 (abs index 1) is inside the range; rows 0 and 2 are outside. + snap.record_rows = Some(1..2); + + let theme = Theme::new(); + let mut instances = Vec::new(); + rebuild_cell_instances(&mut instances, &snap, &mut font, &theme, false); + + assert_eq!( + gutter_quads(&instances, &theme), + vec![[0, 1]], + "rows 0 and 2 sit outside the 1..2 record range and must not get a gutter quad" + ); +} + +#[test] +fn selection_still_wins_the_gutter_column_inside_a_record_row() { + let Some(mut font) = skip_font() else { return }; + let mut terminal = Terminal::new(GridSize::new(2, 3)); + terminal.primary.cursor.visible = false; + let mut snap = FrameSnapshot::from_terminal(&mut terminal); + snap.record_rows = Some(0..3); + snap.selection = Some(Selection::new( + SelectionPoint::new(0, 0), + SelectionPoint::new(0, 0), + )); + + let theme = Theme::new(); + let mut instances = Vec::new(); + rebuild_cell_instances(&mut instances, &snap, &mut font, &theme, false); + + let col0_row0: Vec<_> = instances + .iter() + .filter(|i| i.glyph_size == [0, 0] && i.grid_pos == [0, 0]) + .collect(); + assert_eq!( + col0_row0.len(), + 1, + "selection must still emit exactly one background quad at column 0, \ + not an extra gutter quad on top of it" + ); + assert_eq!( + rgb_from_instance(col0_row0[0]), + theme.selection_bg, + "the selection color must win over the record gutter at column 0" + ); +} + +#[test] +fn cursor_still_wins_the_gutter_column_inside_a_record_row() { + let Some(mut font) = skip_font() else { return }; + let mut terminal = Terminal::new(GridSize::new(2, 3)); + // Default cursor: visible, steady block, at (0, 0) — left in place so it + // owns column 0 of row 0. + let mut snap = FrameSnapshot::from_terminal(&mut terminal); + snap.record_rows = Some(0..3); + + let theme = Theme::new(); + let mut instances = Vec::new(); + rebuild_cell_instances(&mut instances, &snap, &mut font, &theme, false); + + let col0_row0: Vec<_> = instances + .iter() + .filter(|i| i.glyph_size == [0, 0] && i.grid_pos == [0, 0]) + .collect(); + assert_eq!( + col0_row0.len(), + 1, + "the cursor must still emit exactly one background quad at column 0, \ + not an extra gutter quad on top of it" + ); + assert_eq!( + col0_row0[0].flags, + CellInstance::FLAG_CURSOR, + "the cursor fill must win over the record gutter at column 0" + ); +} diff --git a/crates/noa-render/src/snapshot.rs b/crates/noa-render/src/snapshot.rs index 009193f..5336cdd 100644 --- a/crates/noa-render/src/snapshot.rs +++ b/crates/noa-render/src/snapshot.rs @@ -270,6 +270,15 @@ pub struct FrameSnapshot { pub image_placements: Vec, /// Pixel data for the images `image_placements` references (deduped by id). pub images: Vec, + /// Session-absolute row range (same coordinate space as `abs_row_base`, + /// NOT `row_base` — it must stay meaningful across scrollback eviction) + /// that was rehydrated from a persisted scrollback snapshot rather than + /// produced by live pty output (`scrollback-persist`, spec §5). `None` + /// draws no restored-record marker at all. Set by the caller (`noa-app`, + /// from its per-pane restore bookkeeping) after the fact, like + /// `hover_link`; `from_terminal` and every other constructor default to + /// `None`. + pub record_rows: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -541,6 +550,7 @@ impl FrameSnapshot { preedit: None, image_placements, images, + record_rows: None, } } @@ -619,6 +629,7 @@ impl FrameSnapshot { preedit: None, image_placements, images, + record_rows: None, } } @@ -661,6 +672,7 @@ impl FrameSnapshot { snapshot.preedit = None; snapshot.image_placements = image_placements; snapshot.images = images; + snapshot.record_rows = None; } /// Refresh a publish slot for the Session Overview. diff --git a/crates/noa-render/src/theme.rs b/crates/noa-render/src/theme.rs index 9fe85bc..7b13a4a 100644 --- a/crates/noa-render/src/theme.rs +++ b/crates/noa-render/src/theme.rs @@ -154,6 +154,22 @@ impl Theme { pub(crate) fn contrast_adjusted_fg(&self, fg: Rgb, bg: Rgb) -> Rgb { ensure_minimum_contrast(fg, bg, self.minimum_contrast) } + + /// Left-gutter accent for a restored-record row range + /// (`FrameSnapshot::record_rows` — scrollback rehydrated from a + /// persisted snapshot rather than live pty output, `scrollback-persist` + /// spec §5). A 50/50 blend of this theme's own selection and + /// active-search highlight backgrounds rather than a hardcoded hue: + /// both already have to read clearly against `default_bg` in every + /// theme to do their own job, so mixing them yields a third accent that + /// inherits the same light/dark-theme visibility guarantee for free — + /// the same derivation `OverlayStyle` uses for its own accent colors. + /// A method rather than a stored field: `Theme` is built via an + /// exhaustive struct literal in `noa-app`, so a new required field would + /// have to be threaded through there too. + pub fn record_gutter(&self) -> Rgb { + blend(self.selection_bg, self.active_search_bg, 0.5) + } } pub(crate) fn rgba(rgb: Rgb) -> [f32; 4] { diff --git a/crates/noa-render/tests/cache_equiv.rs b/crates/noa-render/tests/cache_equiv.rs index 4dde6c2..ec4f417 100644 --- a/crates/noa-render/tests/cache_equiv.rs +++ b/crates/noa-render/tests/cache_equiv.rs @@ -118,6 +118,7 @@ fn fresh_snapshot(term: &Terminal) -> FrameSnapshot { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, } } diff --git a/crates/noa-render/tests/pipeline/cards.rs b/crates/noa-render/tests/pipeline/cards.rs index 272a840..bc0c148 100644 --- a/crates/noa-render/tests/pipeline/cards.rs +++ b/crates/noa-render/tests/pipeline/cards.rs @@ -635,6 +635,7 @@ fn command_palette_card_composites_without_validation_error() { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, }; renderer.rebuild_cells(&snap, &mut font, &Theme::new()); renderer.sync_atlas(&device, &queue, &mut font); diff --git a/crates/noa-render/tests/pipeline/cell.rs b/crates/noa-render/tests/pipeline/cell.rs index 07571b8..4d2fb1b 100644 --- a/crates/noa-render/tests/pipeline/cell.rs +++ b/crates/noa-render/tests/pipeline/cell.rs @@ -151,6 +151,7 @@ fn cell_pipeline_draws_one_frame_without_validation_error() { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, }; let theme = Theme::new(); @@ -258,6 +259,7 @@ fn command_palette_overlay_draws_one_frame_without_validation_error() { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, }; renderer.rebuild_cells(&snap, &mut font, &Theme::new()); @@ -391,6 +393,7 @@ fn command_palette_surface_alpha_is_uniform_across_plain_and_selected_rows() { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, }; let target = 0.68_f32; @@ -531,6 +534,7 @@ fn cell_pipeline_draws_full_then_dirty_patched_frame_without_validation_error() preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, } } @@ -656,6 +660,7 @@ fn cell_pipeline_draws_color_glyph_without_validation_error_and_samples_passthro preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, }; let theme = Theme::new(); diff --git a/crates/noa-render/tests/pipeline/shared.rs b/crates/noa-render/tests/pipeline/shared.rs index c5e91c1..2838d49 100644 --- a/crates/noa-render/tests/pipeline/shared.rs +++ b/crates/noa-render/tests/pipeline/shared.rs @@ -70,6 +70,7 @@ pub(crate) fn snapshot_for_text(text: &str) -> FrameSnapshot { preedit: None, image_placements: Vec::new(), images: Vec::new(), + record_rows: None, } } @@ -273,6 +274,7 @@ pub(crate) fn image_snapshot( height: 4, rgba: Arc::from(rgba), }], + record_rows: None, } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bbe6ac2..7ef0a7b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -67,7 +67,12 @@ Behavioral (non-key) deviation: the config-file **live reload** cadence describe | `window-height` | integer `0..=65535` | `24` | Number of rows. Must be specified together with `window-width` in the config; rounded up to a minimum of `4` at resolution time | | `window-padding-x` | finite decimal `>= 0` | unspecified | Left/right padding. When unspecified, defaults to `24` physical px on the left and `16` on the right | | `window-padding-y` | finite decimal `>= 0` | unspecified | Top/bottom padding. When unspecified, defaults to `0` physical px on top and `16` on the bottom | -| `window-save-state` | `default`, `never`, `always` | `default` | `default` and `always` save/restore; `never` disables it | +| `window-save-state` | `default`, `never`, `always` | `default` | `default` and `always` save/restore; `never` disables it. Restores the window/tab/split **topology and cwd only** — terminal contents are governed separately by `scrollback-persist` below | +| `scrollback-persist` | `never`, `tail` | `never` | noa-specific key (no Ghostty analog). `tail` writes each pane's scrollback tail to `/noa/scrollback/` on quit and on idle checkpoints, and restores it on launch as a marked read-only **record region** above the live shell. `never` (the default) writes nothing and matches Ghostty, which restores no contents. Opt-in because persisting terminal output changes what a stolen disk yields — see [`docs/specs/scrollback-persistence.md`](specs/scrollback-persistence.md). Enabling it logs a warning naming what is now written to disk; turning it off (or setting `scrollback-persist-limit = 0`) deletes the existing records immediately rather than at the next launch. Requires `window-save-state` to be enabled: the reference that makes a snapshot restorable lives in the session file. Snapshot files are `0600` inside a `0700` directory and excluded from Time Machine; the alternate screen (vim/less), scratch terminals and remote panes are never persisted | +| `scrollback-persist-limit` | non-negative integer (bytes) | `1048576` | noa-specific key. Per-pane cap on persisted scrollback, measured on the **encoded payload before deflate**, so the file on disk is smaller. `0` persists nothing | +| `scrollback-persist-total-limit` | non-negative integer (bytes) | `67108864` | noa-specific key. Cap on the total on-disk size of all snapshots, enforced at launch against real file sizes; the least recently written panes are dropped first | +| `scrollback-persist-max-age-days` | non-negative integer (days) | `7` | noa-specific key. Bounds how long a snapshot **file** may sit untouched: files whose last write is older than this are discarded at launch. `0` never expires. It does not bound the age of the *content* — a pane you keep using is re-captured on every quit, carrying its restored record forward, so its file never ages out. Use `scrollback-persist-limit` to bound how much history a pane retains. Days, as a plain integer — noa's config has no unit-suffix syntax, so `7d` is rejected | +| `scrollback-persist-encrypt` | `true`, `false` | `false` | noa-specific key. Seals each snapshot with AES-256-GCM under a 256-bit key generated on first use and kept in the login keychain (non-syncable, so it never reaches iCloud). Off by default because it is a real trade, not a free win: **lose the keychain entry and the records are unreadable**. If the key cannot be obtained while this is on, noa declines to write rather than storing the record in the clear. Reading branches on the file itself, so turning it on or off does not orphan what is already stored | | `confirm-quit` | `true`, `false` | `true` | Confirm before quitting the app | | `resize-overlay` | `after-first`, `always`, `never` | `after-first` | Display of `cols × rows` during resize. `after-first` excludes only the initial layout | diff --git a/docs/specs/scrollback-persistence.md b/docs/specs/scrollback-persistence.md new file mode 100644 index 0000000..8ace7d8 --- /dev/null +++ b/docs/specs/scrollback-persistence.md @@ -0,0 +1,346 @@ +# Scrollback persistence(記録ビュー) + +**Status: 実装済み(Stage 0 + Stage 1)。GUI 目視確認は未。** +Q1 / Q2 は回答済み、Q3–Q5 は仮決めのまま実装(§10 決定事項)。実装中に確定した +逸脱・縮小は §12 に記録した。 +**Provenance: synthetic (`synthetic: true`)。** 発端は Plea が生成した合成ペルソナ +「陽菜 (27) / 継続ビギナー / 個人 Mac」の要望であり、実ユーザーの検証済みの声では +ない。需要そのものが仮説であることを、優先度判断のときに必ず思い出すこと。 + +--- + +## 1. 問題 + +`docs/specs/session-restore.md` は **トポロジ(ウィンドウ / タブ / split)と +cwd だけ**を復元し、端末の内容は「Ghostty に合わせて復元しない」と定めている。 +これは仕様として正しいが、**UI が復元されたことが、内容も復元されたという期待を +生む**という副作用がある。 + +> タブが全部そのまま戻ってきたから「あ、続きが読める」って思ったんです。なのに +> 開いたら真っ白で。じゃあ何が復元されたんですか? 私が見たかったのは配置じゃなくて +> エラーの文章なんですけど…。 + +ここには**独立した 2 つの欠陥**がある。混ぜて議論しない: + +| # | 欠陥 | 種類 | 対応 | +|---|------|------|------| +| D-1 | 「何が復元され、何が復元されないか」が事前にも事後にも伝わらない | 正直さの欠如(今のコードのバグに近い) | Stage 0 | +| D-2 | 直前の表示内容そのものが失われる | 機能の不在 | Stage 1–3 | + +D-1 は D-2 を実装しなくても単独で解消でき、コストが桁違いに小さい。**先に D-1 を +潰す**。 + +### 受入基準の対応表 + +| AC(ユーザー視点) | 満たす Stage | +|---|---| +| 終了→起動後に各タブの直前の表示内容が読める | Stage 1(履歴テール) | +| 中身が戻らない仕様なら事前に分かる | **Stage 0** | +| ライブ/記録の区別がつく | Stage 1(記録ビュー) | + +--- + +## 2. 前提と仮定(明示) + +- **A-1**: 需要は合成。ペルソナ 1 体ぶんの仮説であり、実ユーザーの要求頻度は未知。 +- **A-2**: 陽菜が読みたかった「エラーの文章」は **primary screen** 上にある想定。 + vim / less などの alt screen 内の表示は本提案の対象外(§6 参照)。 +- **A-3**: 想定環境は**個人所有の単独ユーザー Mac**。共有マシン・管理端末・ + マルチユーザーの脅威モデルは v1 の対象外。 +- **A-4**: マシンをまたいだ復元(同期)は対象外。 +- ~~**A-5**: 「直前の表示内容」の粒度は要望文から確定できない~~ → **Q2 で解決。 + 履歴全体(末尾テール)が対象。** 1 画面モードは作らない(§10 DEC-2)。 + +--- + +## 3. 既存資産 + +| 資産 | 場所 | 本提案での役割 | +|---|---|---| +| セッション復元 | `docs/specs/session-restore.md`, `crates/noa-app/src/session.rs` | 復元の入れ物。leaf にスナップショット参照を 1 フィールド追加 | +| 非同期書き出しワーカー | `crates/noa-app/src/session_persist.rs` | コアレス済み・アトミック書き込み・Drop で flush。スナップショット書き出しも相乗り | +| ページ化 scrollback | `crates/noa-grid/src/scrollback.rs` | `PackedCell` + ページ単位 `StyleTable` + grapheme table。~~保存形式にそのまま使う~~ → プロセススコープの id を含むため不可(§4.1 実装時の訂正)。実際の形式は `crates/noa-grid/src/snapshot.rs` | +| 行の折返しフラグ | `Row::wrapped` (`cell.rs:151`) | 復元後にウィンドウ幅が変わったとき `screen/reflow.rs` が正しく reflow するために必須。保存対象 | +| テキスト抽出 | `Screen::scrollback_text_tail(max_bytes)` (`screen/text.rs:435`) | Stage 1 のフォールバック実装(色を捨てる版)に流用可 | +| 検索 | `noa-grid/src/search.rs` | 復元領域も scrollback なので**追加実装なしで検索対象になる** | +| 圧縮 | `flate2`(workspace dep、現状 `noa-grid` のみ利用) | deflate。新規依存ゼロ | +| 設定行レジストリ | `crates/noa-app/src/theme_settings/rows.rs` | 新キーの GUI 露出。`SettingsRowKind::COUNT`(現在 33)の**手動 bump が必要**(過去に踏んだ罠) | + +--- + +## 4. 設計方針 + +### 4.1 何を保存するか — 「バイト列の再生」ではなく「描画済みの行」 + +| 案 | 内容 | 判定 | +|---|---|---| +| (a) pty バイト列を保存し、起動時に `Stream` へ再投入 | 最も忠実 | **却下**。副作用が再生される(OSC 7 cwd 上書き、タイトル変更、ベル、kitty gfx、alt screen 遷移)。サイズが非有界。復元が遅い | +| (b) **描画済みの行(cells + attrs)を保存** | 決定的、副作用ゼロ、行数で有界 | **採用** | +| (c) プレーンテキストのみ | 最安 | 単独では却下。色が落ちると「赤 = エラー」という初心者の唯一の手掛かりが消える。Stage 1 の縮退パスとしてのみ保持 | + +**実装時の訂正:** 当初は「packed 表現をそのまま直列化する」つもりだったが、これは +不可能だった。packed 表現の 2 つの id が*プロセス*スコープだからである —— +`GraphemeId` はグローバルな `LazyLock` interner の索引であり、`HyperlinkId` は +`Terminal::hyperlinks` の索引。どちらもディスクに書くと**次のプロセスでは別の +テキストにデコードされる**。したがって形式は materialize 済みの `Row`/`Cell` +レベルで動き、両者を内容(クラスタのバイト列、リンクの URI)へ解決してからロード +時に id を張り直す。スタイルはスナップショット単位で再 intern し、残りは deflate +が回収する。 + +### 4.2 保存範囲(scope) + +- **primary screen のみ**。alt screen は定義上一時的な表示であり、保存しない。 + 終了時に alt screen にいたペインは、その下の primary 履歴が復元される。 +- **末尾から**。先頭ではなく最新側を残す(陽菜が読みたいのは「直前」)。 +- **ペイン単位**。split の各 leaf が独立したスナップショットを持つ。 +- **除外**: scratch terminal(使い捨てポップアップ/`docs/specs/scratch-terminal.md`) + は常に非保存。remote attach ペイン(`RemotePane`)も v1 では非保存 + (内容の所有者がローカルではないため)。 + +### 4.3 容量上限 + +多層のキャップで、暴走を構造的に不可能にする: + +| キャップ | 既定値 | 単位 | 目的 | +|---|---|---|---| +| `scrollback-persist-limit` | 1 MiB | **エンコード後・deflate 前**のバイト/ペイン | 保存するテールの上限。**これが唯一の量的つまみ**(1 画面だけ欲しい人は小さくする)。圧縮前で測るのは、キャプチャ側が二度圧縮せずに予算を確定できる唯一の量だから。ディスク上のファイルは必ずこれより小さい | +| `scrollback-persist-total-limit` | 64 MiB | 実ファイルサイズ/全体 | ディスク総量。起動時に実サイズで検査し、超過時は mtime の古い順に破棄 | +| `scrollback-persist-max-age-days` | 7 | 日(整数) | 古い記録の自動失効。`0` で無期限。suffix 構文は noa の config に存在しないので `7d` は診断エラー | + +さらに起動時 GC: `session.json` から参照されていないスナップショットファイルは +削除する(孤児回収)。 + +### 4.4 書き出しのタイミング(session.json と**同じにしない**) + +`persist_session()` はトポロジ変更のたびに走る。ペインごとに最大 1 MiB をコピー +する処理を同じ頻度で回すのは論外。スナップショットのトリガは別立てにする: + +1. **クリーン終了時**(winit `exiting`)— 主経路。 +2. **アイドルチェックポイント** — 出力が **5 秒**止まったら、前回チェックポイント + 以降に出力を出したペインだけを捕捉する。静穏だけを条件にすると*出力が止まらない + 限り一度も走らない* —— まさに末尾を残したい長時間ビルドが該当する —— ので、 + 前回から **60 秒**を上限として、洪水中でも必ず一度は走る。クラッシュ耐性はここ + から来る。 +3. **明示コマンド** — コマンドパレット `Checkpoint Scrollback Now`。 + +キャプチャ(`Arc>` の読み取り+エンコード)はメインスレッド、 +ディスク書き込みは専用ワーカー(`scrollback_persist::ScrollbackPersister`)。 +キーごとにコアレスするので、10 ペインぶんのチェックポイントが重なっても各ペインの +最新バイト列だけが書かれる。 + +### 4.5 ファイル形式 + +`/noa/scrollback/.nsb`(macOS では +`~/Library/Application Support/noa/scrollback/`)。`session.json` の leaf に +`"scrollback": ""` を 1 フィールド追加する(`SESSION_VERSION` は 2 → **3**。2 は remote メタデータで既に使われていた)。pane-key は 16 桁小文字 hex に限定し、`session.json` は編集可能なファイルである以上、パストラバーサルの入力口として扱って検証する。 +**バージョン不一致・破損・欠落は「記録なし」に落ちるだけで、起動を阻害しない** +(既存 session.json と同じ規約)。 + +``` +magic "NOASB\0" 6 B +version u16 = 1 +flags u16 bit0: deflate, bit1: encrypted +cols u32 保存時のグリッド幅(reflow 判断用) +saved_at u64 Unix 秒(記録ビューのラベル表示に使う) +rows u32 行数 +payload … deflate(style table ++ link table ++ grapheme table ++ rows) +``` + +serde は使わない(`noa-config` / `session.rs` の手書きパーサ規約に合わせる)。 + +### 4.6 暗号化と権限 ← **本提案で最も重い判断** + +端末の scrollback は日常的に機微情報を含む: `export AWS_SECRET_ACCESS_KEY=…`、 +CLI が echo したトークン、PAT 入りの `git remote` URL、非公開ソース。**今日それは +RAM にしか存在せず、プロセスと共に消える。永続化はこの脅威モデルを変える。** +「便利だから」で既定 ON にしてよい変更ではない。 + +**v1 のベースライン(Stage 1 から必須):** + +- ディレクトリ `0700`、ファイル `0600`。 +- 保存先は既にユーザースコープの Application Support 配下(FileVault の保護下)。 +- **Time Machine / iCloud バックアップから除外**する + (`NSURLIsExcludedFromBackupKey`)。記録がバックアップ経由で外へ漏れる経路を塞ぐ。 +- `scrollback-persist = never`(既定)のときは**ディレクトリごと作らない**。 + +**Stage 3 の追加(オプトイン):** + +- `scrollback-persist-encrypt = true` で AES-256-GCM。鍵は Keychain + (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`)。ファイルごとに nonce。 +- 鍵が取得できない場合は**復号を諦めて「記録なし」に落ちる**(起動は止めない)。 + +**既定 OFF は確定(§10 DEC-1)。** noa は Ghostty の忠実クローンであり、Ghostty は +内容を復元しない。既定 ON は観測可能な挙動の逸脱になる。よって本機能は**明示的な +noa 拡張として既定 `never`**。AC-1 の意図は Stage 0 が担保する ——「まさに落胆した +その瞬間に」有効化方法が目に入る導線を作ることで、opt-in が「気づかれない機能」に +ならないようにする。**この導線が無ければ Stage 1 は価値を持たない**(§7 の順序が +固定である理由)。 + +--- + +## 5. 記録ビュー(AC-3「ライブ/記録の区別」の答え) + +復元された行を、ただ scrollback に流し込むだけでは**過去の出力がライブ出力に見える**。 +これは元の欠陥(D-1)を別の場所で再生産することになる。よって復元領域は +**明示的にマークされた読み取り専用の帯**として提示する。 + +``` + │ $ cargo build + │ error[E0308]: mismatched types ← 記録(左ガター: 淡いアクセント罫) + │ --> src/main.rs:42:9 + ├──── 2026-07-28 14:03 までの記録 ─── ここから下がライブ ──── + $ ▏ ← ライブ(ガターなし) +``` + +- **セパレータ行**: 全幅の淡い罫 + 保存時刻ラベル。pty 由来ではない合成行。 +- **左ガター**: 記録領域の各行の**第 0 桁**にアクセント淡色の背景矩形(実装上の + 逸脱については §12 D-2)。減光は**しない**(陽菜が読みたいのはその文字であり、 + 読みにくくしては本末転倒)。選択・検索ヒット・カーソルはガターより優先される。 +- ~~**「記録」バッジ**~~ → 見送り(§12 D-3)。 +- 記録領域は**通常の scrollback**なので、選択・コピー・検索はそのまま効く。 +- 記録領域では shell integration のセマンティクス(プロンプトジャンプ再実行等)は + 無効。OSC 8 リンクは有効のまま。 +- **破棄手段**: コマンドパレット `Discard restored history`。`clear` でも消える。 + +--- + +## 6. 非目標 + +- 実行中プロセスの復元。復元されるのは**死んだテキスト**であり、シェルは新規。 +- ログ機能の代替(`script` / tmux logging の置き換えではない)。 +- alt screen の内容(vim / less / TUI の画面)の復元。 +- kitty graphics / sixel の復元。Stage 1–2 では画像セルは落とし、 + 1 行のプレースホルダに置換する(誤って「画像があったこと」まで消さない)。 +- マシン間同期。 + +--- + +## 7. 段階的スコープ + +| Stage | 内容 | 閉じる AC | 規模の目安 | +|---|---|---|---| +| **0 — 正直さ** | 復元されたペインに 1 行の告知(`layout restored · previous output was not saved · set scrollback-persist = tail to keep it`)。Settings に `Persist Scrollback` 行。**永続化なし** | AC-2 | 実装済み | +| **1 — 履歴テール永続化** | 末尾 `scrollback-persist-limit` バイトぶんの履歴を属性付きで保存/復元(deflate 圧縮)。記録ビュー(セパレータ + ガター + バッジ)。ファイル形式・権限・Time Machine 除外・起動時 GC のベースライン。復元領域は既存 `search.rs` でそのまま検索対象になる | AC-1, AC-3 | 本命。`noa-grid` に serialize/deserialize、`noa-app` に capture/restore/記録ビュー、`noa-config` に 4 キー | +| **2 — 堅牢化** | Keychain 暗号化、総量 LRU、`max-age` 失効、`+scrollback-gc` サブコマンド、ペイン単位の非保存トグル | — | 独立して後追い可 | + +**Stage 0 は Stage 1 が無くても価値がある**。逆は成り立たない(Stage 1 だけ入れて +既定 OFF のままだと、誰も気づかない)。順序は固定。 + +--- + +## 8. 設定キー(すべて noa 拡張 — Ghostty 非互換。importer で noa-only として扱う) + +| キー | 値 | 既定 | +|---|---|---| +| `scrollback-persist` | `never` \| `tail` | `never` | +| `scrollback-persist-limit` | バイト | `1048576` | +| `scrollback-persist-total-limit` | バイト | `67108864` | +| `scrollback-persist-max-age-days` | 整数(日) | `7` | +| `scrollback-persist-encrypt` | bool | `false`(Stage 2・未実装) | + +`+show-config` への露出と、`theme_settings/rows.rs` の `SettingsRowKind::COUNT` +bump を忘れないこと。 + +--- + +## 9. リスク + +| リスク | 影響 | 緩和 | +|---|---|---| +| 機微情報がディスクに残る | 高 | 既定 OFF、`0600`/`0700`、バックアップ除外、Stage 3 で暗号化。有効化時に一度だけ警告を出す | +| 復元時のウィンドウ幅が保存時と違う | 中 | `Row::wrapped` と `cols` を保存し、`screen/reflow.rs` に通す | +| 終了時のキャプチャで quit が遅くなる | 中 | 上限が効くので最悪ケースが有界。`exiting` は既に persister の flush を待っている | +| ディスクを食い潰す | 中 | 4 層キャップ + 起動時 GC | +| 記録がライブと誤認される | 中 | §5 の記録ビュー。これを削ると元の欠陥に戻る | +| Ghostty パリティからの逸脱 | 低 | 既定 OFF + Parity Map に明示的逸脱として記載 | + +--- + +## 10. 決定事項 + +| # | 決定 | 根拠 | 状態 | +|---|---|---|---| +| **DEC-1** | 既定は `scrollback-persist = never`(opt-in) | Q1 回答。Ghostty パリティ維持。永続化は脅威モデルを変えるので既定 ON にしない | **確定** | +| **DEC-2** | 保存対象は**履歴全体の末尾テール**。1 画面モード(`screen`)は作らない | Q2 回答。量は `scrollback-persist-limit` 一本で制御でき、2 経路を持つ理由がない(`screen` は「小さい `tail`」に過ぎない) | **確定** | +| **DEC-3** | v1 の保護は FileVault + `0700`/`0600` + バックアップ除外。Keychain 暗号化は Stage 2 で opt-in | Q3 未回答につき §4.6 の推奨値を採用。A-3(個人所有の単独ユーザー Mac)が前提 | 仮決め | +| **DEC-4** | 復元された記録は**残す**。ライブ出力が来ても消えない(通常の scrollback と同じ寿命)。破棄はコマンドパレット `Discard restored history` と `clear` から明示的に行う | Q4 未回答。勝手に消える方が驚きが大きく、「読みに戻れる」ことがそもそもの要望 | 仮決め | +| **DEC-5** | 保存単位は**ペイン単位**(split の各 leaf が独立) | Q5 未回答。タブ単位に縮めると「復元されたのに片方の split だけ空」という、元の欠陥(D-1)と同型の非対称が生まれる。総量は §4.3 の 4 層キャップで抑える | 仮決め | + +DEC-3 / 4 / 5 は仮決め。実装着手までに異議があれば差し替える。 + +--- + +## 11. 手動検証(Stage 1) + +0. 既定(キー未設定)のまま起動 → 記録は復元されず、復元されたタブに Stage 0 の + 告知行が出る。ここから有効化方法が読み取れること。 +1. `scrollback-persist = tail` を設定し `cargo run -p noa`。数タブ開き、片方で + ビルドエラーを出したうえで**十分にスクロールさせる**。`cmd+q` → 再起動。 +2. そのタブに、記録セパレータ + 左ガター付きでエラー本文が色付きで復元される。 + 下にライブプロンプトがある。上にスクロールすると上限ぶんの履歴が遡れる。 + 復元領域が検索(`cmd+f`)にヒットする。 +3. ウィンドウ幅を変えて再起動 → 折返しが正しく reflow される。 +4. `scrollback-persist = never` に戻して再起動 → 記録は復元されず、 + `~/Library/Application Support/noa/scrollback/` が空(または未作成)。 + `Discard restored history` で記録領域だけが消え、ライブ側は無傷。 +5. スナップショットファイルを壊す → 正常起動、記録なし、エラーなし。 +6. scratch terminal を開いて終了 → そのペインの記録は作られない。 +7. `ls -l@` でパーミッション `0600` とバックアップ除外属性を確認。 + +--- + +## 12. 実装時に確定した逸脱(提案からの差分) + +提案どおりに作れなかった/作らなかった点。理由込みで残す。 + +| # | 提案 | 実装 | 理由 | +|---|---|---|---| +| **D-1** | packed scrollback 表現をそのまま直列化 | materialize 済み `Row`/`Cell` を独自形式で直列化 | packed 表現の `GraphemeId` / `HyperlinkId` がプロセススコープ。ディスクに書くと次のプロセスで別テキストにデコードされる(§4.1) | +| **D-2** | 記録領域の左に **1px** の縦罫 | 第 0 桁の**セル 1 個ぶん**の背景矩形 | サブセル幅の矩形は新しい GPU プリミティブ(シェーダ/頂点レイアウト変更)を要求する。`CLAUDE.md` の GPU 罠の項が明示的に警告している領域であり、境界の可読性はセル粒度でも損なわれない。1 行あたり矩形 1 個で、選択の背景と同じ既存パスに乗る | +| **D-3** | ビューポートが記録領域に掛かっている間の「記録」バッジ | **未実装** | バッジはネイティブ AppKit オーバーレイ経路(scratch バッジと同じ)で、境界を示すという目的に対して費用が見合わない。セパレータ行は自分自身にラベル(`record · saved <時刻> · live below`)を持ち、記録領域に入るには必ずその行を跨ぐので、区別は成立している。バッジは追認が取れたら follow-up | +| **D-4** | `scrollback-persist-max-age = 7d` | `scrollback-persist-max-age-days = 7` | noa の config には suffix 構文がどこにも無い(`cursor-stop-blinking-after` も `background-image-interval` も素の整数秒)。単位はキー名で表すのが既存の規約 | +| **D-5** | per-pane 上限は「圧縮後バイト」 | 「エンコード後・deflate 前バイト」 | 圧縮後で予算を切るには、切って圧縮して溢れたらやり直す必要がある。圧縮前は一度で確定でき、圧縮は必ず約束より小さくする方向にしか効かない | +| **D-6** | `SESSION_VERSION` を 2 へ | **3** へ | 2 は remote ペインのメタデータで既に使用済みだった | +| **D-8** | (提案に無し) | 記録の破棄は既存 scrollback を作り直して実装 | ページは packed 後は不変で、ページ単位でしか evict されない。`erase_display(Scrollback)` はライブ履歴まで消してしまうため、残存行を materialize して詰め直す O(n) 経路を足した。ユーザー起点の稀なコマンドなので、bulk-output のホットパスには触っていない | + +| **D-8** | 記録領域のガター範囲は常に有効 | 座標が再採番されたら**マーキングだけ捨てる** | `record_rows`/`annotation_row` は絶対行のキャッシュで、列数 reflow と scrollback clear は空間ごと振り直す。`Terminal::grid_coordinate_generation` はまさにその2つで bump し、通常の eviction では bump しない(既存テストが保証)ので、これを突き合わせるのが完全な陳腐化判定になる。不一致時はガターと `skip_row` と Discard を止める — 誤った行を消すより、印が消えるほうが遥かに軽い。境界そのものはセパレータ**行**(=内容)なので reflow を跨いで残り、AC-3 は維持される | +| **D-9** | (提案に無し) | 永続化リンクは 4 KiB で打ち切る | OSC 8 のペイロード上限はパーサの 12 MiB。リンクは side table に載るため行単位の予算計算からは見えず、**URI 1本でスナップショット全体の上限を突破**できた。これより長い URL は誰も踏まないので、セルの文字は残しリンクだけ落とす | + +| **D-10** | `scrollback-persist-max-age-days` は記録内容の古さを縛る | **ファイルの古さ**を縛る | 使い続けているペインは終了のたびに再キャプチャされ、復元した記録を持ち越すのでファイルの mtime は更新され続ける。内容の年齢を縛るには行ごとの時刻が要り、形式変更になる。キーの意味を `CONFIGURATION.md` で明示する側に倒した。量を縛るのは `scrollback-persist-limit` の仕事 | +| **D-11** | 復元したペインは保存時のキーを引き継ぐ | 復元時に**新しいキーを発番**し、旧ファイルは最初のキャプチャ成立時に削除 | 同じセッションを2つの noa が復元すると同一ファイルを奪い合う。発番し直せば衝突しない。旧ファイルの削除を「復元時」ではなく「自分の最初の書き込みが終わった時」にしてあるのは、その間にクラッシュしても戻る記録が残るようにするため。定常状態でペインあたり 1 ファイル(実機4世代で確認) | + +| **D-12** | 鍵は `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | 非同期化(`kSecAttrSynchronizable = false`)+ login keychain 既定のアクセシビリティ | `security-framework` 3.7 は `kSecAttrAccessible` を公開していない(`PasswordOptions` の setter に無い)。login keychain の generic password は元々ロック解除時のみ読め、同期を明示的に切ったので**意図は満たしている**。厳密な定数指定は deprecated な生 query フィールドを触る必要があり、それに見合わない | + +### Stage 2 の実装状況 + +- ✅ `scrollback-persist-encrypt`(AES-256-GCM + login keychain)。外側コンテナ + `NOAEN` で `noa-grid` の形式を包むので、grid 側は暗号を一切知らない。読み取りは + **設定値ではなくファイル自身**で分岐するため、ON/OFF 切り替えで既存ファイルが + 読めなくなることはない。鍵が取れないときは平文で書かずに**書き込みを見送る**。 +- ✅ kitty graphics / sixel のプレースホルダ置換(`[image]`)。画像本体は永続化 + されず次プロセスには存在しないので、そのまま保存すると豆腐の列が復元される。 + 黙って落とすと「そこに何かあった」ことまで消える。 +- ⛔ **記録バッジは未実装のまま**。`draw_toast_card` は位置指定を取らない中央 + トーストで、常設バッジには新規の描画・配置コードが要る。**画面収録権限が失効 + していて目視検証できない状態で、GPU 罠の多い描画層に検証不能な視覚要素を足す + のは割に合わない**という判断。境界表示自体はセパレータ行で成立している(D-3)。 +- ⛔ 起動時の**prepend 自体**はメインスレッドのまま。読み込みと inflate は並列 + 前倒ししたが、scrollback への挿入は `Terminal` を触るので残っている。 + +### レビューで却下した指摘 + +- **末尾空白の刈り取りを `Row::occupied()` に変える**: 一見正しいが、復元行は + `Row::from_cells` 由来で `occ == cells.len()`(保守的なウォーターマーク)になる。 + `occupied()` を基準にすると復元済みペインの再キャプチャで全桁がエンコードされ、 + スナップショットが膨らむ。既定値のセルは表示上も意味上も不活性なので、値比較の + ままとする。 + +### GUI 目視確認が必要な項目 + +自動テストで押さえられていない部分。実機で見ること: + +1. 記録セパレータの見え方(罫の連続性・時刻の可読性・テーマ light/dark 双方)。 +2. 第 0 桁ガターの色が、記録領域の文字を読みにくくしていないこと(特に light テーマ)。 +3. 復元直後のビューポート位置(記録の末尾=ライブ直前が見えていること)。 +4. Settings の `Persist Scrollback` 行の表示とバッジ種別。 +5. 幅を変えて再起動したときの reflow の見え方(§11 手順 3)。 diff --git a/docs/specs/session-restore.md b/docs/specs/session-restore.md index 9df8c9d..6e513f6 100644 --- a/docs/specs/session-restore.md +++ b/docs/specs/session-restore.md @@ -2,7 +2,15 @@ Ghostty-parity Phase 6 item: persist and restore the window / tab / split **topology** and each pane's **cwd** across launches. Terminal *contents* are -never restored (matching Ghostty). +not restored by this feature (matching Ghostty). + +Restoring the layout but not the output is a promise the layout makes and the +contents break, so the two are wired together deliberately: a pane restored +with no record shows a one-line notice saying its output was not saved and +naming the key that would change that. Contents are opt-in through the +separate `scrollback-persist` key — see +[`scrollback-persistence.md`](scrollback-persistence.md), which also owns the +`scrollback` field this document's leaf schema carries. ## `window-save-state` @@ -21,8 +29,10 @@ Surfaced by `+show-config` and accepted by the Ghostty config importer `/noa/session.json` (`noa-config::session_state_path`); on macOS `` is `~/Library/Application Support`. Written atomically (temp file + -rename). A versioned, hand-written JSON document (`SESSION_VERSION = 1`; the -crate has no serde, matching the hand-written config parser). Schema: +rename). A versioned, hand-written JSON document (`SESSION_VERSION = 3`; the +crate has no serde, matching the hand-written config parser). Version 2 added +per-leaf `remote` metadata, version 3 the per-leaf `scrollback` snapshot key. +Schema: ```json { @@ -35,8 +45,10 @@ crate has no serde, matching the hand-written config parser). Schema: "tabs": [ { "focused_leaf": 0, "split": { "type": "split", "orientation": "horizontal", "ratio": 0.5, - "first": { "type": "leaf", "cwd": "/a" }, - "second": { "type": "leaf", "cwd": null } } } + "first": { "type": "leaf", "cwd": "/a", + "remote": null, "scrollback": "3f1c8a02b7d94e56" }, + "second": { "type": "leaf", "cwd": null, + "remote": null, "scrollback": null } } } ] } ] @@ -49,6 +61,10 @@ crate has no serde, matching the hand-written config parser). Schema: in pre-order. - A **leaf**'s `cwd` is the OSC 7 cwd (8635cdb) when it still resolves to a local directory, else `null` (the pane then opens in the process cwd). +- A **leaf**'s `scrollback` names its persisted snapshot under + `/noa/scrollback/`, or `null` when `scrollback-persist` is off or + the pane has nothing saved. Keys are validated as 16 lowercase hex digits on + read: this file is user-writable and the key is interpolated into a path. A missing, unreadable, malformed, or version-mismatched file parses to "no session" — startup is **never** blocked by session state.