diff options
| author | zhugehyuk <icedac@users.noreply.github.com> | 2026-05-25 03:28:33 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-24 21:28:33 +0300 |
| commit | 43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (patch) | |
| tree | 73be12f81129dab324ea08901c733a6a56ec0e61 /src/persist | |
| parent | 5ce738cf5397d0d8a50ed1aa6335297073cc0f0f (diff) | |
feat: persist pane history across restarts (#248)
refs #217
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/persist')
| -rw-r--r-- | src/persist/io.rs | 134 | ||||
| -rw-r--r-- | src/persist/restore.rs | 206 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 210 |
3 files changed, 515 insertions, 35 deletions
diff --git a/src/persist/io.rs b/src/persist/io.rs index 1b589b09..12f3c9c5 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -2,12 +2,19 @@ use std::path::{Path, PathBuf}; use tracing::warn; -use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION}; +use super::snapshot::{ + parse_history_snapshot, parse_snapshot, snapshot_file_version, SessionHistorySnapshot, + SessionSnapshot, SNAPSHOT_VERSION, +}; fn session_path() -> PathBuf { crate::session::data_dir().join("session.json") } +fn session_history_path() -> PathBuf { + crate::session::data_dir().join("session-history.json") +} + // Follow symlinks manually so a write through a (possibly dangling) symlink // lands on the target. `fs::canonicalize` requires the target to exist, which // excludes the dangling-symlink case stow users hit on the very first save. @@ -35,6 +42,10 @@ fn resolve_write_target(path: &Path) -> std::io::Result<PathBuf> { } pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> { + save_json_to_path(path, snapshot) +} + +fn save_json_to_path<T: serde::Serialize>(path: &Path, snapshot: &T) -> std::io::Result<()> { let target = resolve_write_target(path)?; if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; @@ -49,6 +60,21 @@ pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io:: Ok(()) } +pub(super) fn save_to_paths( + session_path: &Path, + history_path: &Path, + snapshot: &SessionSnapshot, + history: Option<&SessionHistorySnapshot>, +) -> std::io::Result<()> { + save_to_path(session_path, snapshot)?; + if let Some(history) = history { + save_json_to_path(history_path, history)?; + } else { + clear_path(history_path)?; + } + Ok(()) +} + pub(super) fn clear_path(path: &Path) -> std::io::Result<()> { match std::fs::remove_file(path) { Ok(()) => Ok(()), @@ -57,9 +83,10 @@ pub(super) fn clear_path(path: &Path) -> std::io::Result<()> { } } -pub fn save(snapshot: &SessionSnapshot) { +pub fn save(snapshot: &SessionSnapshot, history: Option<&SessionHistorySnapshot>) { let path = session_path(); - if let Err(err) = save_to_path(&path, snapshot) { + let history_path = session_history_path(); + if let Err(err) = save_to_paths(&path, &history_path, snapshot, history) { crate::logging::session_save_failed(&path, &err.to_string()); return; } @@ -72,9 +99,17 @@ pub fn clear() { crate::logging::session_clear_failed(&path, &err.to_string()); return; } + clear_history(); crate::logging::session_cleared(&path); } +pub fn clear_history() { + let path = session_history_path(); + if let Err(err) = clear_path(&path) { + crate::logging::session_clear_failed(&path, &err.to_string()); + } +} + pub fn load() -> Option<SessionSnapshot> { let path = session_path(); if !path.exists() { @@ -106,10 +141,44 @@ pub fn load() -> Option<SessionSnapshot> { } } +pub fn load_history() -> Option<SessionHistorySnapshot> { + let path = session_history_path(); + if !path.exists() { + return None; + } + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(err) => { + warn!(err = %err, "failed to read session history file"); + return None; + } + }; + match parse_history_snapshot(&content) { + Ok(snapshot) => Some(snapshot), + Err(err) => { + if let Some(version) = snapshot_file_version(&content) { + if version > SNAPSHOT_VERSION { + warn!( + file_version = version, + supported = SNAPSHOT_VERSION, + "session history file is from a newer herdr version, ignoring" + ); + return None; + } + } + warn!(err = %err, "failed to parse session history file, ignoring"); + None + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::app::state::AgentPanelScope; + use crate::persist::snapshot::{ + PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot, + }; fn temp_session_path(name: &str) -> PathBuf { let unique = format!( @@ -124,6 +193,12 @@ mod tests { std::env::temp_dir().join(unique).join("session.json") } + fn temp_session_paths(name: &str) -> (PathBuf, PathBuf) { + let session = temp_session_path(name); + let history = session.with_file_name("session-history.json"); + (session, history) + } + fn empty_snapshot() -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, @@ -137,6 +212,59 @@ mod tests { } } + fn history_snapshot(secret: &str) -> SessionHistorySnapshot { + SessionHistorySnapshot { + version: SNAPSHOT_VERSION, + workspaces: vec![WorkspaceHistorySnapshot { + tabs: vec![TabHistorySnapshot { + panes: std::collections::HashMap::from([( + 0, + PaneHistorySnapshot { + ansi: secret.to_string(), + lines: 1, + }, + )]), + }], + }], + } + } + + #[test] + fn save_to_paths_writes_pane_history_only_to_history_file() { + let (session_path, history_path) = temp_session_paths("split-history"); + + save_to_paths( + &session_path, + &history_path, + &empty_snapshot(), + Some(&history_snapshot("split-secret")), + ) + .unwrap(); + + let session = std::fs::read_to_string(&session_path).unwrap(); + let history = std::fs::read_to_string(&history_path).unwrap(); + assert!(!session.contains("split-secret")); + assert!(!session.contains("history")); + assert!(history.contains("split-secret")); + } + + #[test] + fn save_to_paths_removes_stale_history_when_history_is_disabled() { + let (session_path, history_path) = temp_session_paths("clear-history"); + save_to_paths( + &session_path, + &history_path, + &empty_snapshot(), + Some(&history_snapshot("stale-secret")), + ) + .unwrap(); + + save_to_paths(&session_path, &history_path, &empty_snapshot(), None).unwrap(); + + assert!(session_path.exists()); + assert!(!history_path.exists()); + } + #[test] fn clear_path_removes_existing_session_file() { let path = temp_session_path("clear-existing"); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 4f763943..26446172 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -14,11 +14,21 @@ use crate::pane::PaneState; use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; -use super::{DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot}; +use super::snapshot::{TabHistorySnapshot, WorkspaceHistorySnapshot}; +use super::{ + DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot, + WorkspaceSnapshot, +}; + +struct AgentRestoreState<'a> { + enabled: bool, + resumed_sessions: &'a mut HashSet<String>, +} /// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd. pub fn restore( snapshot: &SessionSnapshot, + history: Option<&SessionHistorySnapshot>, rows: u16, cols: u16, scrollback_limit_bytes: usize, @@ -36,9 +46,10 @@ pub fn restore( let mut terminals = HashMap::new(); let mut terminal_runtimes = HashMap::new(); let mut resumed_agent_sessions = HashSet::new(); - for ws_snap in &snapshot.workspaces { + for (idx, ws_snap) in snapshot.workspaces.iter().enumerate() { if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace( ws_snap, + history.and_then(|history| history.workspaces.get(idx)), rows, cols, scrollback_limit_bytes, @@ -61,6 +72,7 @@ pub fn restore( fn restore_workspace( snap: &WorkspaceSnapshot, + history: Option<&WorkspaceHistorySnapshot>, rows: u16, cols: u16, scrollback_limit_bytes: usize, @@ -80,17 +92,21 @@ fn restore_workspace( let mut terminal_runtimes = HashMap::new(); let mut public_pane_numbers = HashMap::new(); let mut next_public_pane_number = 1; + let mut agent_restore = AgentRestoreState { + enabled: resume_agents_on_restore, + resumed_sessions: resumed_agent_sessions, + }; for (idx, tab_snap) in snap.tabs.iter().enumerate() { let (tab, restored_terminals, restored_runtimes) = restore_tab( tab_snap, + history.and_then(|history| history.tabs.get(idx)), idx + 1, rows, cols, scrollback_limit_bytes, default_shell, - resume_agents_on_restore, - resumed_agent_sessions, + &mut agent_restore, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -146,13 +162,13 @@ fn restored_worktree_space_membership( fn restore_tab( snap: &TabSnapshot, + history: Option<&TabHistorySnapshot>, number: usize, rows: u16, cols: u16, scrollback_limit_bytes: usize, default_shell: &str, - resume_agents_on_restore: bool, - resumed_agent_sessions: &mut HashSet<String>, + agent_restore: &mut AgentRestoreState<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -172,9 +188,9 @@ fn restore_tab( let mut terminals = Vec::new(); let mut terminal_runtimes = HashMap::new(); for id in &pane_ids { - let saved_cwd = reverse_id_map - .get(id) - .and_then(|old_id| snap.panes.get(old_id)) + let old_id = reverse_id_map.get(id); + let saved_pane = old_id.and_then(|old_id| snap.panes.get(old_id)); + let saved_cwd = saved_pane .map(|p| p.cwd.clone()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); @@ -195,26 +211,19 @@ fn restore_tab( } }; - let saved_label = reverse_id_map - .get(id) - .and_then(|old_id| snap.panes.get(old_id)) - .and_then(|p| p.label.clone()); - let saved_agent_name = reverse_id_map - .get(id) - .and_then(|old_id| snap.panes.get(old_id)) - .and_then(|p| p.agent_name.clone()); - let saved_agent_session = reverse_id_map - .get(id) - .and_then(|old_id| snap.panes.get(old_id)) - .and_then(|p| p.agent_session.as_ref()); - let mut restore_plan = reverse_id_map - .get(id) - .and_then(|old_id| snap.panes.get(old_id)) - .and_then(|p| p.agent_session.as_ref()) - .and_then(|session| restore_plan_for_snapshot(session, resume_agents_on_restore)); - let duplicate_agent_session = restore_plan - .as_ref() - .is_some_and(|plan| !resumed_agent_sessions.insert(plan.dedupe_key.clone())); + let saved_label = saved_pane.and_then(|p| p.label.clone()); + let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone()); + let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); + let saved_history = + old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); + let initial_history_ansi = saved_history.map(|history| history.ansi.as_str()); + let mut restore_plan = saved_agent_session + .and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled)); + let duplicate_agent_session = restore_plan.as_ref().is_some_and(|plan| { + !agent_restore + .resumed_sessions + .insert(plan.dedupe_key.clone()) + }); if duplicate_agent_session { restore_plan = None; } @@ -223,12 +232,16 @@ fn restore_tab( .and_then(|plan| crate::detect::parse_agent_label(&plan.agent)); let runtime_result = if let Some(plan) = restore_plan { + let launch = crate::agent_resume::AgentResumeLaunch { + plan: &plan, + initial_history_ansi, + }; TerminalRuntime::spawn_agent_restore( *id, rows, cols, cwd.clone(), - &plan, + launch, scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), default_shell, @@ -237,7 +250,7 @@ fn restore_tab( render_dirty.clone(), ) } else { - TerminalRuntime::spawn( + TerminalRuntime::spawn_with_initial_history( *id, rows, cols, @@ -245,6 +258,7 @@ fn restore_tab( scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), default_shell, + initial_history_ansi, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -660,6 +674,7 @@ mod tests { let (_workspaces, terminals, _runtimes) = restore( &snapshot, + None, 24, 80, 0, @@ -679,4 +694,133 @@ mod tests { assert_eq!(session.agent, "opencode"); assert_eq!(session.session_ref.value, "opencode-session"); } + + #[tokio::test] + async fn restore_seeds_saved_pane_history_into_runtime() { + let (snapshot, history) = snapshot_with_saved_pane_history(); + let (events, _events_rx) = mpsc::channel(8); + let render_notify = Arc::new(Notify::new()); + let render_dirty = Arc::new(AtomicBool::new(false)); + + let (_workspaces, _terminals, runtimes) = restore( + &snapshot, + Some(&history), + 5, + 40, + 4096, + "/bin/sh", + false, + events, + render_notify, + render_dirty, + ); + let runtime = runtimes + .values() + .next() + .expect("restored runtime should exist"); + + assert!( + runtime + .recent_unwrapped_text(10) + .contains("RESTORED_HISTORY"), + "saved history should be visible in the restored terminal backend" + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.cwd().is_none() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = runtime.try_send_bytes(bytes::Bytes::from_static(b"exit\n")); + } + + #[tokio::test] + async fn restore_without_history_snapshot_keeps_pane_contents_empty() { + let (snapshot, _history) = snapshot_with_saved_pane_history(); + let (events, _events_rx) = mpsc::channel(8); + let render_notify = Arc::new(Notify::new()); + let render_dirty = Arc::new(AtomicBool::new(false)); + + let (_workspaces, _terminals, runtimes) = restore( + &snapshot, + None, + 5, + 40, + 4096, + "/bin/sh", + false, + events, + render_notify, + render_dirty, + ); + let runtime = runtimes + .values() + .next() + .expect("restored runtime should exist"); + + assert!( + !runtime + .recent_unwrapped_text(10) + .contains("RESTORED_HISTORY"), + "pane history should not restore unless a history snapshot is supplied" + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.cwd().is_none() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = runtime.try_send_bytes(bytes::Bytes::from_static(b"exit\n")); + } + + fn snapshot_with_saved_pane_history() -> (SessionSnapshot, SessionHistorySnapshot) { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let mut panes = HashMap::new(); + panes.insert( + 0, + super::super::snapshot::PaneSnapshot { + cwd: cwd.clone(), + label: None, + agent_name: None, + agent_session: None, + }, + ); + let history = SessionHistorySnapshot { + version: super::super::snapshot::SNAPSHOT_VERSION, + workspaces: vec![WorkspaceHistorySnapshot { + tabs: vec![super::super::snapshot::TabHistorySnapshot { + panes: HashMap::from([( + 0, + super::super::snapshot::PaneHistorySnapshot { + ansi: "RESTORED_HISTORY\r\n".to_string(), + lines: 1, + }, + )]), + }], + }], + }; + let snapshot = SessionSnapshot { + version: super::super::snapshot::SNAPSHOT_VERSION, + workspaces: vec![WorkspaceSnapshot { + id: Some("workspace".into()), + custom_name: None, + identity_cwd: cwd, + worktree_space: None, + tabs: vec![TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(0), + panes, + zoomed: false, + focused: Some(0), + root_pane: Some(0), + }], + active_tab: 0, + }], + active: Some(0), + selected: 0, + agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace, + sidebar_width: Some(26), + sidebar_section_split: Some(0.5), + collapsed_space_keys: Default::default(), + }; + (snapshot, history) + } } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index a223513d..36df83b1 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -31,6 +31,24 @@ pub struct SessionSnapshot { } #[derive(Serialize, Deserialize)] +pub struct SessionHistorySnapshot { + /// Format version follows the matching session snapshot version. + #[serde(default)] + pub version: u32, + pub workspaces: Vec<WorkspaceHistorySnapshot>, +} + +#[derive(Serialize, Deserialize)] +pub struct WorkspaceHistorySnapshot { + pub tabs: Vec<TabHistorySnapshot>, +} + +#[derive(Serialize, Deserialize)] +pub struct TabHistorySnapshot { + pub panes: HashMap<u32, PaneHistorySnapshot>, +} + +#[derive(Serialize, Deserialize)] pub struct WorkspaceSnapshot { #[serde(default)] pub id: Option<String>, @@ -89,6 +107,12 @@ pub struct PaneAgentSessionSnapshot { pub value: String, } +#[derive(Serialize, Deserialize)] +pub struct PaneHistorySnapshot { + pub ansi: String, + pub lines: usize, +} + /// Serializable BSP tree. #[derive(Serialize, Deserialize)] pub enum LayoutSnapshot { @@ -334,6 +358,52 @@ fn capture_tab( } } +/// Capture pane screen history separately from the structural session snapshot. +pub fn capture_history( + workspaces: &[Workspace], + terminal_runtimes: &TerminalRuntimeRegistry, +) -> SessionHistorySnapshot { + SessionHistorySnapshot { + version: SNAPSHOT_VERSION, + workspaces: workspaces + .iter() + .map(|workspace| WorkspaceHistorySnapshot { + tabs: workspace + .tabs + .iter() + .map(|tab| TabHistorySnapshot { + panes: capture_tab_history(tab, terminal_runtimes), + }) + .collect(), + }) + .collect(), + } +} + +fn capture_tab_history( + tab: &crate::workspace::Tab, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> HashMap<u32, PaneHistorySnapshot> { + let mut panes = HashMap::new(); + for (id, pane) in &tab.panes { + if let Some(history) = capture_pane_history(Some(pane), terminal_runtimes) { + panes.insert(id.raw(), history); + } + } + panes +} + +fn capture_pane_history( + pane: Option<&crate::pane::PaneState>, + terminal_runtimes: &TerminalRuntimeRegistry, +) -> Option<PaneHistorySnapshot> { + let ansi = terminal_runtimes + .get(&pane?.attached_terminal_id)? + .snapshot_history()?; + let lines = ansi.lines().count(); + Some(PaneHistorySnapshot { ansi, lines }) +} + pub(super) fn capture_node(node: &Node) -> LayoutSnapshot { match node { Node::Pane(id) => LayoutSnapshot::Pane(id.raw()), @@ -365,6 +435,18 @@ pub(super) fn parse_snapshot(content: &str) -> Result<SessionSnapshot, String> { migrate_snapshot(raw) } +pub(super) fn parse_history_snapshot(content: &str) -> Result<SessionHistorySnapshot, String> { + let snapshot = + serde_json::from_str::<SessionHistorySnapshot>(content).map_err(|e| e.to_string())?; + if snapshot.version > SNAPSHOT_VERSION { + return Err(format!( + "history snapshot version {} is newer than supported {}", + snapshot.version, SNAPSHOT_VERSION + )); + } + Ok(snapshot) +} + pub(super) fn snapshot_file_version(content: &str) -> Option<u32> { serde_json::from_str::<RawSessionSnapshot>(content) .ok() @@ -412,10 +494,17 @@ mod tests { fn capture_from_state(state: &AppState) -> SessionSnapshot { let terminal_runtimes = TerminalRuntimeRegistry::new(); + capture_from_state_with_runtimes(state, &terminal_runtimes) + } + + fn capture_from_state_with_runtimes( + state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + ) -> SessionSnapshot { capture( &state.workspaces, &state.terminals, - &terminal_runtimes, + terminal_runtimes, state.active, state.selected, state.agent_panel_scope, @@ -425,6 +514,13 @@ mod tests { ) } + fn capture_history_from_state_with_runtimes( + state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + ) -> SessionHistorySnapshot { + capture_history(&state.workspaces, terminal_runtimes) + } + fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> { match &tab.layout { LayoutSnapshot::Split { ratio, .. } => Some(*ratio), @@ -601,6 +697,42 @@ mod tests { } #[test] + fn old_pane_snapshot_with_embedded_history_is_ignored() { + let json = serde_json::json!({ + "version": SNAPSHOT_VERSION, + "workspaces": [{ + "id": "wtest", + "identity_cwd": "/tmp", + "tabs": [{ + "layout": { "Pane": 0 }, + "panes": { + "0": { + "cwd": "/tmp", + "history": { + "ansi": "legacy-secret", + "lines": 1 + } + } + }, + "zoomed": false, + "focused": 0, + "root_pane": 0 + }], + "active_tab": 0 + }], + "active": 0, + "selected": 0 + }) + .to_string(); + + let restored = parse_snapshot(&json).unwrap(); + + let encoded = serde_json::to_string(&restored).unwrap(); + assert!(!encoded.contains("legacy-secret")); + assert!(!encoded.contains("\"history\"")); + } + + #[test] fn legacy_workspace_snapshot_migrates_to_single_tab() { let snap = parse_snapshot(session_fixture("legacy-pre-tabs-v2")).unwrap(); let ws = &snap.workspaces[0]; @@ -801,6 +933,82 @@ mod tests { assert_eq!(tab.panes[&second.raw()].cwd, PathBuf::from("/tmp/herdr")); } + #[tokio::test] + async fn capture_contract_tracks_pane_history_from_runtime() { + let state = state_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + terminal_runtimes.insert( + terminal_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 3, + 4096, + b"alpha\r\nbeta\r\ngamma\r\n", + ), + ); + + let snapshot = capture_from_state_with_runtimes(&state, &terminal_runtimes); + let encoded = serde_json::to_string(&snapshot).unwrap(); + assert!(!encoded.contains("alpha")); + assert!(!encoded.contains("\"history\"")); + + let history_snapshot = capture_history_from_state_with_runtimes(&state, &terminal_runtimes); + let history = &history_snapshot.workspaces[0].tabs[0].panes[&root.raw()]; + + assert!(history.ansi.contains("alpha")); + assert!(history.ansi.contains("gamma")); + assert!(history.lines >= 3); + } + + #[tokio::test] + async fn capture_contract_tracks_history_for_each_pane() { + let mut state = state_with_workspaces(&["one"]); + let first = state.workspaces[0].tabs[0].root_pane; + let second = state.workspaces[0].test_split(Direction::Horizontal); + let first_terminal_id = state.workspaces[0].tabs[0].panes[&first] + .attached_terminal_id + .clone(); + let second_terminal_id = state.workspaces[0].tabs[0].panes[&second] + .attached_terminal_id + .clone(); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + terminal_runtimes.insert( + first_terminal_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 3, + 4096, + b"first-pane-history\r\n", + ), + ); + terminal_runtimes.insert( + second_terminal_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 3, + 4096, + b"second-pane-history\r\n", + ), + ); + + let snapshot = capture_from_state_with_runtimes(&state, &terminal_runtimes); + let encoded = serde_json::to_string(&snapshot).unwrap(); + assert!(!encoded.contains("first-pane-history")); + assert!(!encoded.contains("second-pane-history")); + + let history_snapshot = capture_history_from_state_with_runtimes(&state, &terminal_runtimes); + let tab = &history_snapshot.workspaces[0].tabs[0]; + let first_history = &tab.panes[&first.raw()]; + let second_history = &tab.panes[&second.raw()]; + + assert!(first_history.ansi.contains("first-pane-history")); + assert!(second_history.ansi.contains("second-pane-history")); + } + #[test] fn capture_contract_tracks_hook_authority_agent_session() { let mut state = state_with_workspaces(&["one"]); |
