aboutsummaryrefslogtreecommitdiff
path: root/src/persist
diff options
context:
space:
mode:
authorzhugehyuk <icedac@users.noreply.github.com>2026-05-25 03:28:33 +0900
committerGitHub <noreply@github.com>2026-05-24 21:28:33 +0300
commit43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (patch)
tree73be12f81129dab324ea08901c733a6a56ec0e61 /src/persist
parent5ce738cf5397d0d8a50ed1aa6335297073cc0f0f (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.rs134
-rw-r--r--src/persist/restore.rs206
-rw-r--r--src/persist/snapshot.rs210
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"]);