aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs6
-rw-r--r--src/app/config_io.rs8
-rw-r--r--src/app/input/mod.rs3
-rw-r--r--src/app/input/settings.rs126
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/app/session.rs5
-rw-r--r--src/app/state.rs9
-rw-r--r--src/config/model.rs17
-rw-r--r--src/main.rs2
-rw-r--r--src/pane.rs83
-rw-r--r--src/pane/terminal.rs31
-rw-r--r--src/persist.rs6
-rw-r--r--src/persist/io.rs134
-rw-r--r--src/persist/restore.rs206
-rw-r--r--src/persist/snapshot.rs210
-rw-r--r--src/terminal/runtime.rs37
-rw-r--r--src/ui/settings.rs97
17 files changed, 960 insertions, 59 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs
index 8f450088..7728baf4 100644
--- a/src/agent_resume.rs
+++ b/src/agent_resume.rs
@@ -25,6 +25,12 @@ pub struct AgentResumePlan {
pub dedupe_key: String,
}
+#[derive(Debug, Clone, Copy)]
+pub struct AgentResumeLaunch<'a> {
+ pub plan: &'a AgentResumePlan,
+ pub initial_history_ansi: Option<&'a str>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedAgentSession {
pub source: String,
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index 36f44b9a..fa2765e9 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -86,6 +86,14 @@ impl App {
}
}
+ pub(super) fn save_pane_history_persistence(&mut self, enabled: bool) {
+ if self.update_config_file("pane screen history", |content| {
+ crate::config::upsert_section_bool(content, "experimental", "pane_history", enabled)
+ }) {
+ self.apply_config_from_disk(false);
+ }
+ }
+
pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) {
let value = match scope {
crate::app::state::AgentPanelScope::CurrentWorkspace => {
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 44491631..0f2f575d 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -204,6 +204,9 @@ impl App {
SettingsAction::SaveAgentBorderLabels(enabled) => {
self.save_agent_border_labels(enabled)
}
+ SettingsAction::SavePaneHistory(enabled) => {
+ self.save_pane_history_persistence(enabled)
+ }
SettingsAction::InstallRecommendedIntegrations => {
self.install_recommended_integrations()
}
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 6caccb0e..986f59e2 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -17,6 +17,7 @@ pub(super) enum SettingsAction {
SaveSound(bool),
SaveToastDelivery(ToastDelivery),
SaveAgentBorderLabels(bool),
+ SavePaneHistory(bool),
InstallRecommendedIntegrations,
}
@@ -31,6 +32,9 @@ impl App {
SettingsAction::SaveAgentBorderLabels(enabled) => {
self.save_agent_border_labels(enabled)
}
+ SettingsAction::SavePaneHistory(enabled) => {
+ self.save_pane_history_persistence(enabled)
+ }
SettingsAction::InstallRecommendedIntegrations => {
self.install_recommended_integrations()
}
@@ -142,6 +146,10 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
state.settings.section = SettingsSection::Sound;
state.settings.list.selected = usize::from(!state.sound_enabled());
}
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Experiments;
+ state.settings.list.selected = 0;
+ }
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
Some(super::modal::ModalAction::Apply) => return apply_settings(state),
Some(super::modal::ModalAction::Close) => cancel_settings(state),
@@ -219,6 +227,28 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
}
}
},
+ SettingsSection::Experiments => match key.code {
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ return Some(SettingsAction::SavePaneHistory(
+ !state.pane_history_persistence_enabled(),
+ ));
+ }
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Integrations;
+ state.settings.list.selected = 0;
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ }
+ _ => {
+ if let Some(super::modal::ModalAction::Close) =
+ super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS)
+ {
+ cancel_settings(state);
+ }
+ }
+ },
SettingsSection::Integrations => match key.code {
KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => {
return Some(SettingsAction::InstallRecommendedIntegrations);
@@ -228,8 +258,8 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
state.settings.list.selected = usize::from(!state.agent_border_labels_enabled());
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
- state.settings.section = SettingsSection::Theme;
- state.settings.list.selected = current_theme_index(&state.theme_name);
+ state.settings.section = SettingsSection::Experiments;
+ state.settings.list.selected = 0;
}
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
Some(super::modal::ModalAction::Apply) => return apply_settings(state),
@@ -255,6 +285,7 @@ pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) {
SettingsSection::Sound => usize::from(!state.sound_enabled()),
SettingsSection::Toast => toast_delivery_index(state.toast_delivery()),
SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()),
+ SettingsSection::Experiments => 0,
SettingsSection::Integrations => 0,
};
state.mode = Mode::Settings;
@@ -344,6 +375,10 @@ impl AppState {
None
}
}
+ SettingsSection::Experiments => {
+ let list_y = area.y + 3;
+ (row == list_y).then_some(0)
+ }
SettingsSection::Integrations => None,
}
}
@@ -360,6 +395,7 @@ impl AppState {
SettingsSection::PaneLabels => {
usize::from(!self.agent_border_labels_enabled())
}
+ SettingsSection::Experiments => 0,
SettingsSection::Integrations => 0,
});
return None;
@@ -383,6 +419,9 @@ impl AppState {
let enabled = idx == 0;
Some(SettingsAction::SaveAgentBorderLabels(enabled))
}
+ SettingsSection::Experiments => Some(SettingsAction::SavePaneHistory(
+ !self.pane_history_persistence_enabled(),
+ )),
SettingsSection::Integrations => None,
};
}
@@ -470,6 +509,57 @@ mod tests {
}
#[test]
+ fn settings_experiments_toggles_pane_history() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.pane_history_persistence = false;
+ open_settings_at(&mut state, SettingsSection::Experiments);
+
+ let action = update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(action, Some(SettingsAction::SavePaneHistory(true)));
+ assert_eq!(state.mode, Mode::Settings);
+ }
+
+ #[test]
+ fn settings_tab_cycle_places_experiments_last() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_settings_at(&mut state, SettingsSection::PaneLabels);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::Integrations);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::Experiments);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::Theme);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::Experiments);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::Integrations);
+ }
+
+ #[test]
fn integrations_enter_does_nothing_when_nothing_needs_install() {
let mut state = state_with_workspaces(&["test"]);
open_settings_at(&mut state, SettingsSection::Integrations);
@@ -500,6 +590,23 @@ mod tests {
}
#[test]
+ fn settings_mouse_click_toggles_pane_history() {
+ let mut app = app_for_mouse_test();
+ app.state.pane_history_persistence = false;
+ open_settings_at(&mut app.state, SettingsSection::Experiments);
+
+ let area = app.state.settings_content_rect();
+ let action = app.state.handle_settings_mouse(mouse(
+ MouseEventKind::Down(crossterm::event::MouseButton::Left),
+ area.x + 2,
+ area.y + 3,
+ ));
+
+ assert_eq!(action, Some(SettingsAction::SavePaneHistory(true)));
+ assert_eq!(app.state.settings.list.selected, 0);
+ }
+
+ #[test]
fn integration_update_badge_only_tracks_outdated_recommendations() {
let mut state = state_with_workspaces(&["test"]);
state.integration_recommendations = vec![integration_recommendation(
@@ -538,10 +645,21 @@ mod tests {
let inner = state.settings_inner_rect();
let tab_y = inner.y + 1;
+ let integrations_idx = SettingsSection::ALL
+ .iter()
+ .position(|section| *section == SettingsSection::Integrations)
+ .expect("integrations section should be present");
let integrations_x = inner.x
- + SettingsSection::ALL[..SettingsSection::ALL.len() - 1]
+ + SettingsSection::ALL[..integrations_idx]
.iter()
- .map(|section| section.label().len() as u16 + 3)
+ .map(|section| {
+ let badge_width = if state.settings_section_has_badge(*section) {
+ 2
+ } else {
+ 0
+ };
+ section.label().len() as u16 + 3 + badge_width
+ })
.sum::<u16>();
let dotted_width = SettingsSection::Integrations.label().len() as u16 + 4;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index bacbf0ce..1679364b 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -82,6 +82,7 @@ pub struct App {
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
+ pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
pub(crate) suppressed_repeat_keys:
HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
@@ -252,8 +253,14 @@ impl App {
std::collections::HashSet::new(),
)
} else if let Some(snap) = crate::persist::load() {
+ let history = config
+ .experimental
+ .pane_history
+ .then(crate::persist::load_history)
+ .flatten();
let (ws, terminals, terminal_runtimes) = crate::persist::restore(
&snap,
+ history.as_ref(),
24,
80,
config.advanced.scrollback_limit_bytes,
@@ -455,6 +462,7 @@ impl App {
confirm_close: config.ui.confirm_close,
prompt_new_tab_name: config.ui.prompt_new_tab_name,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
+ pane_history_persistence: config.experimental.pane_history,
reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime,
cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(),
cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents),
@@ -529,6 +537,7 @@ impl App {
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
session_save_deadline: None,
selection_autoscroll_deadline: None,
+ persist_pane_history: config.experimental.pane_history,
last_render_at: None,
suppressed_repeat_keys: HashSet::new(),
api_rx,
@@ -1013,6 +1022,11 @@ impl App {
self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents);
self.state.cjk_ime_cursor_shape =
config.experimental.cjk_ime_cursor_shape.to_decscusr();
+ self.persist_pane_history = config.experimental.pane_history;
+ self.state.pane_history_persistence = config.experimental.pane_history;
+ if !self.persist_pane_history {
+ crate::persist::clear_history();
+ }
}
if !invalid_section("advanced") {
@@ -1848,6 +1862,31 @@ mod tests {
}
#[test]
+ fn settings_save_pane_history_persists_then_applies_live_config() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("settings-save-pane-history");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, "onboarding = false\n").unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ assert!(!app.persist_pane_history);
+ assert!(!app.state.pane_history_persistence);
+
+ app.save_pane_history_persistence(true);
+
+ assert!(app.persist_pane_history);
+ assert!(app.state.pane_history_persistence);
+ let content = std::fs::read_to_string(&path).unwrap();
+ assert!(content.contains("[experimental]"));
+ assert!(content.contains("pane_history = true"));
+ assert!(app.state.config_diagnostic.is_none());
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn reload_config_keeps_current_state_on_invalid_toml() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-invalid-toml");
diff --git a/src/app/session.rs b/src/app/session.rs
index 2adff1b8..f9b38e79 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -36,7 +36,10 @@ impl App {
self.state.sidebar_section_split,
self.state.collapsed_space_keys.clone(),
);
- crate::persist::save(&snap);
+ let history = self.persist_pane_history.then(|| {
+ crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes)
+ });
+ crate::persist::save(&snap, history.as_ref());
}
self.session_save_deadline = None;
diff --git a/src/app/state.rs b/src/app/state.rs
index 43d6e00f..422664ff 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -721,6 +721,7 @@ pub enum SettingsSection {
Sound,
Toast,
PaneLabels,
+ Experiments,
Integrations,
}
@@ -731,6 +732,7 @@ impl SettingsSection {
Self::Toast,
Self::PaneLabels,
Self::Integrations,
+ Self::Experiments,
];
pub fn label(self) -> &'static str {
@@ -739,6 +741,7 @@ impl SettingsSection {
Self::Sound => "sound",
Self::Toast => "toasts",
Self::PaneLabels => "pane labels",
+ Self::Experiments => "experiments",
Self::Integrations => "integrations",
}
}
@@ -1116,6 +1119,7 @@ pub struct AppState {
pub confirm_close: bool,
pub prompt_new_tab_name: bool,
pub show_agent_labels_on_pane_borders: bool,
+ pub pane_history_persistence: bool,
/// Expose the focused pane's cursor anchor to the outer terminal even when
/// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`.
pub reveal_hidden_cursor_for_cjk_ime: bool,
@@ -1175,6 +1179,10 @@ impl AppState {
self.show_agent_labels_on_pane_borders
}
+ pub fn pane_history_persistence_enabled(&self) -> bool {
+ self.pane_history_persistence
+ }
+
pub(crate) fn integration_updates_available(&self) -> bool {
self.integration_recommendations
.iter()
@@ -1410,6 +1418,7 @@ impl AppState {
confirm_close: true,
prompt_new_tab_name: true,
show_agent_labels_on_pane_borders: false,
+ pane_history_persistence: false,
reveal_hidden_cursor_for_cjk_ime: false,
cjk_ime_agent_filter_configured: false,
cjk_ime_agents: Vec::new(),
diff --git a/src/config/model.rs b/src/config/model.rs
index 7db8c2f4..35b08582 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -320,6 +320,8 @@ pub struct ExperimentalConfig {
pub allow_nested: bool,
/// Experimental local Kitty graphics rendering for attached clients. Default: false.
pub kitty_graphics: bool,
+ /// Persist pane screen history to session-history.json. Default: false.
+ pub pane_history: bool,
/// Expose the focused pane's cursor anchor to the outer terminal even when
/// the pane requested `?25l`, so macOS native input methods keep tracking
/// the candidate window when TUIs paint their own cursor (Claude Code, pi,
@@ -765,6 +767,19 @@ delivery = "terminal"
}
#[test]
+ fn pane_history_persistence_is_opt_in() {
+ assert!(!Config::default().experimental.pane_history);
+
+ let toml = r#"
+[experimental]
+pane_history = true
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+
+ assert!(config.experimental.pane_history);
+ }
+
+ #[test]
fn kitty_graphics_default_off_and_parse() {
let config = Config::default();
assert!(!config.experimental.kitty_graphics);
@@ -783,10 +798,12 @@ kitty_graphics = true
[experimental]
allow_nested = true
kitty_graphics = true
+pane_history = true
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.experimental.allow_nested);
assert!(config.experimental.kitty_graphics);
+ assert!(config.experimental.pane_history);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 4c7063ca..43737e01 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -234,6 +234,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Experimental local Kitty graphics rendering for attached clients.
# Requires a Kitty graphics-compatible outer terminal.
# kitty_graphics = false
+# Save recent pane screen history across full server restarts.
+pane_history = false
# Expose the focused pane's cursor to the outer terminal so macOS input
# methods keep tracking the candidate window when TUIs paint their own
# cursor (Claude Code, pi, codex). Trade-off: extra cursor visible for
diff --git a/src/pane.rs b/src/pane.rs
index 77f72bca..9b1bf125 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -45,6 +45,12 @@ struct PendingAgentRelease {
until: std::time::Instant,
}
+#[derive(Clone, Copy, Default)]
+struct SpawnInitialState<'a> {
+ detected_agent: Option<Agent>,
+ history_ansi: Option<&'a str>,
+}
+
fn active_pending_release(
pending_release: &Mutex<Option<PendingAgentRelease>>,
now: std::time::Instant,
@@ -384,6 +390,34 @@ impl PaneRuntime {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
+ Self::spawn_with_initial_history(
+ pane_id,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ default_shell,
+ None,
+ events,
+ render_notify,
+ render_dirty,
+ )
+ }
+
+ pub(crate) fn spawn_with_initial_history(
+ pane_id: PaneId,
+ rows: u16,
+ cols: u16,
+ cwd: std::path::PathBuf,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
+ initial_history_ansi: Option<&str>,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> std::io::Result<Self> {
let shell = pane_shell(default_shell);
let mut cmd = CommandBuilder::new(&shell);
cmd.cwd(cwd);
@@ -401,7 +435,10 @@ impl PaneRuntime {
render_dirty,
cmd,
"failed to spawn shell",
- None,
+ SpawnInitialState {
+ detected_agent: None,
+ history_ansi: initial_history_ansi,
+ },
)
}
@@ -439,7 +476,7 @@ impl PaneRuntime {
render_dirty,
cmd,
"failed to spawn command pane",
- None,
+ SpawnInitialState::default(),
)
}
@@ -480,7 +517,7 @@ impl PaneRuntime {
render_dirty,
cmd,
"failed to spawn argv command pane",
- None,
+ SpawnInitialState::default(),
)
}
@@ -489,7 +526,7 @@ impl PaneRuntime {
rows: u16,
cols: u16,
cwd: std::path::PathBuf,
- restore_plan: &crate::agent_resume::AgentResumePlan,
+ launch: crate::agent_resume::AgentResumeLaunch<'_>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
default_shell: &str,
@@ -497,7 +534,7 @@ impl PaneRuntime {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- if restore_plan.argv.is_empty() {
+ if launch.plan.argv.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"restore argv must not be empty",
@@ -505,7 +542,7 @@ impl PaneRuntime {
}
let shell = pane_shell(default_shell);
- let mut cmd = restore_command_builder(&restore_plan.agent, &shell, &restore_plan.argv);
+ let mut cmd = restore_command_builder(&launch.plan.agent, &shell, &launch.plan.argv);
cmd.cwd(cwd);
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
apply_pane_terminal_env(&mut cmd);
@@ -521,7 +558,10 @@ impl PaneRuntime {
render_dirty,
cmd,
"failed to spawn agent restore pane",
- crate::detect::parse_agent_label(&restore_plan.agent),
+ SpawnInitialState {
+ detected_agent: crate::detect::parse_agent_label(&launch.plan.agent),
+ history_ansi: launch.initial_history_ansi,
+ },
)
}
@@ -536,7 +576,7 @@ impl PaneRuntime {
render_dirty: Arc<AtomicBool>,
cmd: CommandBuilder,
spawn_error_message: &'static str,
- initial_detected_agent: Option<Agent>,
+ initial_state: SpawnInitialState<'_>,
) -> std::io::Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system
@@ -562,6 +602,9 @@ impl PaneRuntime {
}
let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
+ if let Some(ansi) = initial_state.history_ansi {
+ pane_terminal.seed_history_ansi(ansi);
+ }
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
@@ -686,8 +729,9 @@ impl PaneRuntime {
let pending_release_for_task = pending_release.clone();
let handle = tokio::spawn(async move {
- let mut agent_presence = AgentDetectionPresence::from_agent(initial_detected_agent);
- let mut state = if initial_detected_agent.is_some() {
+ let mut agent_presence =
+ AgentDetectionPresence::from_agent(initial_state.detected_agent);
+ let mut state = if initial_state.detected_agent.is_some() {
AgentState::Idle
} else {
AgentState::Unknown
@@ -696,7 +740,7 @@ impl PaneRuntime {
let mut last_foreground_pgid = None;
let mut pending_foreground_shell_clear = false;
let mut foreground_shell_exit_reported = false;
- let mut pending_restore_probe = initial_detected_agent.is_some();
+ let mut pending_restore_probe = initial_state.detected_agent.is_some();
let mut last_claude_working_at = None;
let mut last_visible_blocker = false;
let mut last_visible_idle = false;
@@ -1084,6 +1128,11 @@ impl PaneRuntime {
self.terminal.recent_unwrapped_ansi(lines)
}
+ pub fn snapshot_history(&self) -> Option<String> {
+ let ansi = self.recent_unwrapped_ansi(usize::MAX);
+ (!ansi.trim().is_empty()).then_some(ansi)
+ }
+
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.terminal.extract_selection(selection)
}
@@ -1381,15 +1430,19 @@ mod tests {
#[tokio::test]
async fn spawn_agent_restore_keeps_pane_alive_after_early_failure() {
let (events, mut event_rx) = mpsc::channel(4);
+ let plan = crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()],
+ dedupe_key: "test".into(),
+ };
let runtime = PaneRuntime::spawn_agent_restore(
PaneId::from_raw(7),
24,
80,
std::env::current_dir().unwrap(),
- &crate::agent_resume::AgentResumePlan {
- agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()],
- dedupe_key: "test".into(),
+ crate::agent_resume::AgentResumeLaunch {
+ plan: &plan,
+ initial_history_ansi: None,
},
0,
crate::terminal_theme::TerminalTheme::default(),
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index e1a57e89..77c83447 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -420,6 +420,19 @@ impl GhosttyPaneTerminal {
}
}
+ pub fn seed_history_ansi(&self, ansi: &str) {
+ if ansi.is_empty() {
+ return;
+ }
+ let Ok(mut core) = self.core.lock() else {
+ return;
+ };
+ core.terminal.write(ansi.as_bytes());
+ if let Ok(mut key_encoder) = self.key_encoder.lock() {
+ key_encoder.set_from_terminal(&core.terminal);
+ }
+ }
+
pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
if let Ok(mut core) = self.core.lock() {
let _ = core
@@ -1848,6 +1861,24 @@ mod tests {
}
#[test]
+ fn seeded_history_is_rendered_on_next_draw() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 100).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ pane.seed_history_ansi("restored history");
+
+ let backend = ratatui::backend::TestBackend::new(20, 5);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
+ .unwrap();
+
+ let buffer = terminal.backend().buffer();
+ let row = (0..16).map(|x| buffer[(x, 0)].symbol()).collect::<String>();
+ assert_eq!(row, "restored history");
+ }
+
+ #[test]
fn render_leaves_unknown_host_default_background_transparent() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
diff --git a/src/persist.rs b/src/persist.rs
index 1753d679..b2698364 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -1,13 +1,15 @@
//! Session persistence — save/restore workspaces, layouts, and working directories.
//!
//! Stored at `~/.config/herdr/session.json`.
+//! Optional pane screen history is stored separately at `session-history.json`.
mod io;
mod restore;
mod snapshot;
-pub use self::io::{clear, load, save};
+pub use self::io::{clear, clear_history, load, load_history, save};
pub use self::restore::restore;
pub use self::snapshot::{
- capture, DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
+ capture, capture_history, DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot,
+ SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
};
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"]);
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index be69b713..2aa49b68 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -46,6 +46,35 @@ impl TerminalRuntime {
.map(Self)
}
+ pub fn spawn_with_initial_history(
+ pane_id: PaneId,
+ rows: u16,
+ cols: u16,
+ cwd: std::path::PathBuf,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ default_shell: &str,
+ initial_history_ansi: Option<&str>,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> std::io::Result<Self> {
+ crate::pane::PaneRuntime::spawn_with_initial_history(
+ pane_id,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ default_shell,
+ initial_history_ansi,
+ events,
+ render_notify,
+ render_dirty,
+ )
+ .map(Self)
+ }
+
pub fn spawn_shell_command(
pane_id: PaneId,
rows: u16,
@@ -107,7 +136,7 @@ impl TerminalRuntime {
rows: u16,
cols: u16,
cwd: std::path::PathBuf,
- restore_plan: &crate::agent_resume::AgentResumePlan,
+ launch: crate::agent_resume::AgentResumeLaunch<'_>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
default_shell: &str,
@@ -120,7 +149,7 @@ impl TerminalRuntime {
rows,
cols,
cwd,
- restore_plan,
+ launch,
scrollback_limit_bytes,
host_terminal_theme,
default_shell,
@@ -199,6 +228,10 @@ impl TerminalRuntime {
self.0.recent_unwrapped_ansi(lines)
}
+ pub fn snapshot_history(&self) -> Option<String> {
+ self.0.snapshot_history()
+ }
+
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.0.extract_selection(selection)
}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index f104c882..2e5ae806 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -131,6 +131,9 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R
app.settings.list.selected,
);
}
+ SettingsSection::Experiments => {
+ render_settings_experiments(app, frame, content_area);
+ }
SettingsSection::Integrations => {
render_settings_integrations(app, frame, content_area);
}
@@ -375,3 +378,97 @@ fn render_settings_toggle(
1,
);
}
+
+fn render_settings_experiments(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+ let [desc_area, _, list_area] = Layout::vertical([
+ Constraint::Length(2),
+ Constraint::Length(1),
+ Constraint::Min(1),
+ ])
+ .areas::<3>(area);
+
+ super::widgets::render_modal_description(
+ frame,
+ desc_area,
+ "optional features that are off by default",
+ Style::default().fg(p.overlay1),
+ );
+
+ let marker = if app.pane_history_persistence_enabled() {
+ "[✓]"
+ } else {
+ "[ ]"
+ };
+ let style = if app.settings.list.selected == 0 {
+ Style::default()
+ .bg(p.surface0)
+ .fg(p.text)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.subtext0)
+ };
+ let row = Rect::new(list_area.x, list_area.y, list_area.width, 1);
+ frame.render_widget(
+ Paragraph::new(format!(" pane screen history {marker}")).style(style),
+ row,
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::app::{state::SettingsSection, Mode};
+ use ratatui::{backend::TestBackend, Terminal};
+
+ #[test]
+ fn experiments_pane_history_uses_settings_checkmark_marker() {
+ let mut app = AppState::test_new();
+ app.pane_history_persistence = true;
+ app.settings.section = SettingsSection::Experiments;
+ app.settings.list.selected = 0;
+ app.mode = Mode::Settings;
+
+ let mut terminal =
+ Terminal::new(TestBackend::new(80, 24)).expect("test terminal should initialize");
+ terminal
+ .draw(|frame| render_settings_overlay(&app, frame, Rect::new(0, 0, 80, 24)))
+ .expect("settings overlay should render");
+
+ let rendered = terminal
+ .backend()
+ .buffer()
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+
+ assert!(rendered.contains("pane screen history [✓]"));
+ assert!(!rendered.contains("[x]"));
+ }
+
+ #[test]
+ fn experiments_pane_history_keeps_empty_checkbox_marker_when_disabled() {
+ let mut app = AppState::test_new();
+ app.pane_history_persistence = false;
+ app.settings.section = SettingsSection::Experiments;
+ app.settings.list.selected = 0;
+ app.mode = Mode::Settings;
+
+ let mut terminal =
+ Terminal::new(TestBackend::new(80, 24)).expect("test terminal should initialize");
+ terminal
+ .draw(|frame| render_settings_overlay(&app, frame, Rect::new(0, 0, 80, 24)))
+ .expect("settings overlay should render");
+
+ let rendered = terminal
+ .backend()
+ .buffer()
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+
+ assert!(rendered.contains("pane screen history [ ]"));
+ }
+}