diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8a1c0..573bddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.1.5 + +- Add `--record-stdin` so `twatch` can record terminal output from stdin without spawning a child PTY +- Add `--size WIDTH,HEIGHT` for fixed-size stdin recording and document the tmux-oriented JSONL recording flow +- Store `--record-stdin` traces as checkpoints plus deltas instead of writing a full snapshot on every frame +- Compact in-memory cell symbols, compress delta history when `-C` is enabled, and raise the default checkpoint interval to reduce long-running memory pressure +- Add transparent `.jsonl.gz` log read/write support and make the tmux plugin default to `archive` compression so active pane replay stays faster +- Compress large snapshot and delta payloads inside JSONL records so plain `.jsonl` traces also shrink without hiding frame metadata +- Open replay after preloading the first 256 frames and continue loading the rest in the background to reduce large-log startup delay +- Spill older active `--record-stdin` frames into a compact sidecar during long-running sessions so live `.jsonl` logs grow more slowly +- Add tunable `--record-stdin-spill-every` and `--record-stdin-spill-retain` controls, and store deltas in a more compact run/style-table form to shrink active tmux logs further +- Treat `-L 0` as unlimited history retention so tmux replay can avoid trimming large recordings on startup +- Write active JSONL records in a more compact array-based format while keeping backward-compatible readers for older object-based logs + +This release is focused on tmux-oriented recording and replay scalability. +It adds the first backend-oriented building block for `tmux pipe-pane` style integrations by letting `twatch` ingest terminal output from stdin and store it as replayable traces. + +Notes: + +- `--record-stdin` requires `--logfile` and cannot be combined with `--batch`, `--replay`, or a child command +- Replay readers remain backward-compatible with older object-based JSONL logs + ## 0.1.4 - Add customizable `twatch` keymaps with `-K/--keymap KEY=ACTION`, including pane-specific navigation, snapshot actions, pause control, and app-input mode switching diff --git a/Cargo.lock b/Cargo.lock index 5ff0ef6..d328313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,6 +1266,25 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1660,9 +1679,10 @@ dependencies = [ [[package]] name = "twatch" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", + "base64", "clap", "crossterm 0.28.1", "flate2", @@ -1670,6 +1690,7 @@ dependencies = [ "portable-pty", "ratatui", "regex", + "rmp-serde", "serde", "serde_json", "shell-words", diff --git a/Cargo.toml b/Cargo.toml index 4745ebe..b60d883 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "twatch" -version = "0.1.4" +version = "0.1.5" edition = "2024" description = "twatch - record, rewind, and diff terminal UI screens." license = "MIT" @@ -12,12 +12,14 @@ homepage = "https://github.com/blacknon/twatch" [dependencies] anyhow = "1.0" +base64 = "0.22" clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" flate2 = "1.1" portable-pty = "0.9" ratatui = "0.30" regex = "1.12" +rmp-serde = "1.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" shell-words = "1.1" diff --git a/README.md b/README.md index 9095cb2..042115d 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ Options: --replay Replay a saved JSONL trace in read-only mode + --record-stdin + Record terminal output from stdin without spawning a child PTY + --size + Use a fixed terminal size for stdin recording --screenshot-dir [default: /tmp] --screenshot-format @@ -116,7 +120,7 @@ Options: -L, --limit [default: 500] --checkpoint-interval - [default: 12] + [default: 120] --debug Show debug diagnostics in the interactive UI -h, --help @@ -313,6 +317,15 @@ This is useful for debugging long-running screens outside the live UI. twatch --logfile ./twatch.jsonl htop ``` +### Stdin recorder + +Record terminal output from stdin into a JSONL trace without launching a child PTY. +This is the backend intended for tmux `pipe-pane` style integrations. + +```bash +tmux pipe-pane -o "twatch --record-stdin --size '#{pane_width},#{pane_height}' --logfile '$HOME/.local/state/twatch/tmux/pane-12.jsonl'" +``` + ### Replay mode Open a saved JSONL trace without launching a child PTY. diff --git a/src/app/config.rs b/src/app/config.rs index 8f2a5e3..e5ca613 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -31,6 +31,7 @@ pub(super) struct AppConfig { pub aftercommand_runtime: Option, pub command_display: String, pub debug: bool, + pub hide_header: bool, pub keymap: Vec, pub child_bindings: Vec, } @@ -59,7 +60,7 @@ impl AppConfig { Ok(Self { child_pause_supported, diff_mode: cli.differences.into(), - history_limit: cli.limit.max(1), + history_limit: cli.limit, checkpoint_interval: cli.checkpoint_interval.max(1), compress: cli.compress, logfile: cli.replay.clone().or(cli.logfile.clone()), @@ -84,6 +85,7 @@ impl AppConfig { }), command_display, debug: cli.debug, + hide_header: cli.hide_header, keymap, child_bindings, }) diff --git a/src/app/filter.rs b/src/app/filter.rs index 92959dc..2985687 100644 --- a/src/app/filter.rs +++ b/src/app/filter.rs @@ -53,7 +53,7 @@ impl App { } } - fn current_snapshot_matches_filter(&self) -> bool { + pub(super) fn current_snapshot_matches_filter(&self) -> bool { let Some(snapshot) = &self.current_snapshot else { return false; }; diff --git a/src/app/history_ops.rs b/src/app/history_ops.rs index 65d8361..25f4523 100644 --- a/src/app/history_ops.rs +++ b/src/app/history_ops.rs @@ -3,10 +3,15 @@ // that can be found in the LICENSE file. use anyhow::Result; +use std::path::Path; -use super::{App, FocusPane}; +use super::{App, AppHistoryMetadata, FocusPane, ReplayReplaceState}; use crate::history::{HistoryMetadata, HistoryStore}; -use crate::logging::{LogRecord, append_record, load_records}; +use crate::logging::{ + LogRecord, LogRecordStream, append_delta_record, load_recent_records_from_cache, + load_recent_records_from_plain_path, load_recent_records_from_single_path, load_records, + load_records_from_single_path, replay_path_info, +}; impl App { pub(super) fn delete_selected_history(&mut self) -> Result<()> { @@ -68,6 +73,9 @@ impl App { } pub(super) fn trim_history(&mut self) -> Result<()> { + if self.limit == 0 { + return Ok(()); + } let total = self.history.len(); if total <= self.limit { return Ok(()); @@ -107,7 +115,193 @@ impl App { return Ok(()); }; - for record in load_records(path)? { + self.apply_loaded_records(load_records(path)?)?; + + Ok(()) + } + + pub(super) fn load_history_from_replay_log_prefetch(&mut self) -> Result<()> { + let Some(path) = self.logfile.clone() else { + return Ok(()); + }; + if !Path::new(&path).exists() { + return Ok(()); + } + + let replay_info = replay_path_info(&path)?; + let replay_uses_manifest = path.ends_with(".replay.json"); + + if !replay_info.spill_paths.is_empty() { + let latest_spill_records = replay_info + .spill_paths + .last() + .filter(|spill_path| { + std::fs::metadata(spill_path) + .ok() + .map(|meta| meta.len() <= super::state::REPLAY_SYNC_LATEST_SPILL_MAX_BYTES) + .unwrap_or(false) + }) + .map(|spill_path| load_records_from_single_path(spill_path)) + .transpose()? + .unwrap_or_default(); + let cached = load_recent_records_from_cache( + &path, + super::state::replay_prefetch_record_count(), + )?; + if !cached.is_empty() { + let mut loaded = latest_spill_records; + loaded.extend(cached); + self.apply_loaded_records(loaded)?; + if replay_uses_manifest { + self.replay_reload_path = Some(path); + self.replay_loading = true; + self.ui.status_message = Some(format!( + "replay loading recent cache: {} history frames ready, older history loading in background", + self.metadata.len() + )); + } else { + self.replay_deferred_path = Some(path); + self.replay_loading = false; + self.ui.status_message = Some(format!( + "replay loaded recent cache: {} history frames ready, load older history on demand", + self.metadata.len() + )); + } + return Ok(()); + } + + let total_spill_size_bytes: u64 = replay_info + .spill_paths + .iter() + .filter_map(|spill_path| std::fs::metadata(spill_path).ok().map(|meta| meta.len())) + .sum(); + if total_spill_size_bytes <= super::state::REPLAY_SYNC_SMALL_SPILL_MAX_BYTES { + self.apply_loaded_records(load_records(&path)?)?; + self.ui.status_message = Some(format!( + "replay loaded: {} frames", + self.loaded_frame_count() + )); + return Ok(()); + } + let loaded = load_recent_records_from_single_path( + &path, + super::state::replay_prefetch_record_count(), + )?; + let mut prefetched = latest_spill_records; + prefetched.extend(loaded); + self.apply_loaded_records(prefetched)?; + if replay_uses_manifest { + self.replay_reload_path = Some(path); + self.replay_loading = true; + self.ui.status_message = Some(format!( + "replay loading recent tail: {} history frames ready, older history loading in background", + self.metadata.len() + )); + } else { + self.replay_deferred_path = Some(path); + self.replay_loading = false; + self.ui.status_message = Some(format!( + "replay loaded recent tail: {} history frames ready, load older history on demand", + self.metadata.len() + )); + } + return Ok(()); + } + + if path.ends_with(".jsonl") { + let loaded = load_recent_records_from_plain_path( + &path, + super::state::replay_prefetch_record_count(), + )?; + if !loaded.is_empty() { + self.apply_loaded_records(loaded)?; + self.replay_reload_path = Some(path); + self.replay_loading = true; + self.ui.status_message = Some(format!( + "replay loading recent tail: {} history frames ready, older history loading in background", + self.metadata.len() + )); + return Ok(()); + } + } + + let mut stream = LogRecordStream::open(&path)?; + let mut loaded = Vec::new(); + + while loaded.len() < super::state::replay_prefetch_record_count() { + let Some(record) = stream.next_record()? else { + break; + }; + loaded.push(record); + } + + self.apply_loaded_records(loaded)?; + + if stream.has_more()? { + self.replay_loader = Some(stream); + self.replay_loading = true; + self.ui.status_message = Some(format!( + "replay loading: {} history frames ready, more loading in background", + self.metadata.len() + )); + } + + Ok(()) + } + + pub(super) fn replace_loaded_replay_state(&mut self, state: ReplayReplaceState) -> Result<()> { + let preserve_follow_latest = self.follow_latest; + let preserve_frame_seq = if self.follow_latest { + self.current_metadata + .as_ref() + .map(|metadata| metadata.frame_seq) + } else { + self.metadata + .get(self.selected_index) + .map(|metadata| metadata.frame_seq) + }; + + self.history = state.history; + self.metadata = state.metadata; + self.current_snapshot = state.current_snapshot; + self.current_metadata = state.current_metadata; + self.filtered.clear(); + self.selected_index = 0; + self.follow_latest = preserve_follow_latest; + self.invalidate_view_cache(); + + if self.limit > 0 && self.metadata.len() > self.limit { + self.trim_history()?; + } + + if self.current_snapshot.is_some() { + if preserve_follow_latest { + self.selected_index = self.history.len().saturating_sub(1); + self.follow_latest = true; + } + self.rebuild_filter()?; + } + + if !preserve_follow_latest { + if let Some(frame_seq) = preserve_frame_seq + && let Some(index) = self + .metadata + .iter() + .position(|metadata| metadata.frame_seq == frame_seq) + { + self.follow_latest = false; + self.selected_index = index; + self.rebuild_filter()?; + } + } + + Ok(()) + } + + pub(super) fn apply_loaded_records(&mut self, records: Vec) -> Result<()> { + let was_follow_latest = self.follow_latest; + + for record in records { let (snapshot, metadata, changed) = record.into_parts(); self.archive_current_snapshot()?; let metadata = self.complete_metadata( @@ -120,19 +314,25 @@ impl App { self.set_current_snapshot(snapshot, metadata); } - if self.metadata.len() > self.limit { + if self.limit > 0 && self.metadata.len() > self.limit { self.trim_history()?; } if self.current_snapshot.is_some() { - self.selected_index = self.history.len().saturating_sub(1); - self.follow_latest = true; + if was_follow_latest { + self.selected_index = self.history.len().saturating_sub(1); + self.follow_latest = true; + } self.rebuild_filter()?; } Ok(()) } + pub(super) fn loaded_frame_count(&self) -> usize { + self.metadata.len() + usize::from(self.current_snapshot.is_some()) + } + pub(super) fn append_log_record(&self) -> Result<()> { if self.replay_mode { return Ok(()); @@ -147,7 +347,13 @@ impl App { return Ok(()); }; - append_record( + let previous_snapshot = if self.history.is_empty() { + None + } else { + self.history.snapshot(self.history.len() - 1)? + }; + + append_delta_record( path, &LogRecord { label: metadata.label.clone(), @@ -166,6 +372,44 @@ impl App { resize_source: metadata.resize_source.clone(), snapshot: snapshot.clone(), }, + previous_snapshot.as_ref(), + self.checkpoint_interval as u64, ) } } + +pub(super) fn build_replay_replace_state( + path: &str, + checkpoint_interval: usize, + compress: bool, +) -> Result { + let mut loader = LogRecordStream::open(path)?; + let mut history = HistoryStore::new(checkpoint_interval, compress); + let mut metadata = Vec::new(); + let mut current_snapshot = None; + let mut current_metadata: Option = None; + + while let Some(record) = loader.next_record()? { + let (snapshot, record_metadata, changed) = record.into_parts(); + + if let (Some(previous_snapshot), Some(previous_metadata)) = + (current_snapshot.take(), current_metadata.take()) + { + history.push(previous_snapshot, previous_metadata.to_history_metadata())?; + metadata.push(previous_metadata); + } + + current_snapshot = Some(snapshot); + current_metadata = Some(AppHistoryMetadata::from_history_metadata(HistoryMetadata { + changed, + ..record_metadata + })); + } + + Ok(ReplayReplaceState { + history, + metadata, + current_snapshot, + current_metadata, + }) +} diff --git a/src/app/input/history_nav.rs b/src/app/input/history_nav.rs index f6daf48..86130fb 100644 --- a/src/app/input/history_nav.rs +++ b/src/app/input/history_nav.rs @@ -125,6 +125,17 @@ impl App { return; } + if offset > 0 + && next_position as usize >= self.filtered.len().saturating_sub(1) + && !self.replay_loading + && self.replay_deferred_path.is_some() + { + if self.start_deferred_replay_loader() { + self.ui.status_message = + Some("loading older replay history in background".to_string()); + } + } + let next = usize::min( next_position as usize, self.filtered.len().saturating_sub(1), diff --git a/src/app/input/key.rs b/src/app/input/key.rs index 3b2c034..d7be6ba 100644 --- a/src/app/input/key.rs +++ b/src/app/input/key.rs @@ -112,6 +112,7 @@ impl App { } } (KeyCode::Char('h'), _) => self.ui.show_help = true, + (KeyCode::Char('H'), _) => self.toggle_header_visibility(), (KeyCode::Char('I'), _) => { self.ui.show_inspector = !self.ui.show_inspector; self.clamp_inspector_to_snapshot(); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index a44a2f7..d966fdf 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -105,6 +105,15 @@ impl App { }; } + fn toggle_header_visibility(&mut self) { + self.hide_header = !self.hide_header; + self.ui.status_message = Some(if self.hide_header { + "header hidden".to_string() + } else { + "header shown".to_string() + }); + } + fn should_ignore_mouse_ghost_key(&self, key: KeyEvent) -> bool { let Some(last_mouse_scroll_input) = self.last_mouse_scroll_input else { return false; @@ -319,6 +328,7 @@ impl App { self.ui.show_inspector = !self.ui.show_inspector; self.clamp_inspector_to_snapshot(); } + KeyAction::ToggleHeader => self.toggle_header_visibility(), KeyAction::SaveSnapshot => self.save_snapshot()?, KeyAction::CycleSnapshotFormat => self.cycle_screenshot_format(), KeyAction::ScrollLeft => { diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index b0d4170..29c118c 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -24,7 +24,7 @@ impl App { MouseEventKind::ScrollUp | MouseEventKind::ScrollDown ) && self.selected_snapshot_has_mouse_reporting() { - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(false); @@ -41,14 +41,14 @@ impl App { }; return self.scroll_main_screen_view(delta); } - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } } return Ok(false); } - if mouse.row < 2 { + if mouse.row < self.header_rows() { return Ok(false); } @@ -69,7 +69,7 @@ impl App { return Ok(true); } else if self.follow_latest && self.selected_snapshot_has_mouse_reporting() { self.ui.focus = FocusPane::Watch; - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(previous_focus != self.ui.focus); @@ -79,7 +79,7 @@ impl App { return Ok(changed || previous_focus != self.ui.focus); } else if self.follow_latest { self.ui.focus = FocusPane::Watch; - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(previous_focus != self.ui.focus); @@ -97,7 +97,7 @@ impl App { return Ok(true); } else if self.follow_latest && self.selected_snapshot_has_mouse_reporting() { self.ui.focus = FocusPane::Watch; - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(previous_focus != self.ui.focus); @@ -107,7 +107,7 @@ impl App { return Ok(changed || previous_focus != self.ui.focus); } else if self.follow_latest { self.ui.focus = FocusPane::Watch; - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(previous_focus != self.ui.focus); @@ -130,7 +130,7 @@ impl App { return Ok(true); } else if self.follow_latest { self.ui.focus = FocusPane::Watch; - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(previous_focus != self.ui.focus); @@ -143,7 +143,7 @@ impl App { MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { if !over_history && self.follow_latest { self.ui.focus = FocusPane::Watch; - if self.source.send_mouse(mouse, 2)? { + if self.source.send_mouse(mouse, self.header_rows())? { self.record_child_mouse_event(mouse); } return Ok(previous_focus != self.ui.focus); @@ -163,11 +163,11 @@ impl App { fn history_visible_rows(&self) -> usize { crossterm::terminal::size() - .map(|(_, height)| usize::from(height.saturating_sub(4))) + .map(|(_, height)| usize::from(height.saturating_sub(self.header_rows() + 2))) .unwrap_or(0) } fn history_content_top(&self) -> u16 { - 3 + self.header_rows() + 1 } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 834af31..e70d93b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ use std::cell::RefCell; use std::collections::VecDeque; use std::path::PathBuf; +use std::sync::mpsc; use std::time::{Duration, Instant}; use crate::aftercommand::AfterCommandRuntime; @@ -13,6 +14,7 @@ use crate::cli::{DiffModeArg, ScreenshotFormatArg}; use crate::history::HistoryMetadata; use crate::history::HistoryStore; use crate::keymap::KeyBinding; +use crate::logging::{LogRecord, LogRecordStream}; use crate::runner::FrameSource; use crate::screen::ScreenSnapshot; use crate::screenshot::ScreenshotFormat; @@ -35,6 +37,24 @@ enum AppEvent { Terminal(crossterm::event::Event), SourceUpdated, SourceClosed(Option), + ReplayRecordsLoaded(Vec), + ReplayStateReplaced(ReplayReplaceState), + ReplayLoadFinished, + ReplayLoadFailed(String), +} + +enum ReplayLoadMessage { + Records(Vec), + ReplaceState(Box), + Finished, + Failed(String), +} + +struct ReplayReplaceState { + history: HistoryStore, + metadata: Vec, + current_snapshot: Option, + current_metadata: Option, } enum LoopControl { @@ -287,6 +307,7 @@ impl InputTraceEvent { pub struct App { pub debug: bool, + pub hide_header: bool, pub paused: bool, pub child_paused: bool, pub child_pause_supported: bool, @@ -318,6 +339,12 @@ pub struct App { keymap: Vec, child_bindings: Vec, source: Box, + replay_loader: Option, + replay_reload_path: Option, + replay_deferred_path: Option, + replay_event_tx: Option>, + replay_loader_rx: Option>, + replay_loading: bool, last_mouse_input: Option, last_mouse_scroll_input: Option, pending_mouse_escape: Option, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index f16b04d..5d0455d 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -29,6 +29,10 @@ impl App { } }); + self.replay_event_tx = Some(tx.clone()); + self.start_replay_loader(); + self.attach_replay_loader_forwarder(); + if let Some(update_rx) = self.source.take_update_receiver() { let update_tx = tx.clone(); let pending_source_update = Arc::new(AtomicBool::new(false)); @@ -70,14 +74,15 @@ impl App { fn capture_terminal_size(&mut self, terminal: &DefaultTerminal) -> Result<()> { let size = terminal.size()?; - self.capture(size.width, size.height.saturating_sub(2)) + self.capture(size.width, self.content_height(size.height)) } fn resize_and_capture(&mut self, width: u16, height: u16) -> Result<()> { - self.note_resize_event(width, height.saturating_sub(2), "terminal"); - self.source.resize(width, height.saturating_sub(2))?; + let content_height = self.content_height(height); + self.note_resize_event(width, content_height, "terminal"); + self.source.resize(width, content_height)?; if !self.paused { - self.capture(width, height.saturating_sub(2))?; + self.capture(width, content_height)?; } Ok(()) } @@ -90,7 +95,7 @@ impl App { ) -> Result<()> { if self.current_snapshot.is_none() { let size = terminal.size()?; - if let Err(err) = self.capture(size.width, size.height.saturating_sub(2)) { + if let Err(err) = self.capture(size.width, self.content_height(size.height)) { if self.is_source_closed_error(&err) { self.source.terminate().ok(); return Ok(()); @@ -139,6 +144,27 @@ impl App { } break; } + Ok(AppEvent::ReplayRecordsLoaded(records)) => { + self.apply_loaded_records(records)?; + needs_redraw = true; + } + Ok(AppEvent::ReplayStateReplaced(state)) => { + self.replace_loaded_replay_state(state)?; + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFinished) => { + self.replay_loading = false; + self.ui.status_message = Some(format!( + "replay loaded: {} frames", + self.loaded_frame_count() + )); + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFailed(err)) => { + self.replay_loading = false; + self.ui.status_message = Some(format!("replay load failed: {err}")); + needs_redraw = true; + } Err(RecvTimeoutError::Timeout) => { if !self.paused && self.source.has_pending_update() { if let Some(flag) = &pending_source_update { @@ -159,6 +185,27 @@ impl App { LoopControl::Break => break, }, Ok(AppEvent::SourceUpdated) | Ok(AppEvent::SourceClosed(_)) => {} + Ok(AppEvent::ReplayRecordsLoaded(records)) => { + self.apply_loaded_records(records)?; + needs_redraw = true; + } + Ok(AppEvent::ReplayStateReplaced(state)) => { + self.replace_loaded_replay_state(state)?; + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFinished) => { + self.replay_loading = false; + self.ui.status_message = Some(format!( + "replay loaded: {} frames", + self.loaded_frame_count() + )); + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFailed(err)) => { + self.replay_loading = false; + self.ui.status_message = Some(format!("replay load failed: {err}")); + needs_redraw = true; + } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } diff --git a/src/app/state.rs b/src/app/state.rs index d2ca8e9..c87de9f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -3,18 +3,29 @@ // that can be found in the LICENSE file. use anyhow::Result; +use std::sync::mpsc; use super::config::AppConfig; -use super::{App, AppHistoryMetadata, FilterMode, InputMode}; +use super::history_ops::build_replay_replace_state; +use super::{App, AppEvent, AppHistoryMetadata, FilterMode, InputMode, ReplayLoadMessage}; use crate::cli::Cli; use crate::history::HistoryStore; use crate::runner::FrameSource; +pub(super) const REPLAY_INITIAL_LOAD_FRAMES: usize = 16; +pub(super) const REPLAY_SYNC_SMALL_SPILL_MAX_BYTES: u64 = 128 * 1024; +pub(super) const REPLAY_SYNC_LATEST_SPILL_MAX_BYTES: u64 = 1024 * 1024; + +pub(super) fn replay_prefetch_record_count() -> usize { + REPLAY_INITIAL_LOAD_FRAMES.saturating_add(1) +} + impl App { pub fn new(cli: &Cli, source: Box) -> Result { let config = AppConfig::from_cli(cli, source.supports_child_pause())?; let mut app = Self { debug: config.debug, + hide_header: config.hide_header, paused: false, child_paused: false, child_pause_supported: config.child_pause_supported, @@ -46,6 +57,12 @@ impl App { keymap: config.keymap, child_bindings: config.child_bindings, source, + replay_loader: None, + replay_reload_path: None, + replay_deferred_path: None, + replay_event_tx: None, + replay_loader_rx: None, + replay_loading: false, last_mouse_input: None, last_mouse_scroll_input: None, pending_mouse_escape: None, @@ -57,10 +74,120 @@ impl App { ui: super::UiState::new(), }; - app.load_history_from_log()?; + if config.replay_mode { + app.load_history_from_replay_log_prefetch()?; + } else { + app.load_history_from_log()?; + } Ok(app) } + pub(crate) fn header_rows(&self) -> u16 { + if self.hide_header { 0 } else { 2 } + } + + pub(crate) fn content_height(&self, total_height: u16) -> u16 { + total_height.saturating_sub(self.header_rows()) + } + + pub(super) fn start_replay_loader(&mut self) { + if let Some(path) = self.replay_reload_path.take() { + self.start_replay_replace_loader_for_path(path); + return; + } + + let Some(mut loader) = self.replay_loader.take() else { + return; + }; + let (tx, rx) = mpsc::channel(); + self.replay_loader_rx = Some(rx); + self.replay_loading = true; + std::thread::spawn(move || { + const CHUNK_SIZE: usize = 256; + loop { + let mut chunk = Vec::with_capacity(CHUNK_SIZE); + for _ in 0..CHUNK_SIZE { + match loader.next_record() { + Ok(Some(record)) => chunk.push(record), + Ok(None) => { + if !chunk.is_empty() + && tx.send(ReplayLoadMessage::Records(chunk)).is_err() + { + return; + } + let _ = tx.send(ReplayLoadMessage::Finished); + return; + } + Err(err) => { + let _ = tx.send(ReplayLoadMessage::Failed(err.to_string())); + return; + } + } + } + + if tx.send(ReplayLoadMessage::Records(chunk)).is_err() { + return; + } + } + }); + self.attach_replay_loader_forwarder(); + } + + pub(super) fn start_deferred_replay_loader(&mut self) -> bool { + let Some(path) = self.replay_deferred_path.take() else { + return false; + }; + self.start_replay_replace_loader_for_path(path); + true + } + + fn start_replay_replace_loader_for_path(&mut self, path: String) { + let (tx, rx) = mpsc::channel(); + self.replay_loader_rx = Some(rx); + self.replay_loading = true; + let checkpoint_interval = self.checkpoint_interval; + let compress = self.compress; + std::thread::spawn(move || { + match build_replay_replace_state(&path, checkpoint_interval, compress) { + Ok(state) => { + if tx + .send(ReplayLoadMessage::ReplaceState(Box::new(state))) + .is_err() + { + return; + } + let _ = tx.send(ReplayLoadMessage::Finished); + } + Err(err) => { + let _ = tx.send(ReplayLoadMessage::Failed(err.to_string())); + } + } + }); + self.attach_replay_loader_forwarder(); + } + + pub(super) fn attach_replay_loader_forwarder(&mut self) { + let Some(replay_tx) = self.replay_event_tx.clone() else { + return; + }; + let Some(replay_rx) = self.replay_loader_rx.take() else { + return; + }; + std::thread::spawn(move || { + while let Ok(event) = replay_rx.recv() { + let app_event = match event { + ReplayLoadMessage::Records(records) => AppEvent::ReplayRecordsLoaded(records), + ReplayLoadMessage::ReplaceState(state) => AppEvent::ReplayStateReplaced(*state), + ReplayLoadMessage::Finished => AppEvent::ReplayLoadFinished, + ReplayLoadMessage::Failed(err) => AppEvent::ReplayLoadFailed(err), + }; + if replay_tx.send(app_event).is_err() { + break; + } + } + }); + } + pub(super) fn command_display_from_cli(cli: &Cli) -> String { if let Some(path) = &cli.replay { format!("replay: {path}") @@ -134,10 +261,16 @@ impl App { } pub(super) fn trim_slack(&self) -> usize { + if self.limit == 0 { + return 0; + } self.limit.min(self.checkpoint_interval.max(32)) } pub(super) fn trim_trigger_len(&self) -> usize { + if self.limit == 0 { + return usize::MAX; + } self.limit.saturating_add(self.trim_slack()) } diff --git a/src/app/tests.rs b/src/app/tests.rs index ae9af7c..47fae35 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -2,9 +2,12 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. +use super::history_ops::build_replay_replace_state; use super::{App, FilterMode, FocusPane}; use crate::cli::{Cli, DiffModeArg, ScreenshotFormatArg}; -use crate::logging::{LogRecord, append_record}; +use crate::logging::{ + LogRecord, active_spill_paths, append_delta_record, append_record, compact_active_log, +}; use crate::runner::{CaptureFrame, FrameSource, SourceEvent}; use crate::screen::ScreenSnapshot; use anyhow::Result; @@ -12,7 +15,7 @@ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{KeyEvent, KeyEventState, MouseButton, MouseEvent, MouseEventKind}; use std::fs; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; struct MockSource { @@ -614,6 +617,27 @@ fn history_overlay_row_selection_accounts_for_window_offset() { } } +#[test] +fn limit_zero_keeps_full_history_visible() { + let mut cli = test_cli(); + cli.limit = 0; + let mut app = App::new( + &cli, + Box::new(MockSource::new( + (0..80).map(|i| frame(&format!("{i:04}"), &["x"])).collect(), + )), + ) + .unwrap(); + + for _ in 0..80 { + app.capture(20, 5).unwrap(); + } + + assert_eq!(app.visible_history_start(), 0); + assert_eq!(app.visible_history_len(), app.history_len()); + assert!(app.history_len() >= 79); +} + #[test] fn mouse_passthrough_is_blocked_when_not_following_latest() { let source = MockSource::new(vec![frame("a", &["one"])]); @@ -1373,6 +1397,373 @@ fn replay_mode_loads_existing_log() { let _ = fs::remove_dir_all(dir); } +#[test] +fn replay_mode_prefetches_initial_frames_and_defers_rest() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-prefetch"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + for index in 0..300u64 { + append_record( + cli.replay.as_deref().unwrap(), + &LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(20, 5, &[&format!("line-{index}")]), + }, + ) + .unwrap(); + } + + let app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + + assert_eq!(app.metadata.len(), super::state::REPLAY_INITIAL_LOAD_FRAMES); + assert!(app.current_snapshot.is_some()); + assert!(app.replay_loader.is_none()); + assert!(app.replay_loading); + assert_eq!(app.current_label(), Some("frame-299")); + assert_eq!( + app.ui.status_message.as_deref(), + Some( + "replay loading recent tail: 16 history frames ready, older history loading in background", + ) + ); + + let _ = fs::remove_file(path); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn replay_mode_prefetches_recent_tail_when_spill_exists() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-spill-tail"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + let mut previous = None; + for index in 0..160u64 { + let line = noisy_line(index + 1, 4096); + let record = LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(5000, 5, &[&line]), + }; + append_delta_record( + cli.replay.as_deref().unwrap(), + &record, + previous.as_ref(), + 120, + ) + .unwrap(); + previous = Some(record.snapshot); + } + compact_active_log(cli.replay.as_deref().unwrap(), 8).unwrap(); + + let app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + + assert_eq!(app.current_label(), Some("frame-159")); + assert!(app.metadata.len() >= 7); + assert!(app.current_snapshot.is_some()); + assert!(app.replay_loader.is_none()); + assert!(!app.replay_loading); + assert!( + app.ui + .status_message + .as_deref() + .is_some_and(|message| message.starts_with("replay loaded recent tail: ")) + ); + + let _ = fs::remove_file(&path); + remove_spill_files(&path); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn moving_past_oldest_loaded_history_starts_deferred_replay_load() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-deferred-load"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + let mut previous = None; + for index in 0..160u64 { + let line = noisy_line(index + 1, 4096); + let record = LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(5000, 5, &[&line]), + }; + append_delta_record( + cli.replay.as_deref().unwrap(), + &record, + previous.as_ref(), + 120, + ) + .unwrap(); + previous = Some(record.snapshot); + } + compact_active_log(cli.replay.as_deref().unwrap(), 8).unwrap(); + + let mut app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + + assert!(app.replay_deferred_path.is_some()); + assert!(!app.replay_loading); + + app.follow_latest = false; + app.selected_index = *app.filtered.last().unwrap(); + app.move_down(); + + assert!(app.replay_deferred_path.is_none()); + assert!(app.replay_loading); + assert_eq!( + app.ui.status_message.as_deref(), + Some("loading older replay history in background") + ); + + let _ = fs::remove_file(&path); + remove_spill_files(&path); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn deferred_replay_loader_reconnects_to_runtime_event_channel() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-deferred-forward"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + let mut previous = None; + for index in 0..160u64 { + let line = noisy_line(index + 1, 4096); + let record = LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(5000, 5, &[&line]), + }; + append_delta_record( + cli.replay.as_deref().unwrap(), + &record, + previous.as_ref(), + 120, + ) + .unwrap(); + previous = Some(record.snapshot); + } + compact_active_log(cli.replay.as_deref().unwrap(), 8).unwrap(); + + let mut app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + let (tx, rx) = mpsc::channel(); + app.replay_event_tx = Some(tx); + + app.follow_latest = false; + app.selected_index = *app.filtered.last().unwrap(); + app.move_down(); + + let replaced = rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(matches!(replaced, super::AppEvent::ReplayStateReplaced(_))); + let finished = rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(matches!(finished, super::AppEvent::ReplayLoadFinished)); + + let _ = fs::remove_file(&path); + remove_spill_files(&path); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn replay_mode_loads_small_spill_history_immediately() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-small-spill"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + let mut previous = None; + for index in 0..40u64 { + let line = (0..512usize) + .map(|offset| format!("{:08x}", index.saturating_mul(512) + offset as u64)) + .collect::>() + .join(""); + let record = LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(5000, 5, &[&line]), + }; + append_delta_record( + cli.replay.as_deref().unwrap(), + &record, + previous.as_ref(), + 120, + ) + .unwrap(); + previous = Some(record.snapshot); + } + compact_active_log(cli.replay.as_deref().unwrap(), 8).unwrap(); + + let app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + + assert_eq!(app.current_label(), Some("frame-39")); + assert_eq!(app.loaded_frame_count(), 40); + assert!(!app.replay_loading); + assert_eq!( + app.ui.status_message.as_deref(), + Some("replay loaded: 40 frames") + ); + + let _ = fs::remove_file(&path); + remove_spill_files(&path); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn replay_replace_state_restores_full_history_after_prefetch() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-replace-state"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + let mut previous = None; + for index in 0..40u64 { + let line = (0..512usize) + .map(|offset| format!("{:08x}", index.saturating_mul(512) + offset as u64)) + .collect::>() + .join(""); + let record = LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(5000, 5, &[&line]), + }; + append_delta_record( + cli.replay.as_deref().unwrap(), + &record, + previous.as_ref(), + 120, + ) + .unwrap(); + previous = Some(record.snapshot); + } + compact_active_log(cli.replay.as_deref().unwrap(), 8).unwrap(); + + let mut app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + assert!(app.loaded_frame_count() <= 40); + + let state = build_replay_replace_state( + cli.replay.as_deref().unwrap(), + app.checkpoint_interval, + app.compress, + ) + .unwrap(); + app.replace_loaded_replay_state(state).unwrap(); + + assert_eq!(app.loaded_frame_count(), 40); + assert_eq!(app.current_label(), Some("frame-39")); + + let _ = fs::remove_file(&path); + remove_spill_files(&path); + let _ = fs::remove_dir_all(dir); +} + #[test] fn save_snapshot_uses_configured_directory_and_format() { let mut cli = test_cli(); @@ -1426,6 +1817,27 @@ fn cycle_screenshot_format_toggles_and_updates_status() { assert_eq!(app.screenshot_format.label(), "text"); } +#[test] +fn shift_h_toggles_header_visibility() { + let mut app = App::new( + &test_cli(), + Box::new(MockSource::new(vec![frame("a", &["one"])])), + ) + .unwrap(); + + assert!(!app.hide_header); + + app.handle_key_event(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT)) + .unwrap(); + assert!(app.hide_header); + assert_eq!(app.ui.status_message.as_deref(), Some("header hidden")); + + app.handle_key_event(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT)) + .unwrap(); + assert!(!app.hide_header); + assert_eq!(app.ui.status_message.as_deref(), Some("header shown")); +} + #[test] fn history_trimming_is_batched_after_limit_boundary() { let mut cli = test_cli(); @@ -1563,6 +1975,12 @@ fn test_cli() -> Cli { compress: false, logfile: None, replay: None, + pack_logfile: None, + pack_output: None, + record_stdin: false, + record_stdin_spill_every: 64, + record_stdin_spill_retain: 32, + size: None, screenshot_dir: "/tmp".to_string(), screenshot_format: ScreenshotFormatArg::Text, snapshot_on: None, @@ -1574,6 +1992,7 @@ fn test_cli() -> Cli { limit: 500, checkpoint_interval: 12, debug: false, + hide_header: false, command: vec!["mock".to_string()], } } @@ -1605,3 +2024,21 @@ fn unique_temp_dir(prefix: &str) -> PathBuf { .as_nanos(); std::env::temp_dir().join(format!("{prefix}-{id}")) } + +fn remove_spill_files(path: &PathBuf) { + for spill in active_spill_paths(path.to_string_lossy().as_ref()) { + let _ = fs::remove_file(spill); + } +} + +fn noisy_line(seed: u64, words: usize) -> String { + let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15); + let mut line = String::with_capacity(words * 8); + for _ in 0..words { + state ^= state << 7; + state ^= state >> 9; + state ^= state << 8; + line.push_str(&format!("{:08x}", (state & 0xffff_ffff) as u32)); + } + line +} diff --git a/src/app/view.rs b/src/app/view.rs index ad44907..8fa89f8 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -11,11 +11,32 @@ impl App { } pub fn visible_history_len(&self) -> usize { - self.history_len().min(self.limit) + if self.limit == 0 { + self.history_len() + } else { + self.history_len().min(self.limit) + } + } + + pub fn display_history_len(&self) -> usize { + self.visible_history_len() + usize::from(self.current_snapshot.is_some()) + } + + pub fn display_filtered_history_len(&self) -> usize { + let current_matches = if self.ui.filter_query.is_empty() { + self.current_snapshot.is_some() + } else { + self.current_snapshot_matches_filter() + }; + self.filtered.len() + usize::from(current_matches) } pub(super) fn visible_history_start(&self) -> usize { - self.history_len().saturating_sub(self.limit) + if self.limit == 0 { + 0 + } else { + self.history_len().saturating_sub(self.limit) + } } pub fn selected_snapshot(&self) -> Option { @@ -240,9 +261,9 @@ impl App { return Ok(()); } let (width, height) = crossterm::terminal::size().unwrap_or((120, 40)); - let Some(snapshot) = self - .source - .view_snapshot(width, height.saturating_sub(2), offset)? + let Some(snapshot) = + self.source + .view_snapshot(width, self.content_height(height), offset)? else { self.clear_live_scrollback_view(); return Ok(()); diff --git a/src/batch/tests.rs b/src/batch/tests.rs index db20cbd..14e940f 100644 --- a/src/batch/tests.rs +++ b/src/batch/tests.rs @@ -82,6 +82,12 @@ fn test_cli() -> Cli { compress: false, logfile: None, replay: None, + pack_logfile: None, + pack_output: None, + record_stdin: false, + record_stdin_spill_every: 64, + record_stdin_spill_retain: 32, + size: None, screenshot_dir: "/tmp".to_string(), screenshot_format: ScreenshotFormatArg::Text, snapshot_on: None, @@ -93,6 +99,7 @@ fn test_cli() -> Cli { limit: 500, checkpoint_interval: 12, debug: false, + hide_header: false, command: vec!["mock".to_string()], } } diff --git a/src/cli.rs b/src/cli.rs index ea71e2e..d331d51 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -96,6 +96,43 @@ pub struct Cli { #[arg(long, help = "Replay a saved JSONL trace in read-only mode")] pub replay: Option, + #[arg( + long, + hide = true, + help = "Pack a saved log into compact archive format" + )] + pub pack_logfile: Option, + + #[arg(long, hide = true, help = "Write compact archive output to this path")] + pub pack_output: Option, + + #[arg( + long, + help = "Record terminal output from stdin without spawning a child PTY" + )] + pub record_stdin: bool, + + #[arg( + long, + default_value_t = 64, + help = "During stdin recording, spill older active log frames into a sidecar every N frames; use 0 to disable" + )] + pub record_stdin_spill_every: usize, + + #[arg( + long, + default_value_t = 32, + help = "During stdin recording, keep this many recent frames in the active JSONL before spilling older ones" + )] + pub record_stdin_spill_retain: usize, + + #[arg( + long, + value_name = "WIDTH,HEIGHT", + help = "Use a fixed terminal size for stdin recording" + )] + pub size: Option, + #[arg(long, default_value = "/tmp")] pub screenshot_dir: String, @@ -135,12 +172,15 @@ pub struct Cli { #[arg(short = 'L', long, default_value_t = 500)] pub limit: usize, - #[arg(long, default_value_t = 12)] + #[arg(long, default_value_t = 120)] pub checkpoint_interval: usize, #[arg(long, help = "Show debug diagnostics in the interactive UI")] pub debug: bool, + #[arg(long, help = "Hide the two-line interactive header")] + pub hide_header: bool, + #[arg()] pub command: Vec, } @@ -263,8 +303,8 @@ impl fmt::Display for CropSpec { #[cfg(test)] mod tests { - use super::{CropSpec, DiffModeArg, SizeSpec, default_shell}; - use clap::ValueEnum; + use super::{Cli, CropSpec, DiffModeArg, SizeSpec, default_shell}; + use clap::{Parser, ValueEnum}; #[test] fn parses_size_spec() { @@ -307,4 +347,26 @@ mod tests { #[cfg(not(windows))] assert_eq!(default_shell(), "sh -c"); } + + #[test] + fn parses_record_stdin_size() { + let cli = Cli::parse_from(["twatch", "--record-stdin", "--size", "120,40"]); + assert!(cli.record_stdin); + assert_eq!( + cli.size, + Some(SizeSpec { + width: 120, + height: 40, + }) + ); + assert_eq!(cli.record_stdin_spill_every, 64); + assert_eq!(cli.record_stdin_spill_retain, 32); + } + + #[test] + fn parses_hide_header_flag() { + let cli = Cli::parse_from(["twatch", "--hide-header", "--replay", "trace.jsonl"]); + assert!(cli.hide_header); + assert_eq!(cli.replay.as_deref(), Some("trace.jsonl")); + } } diff --git a/src/history.rs b/src/history.rs index d27b5ef..44558f0 100644 --- a/src/history.rs +++ b/src/history.rs @@ -9,10 +9,13 @@ use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use regex::Regex; +use rmp_serde::{from_slice as rmp_from_slice, to_vec as rmp_to_vec}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use crate::diff::{LineDiff, WordDiff, diff_lines, diff_words}; -use crate::screen::{Cell, ScreenSnapshot}; +use crate::screen::{DeltaCell, ScreenSnapshot, Style}; + +const RECENT_RAW_HISTORY_ENTRIES: usize = 256; #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -49,7 +52,8 @@ struct HistoryEntry { struct FrameDelta { width: u16, height: u16, - changes: Vec<(usize, Cell)>, + changes: Vec<(usize, DeltaCell)>, + styles: Vec