diff --git a/docs/next/README.md b/docs/next/README.md index 2df8a523..5a447d8c 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -79,7 +79,9 @@ tmux gives you persistence and panes, but it was built before agents existed. gu ## persistence -start herdr where the work lives. locally, run `herdr`. it starts or attaches to the background session automatically, with no socket setup. run your agents, split panes, do your work. press `ctrl+b q` to detach. close your terminal, close your laptop; your agents keep running. open a new terminal, run `herdr`, you're back. same session, same panes, same agents. +start herdr where the work lives. locally, run `herdr`. it starts or attaches to the background session automatically, with no socket setup. run your agents, split panes, do your work. press `ctrl+b q` to detach. close your terminal, close your laptop; your agents keep running. open a new terminal, run `herdr`, you're back. same session, same panes, same agents. if you stop the server and later start herdr again, restored panes bring back workspaces, tabs, cwd, layout, and focus. + +pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. enable it with `[experimental] pane_history = true` or settings > experiments > pane screen history. when enabled, herdr writes saved pane history to `session-history.json` next to `session.json`; treat the herdr config/session directory like terminal history. ### from anywhere @@ -138,7 +140,7 @@ not a gui window, not a web dashboard, not electron. herdr runs inside whatever - **mouse-native** — click panes/tabs/workspaces/agents, drag borders, select text to copy, right-click menus; not keyboard-only - **notifications** — sounds and toasts for background events; tab-aware suppression - **18 built-in themes** — catppuccin, terminal, tokyo night, gruvbox, one, solarized, kanagawa, rosé pine, vesper, and light variants for the main palettes -- **session persistence** — pane processes survive client detach; sessions restore after full restart +- **session persistence** — pane processes survive client detach; sessions restore panes after full restart, with opt-in recent screen history ## agents can use herdr too diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 735befb2..470e2709 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -304,6 +304,21 @@ scrollback_limit_bytes = 10485760 Existing panes keep their current buffer until they are recreated. +## Pane screen history + +By default, full session restart restores workspaces, tabs, panes, cwd, layout, and focus without saving pane contents. + +Pane screen history is off by default. Pane output can include secrets, tokens, prompts, and command output, so enable it only when you want Herdr to save recent pane contents across full server restarts: + +```toml +[experimental] +pane_history = true +``` + +You can also toggle it from Settings > Experiments > pane screen history. + +When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`. + ## Nested launches Herdr normally protects you from launching Herdr inside Herdr. diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index d6c02f0a..ad83e2f6 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -29,6 +29,10 @@ If you want to end the session and stop its panes, stop the default server: herdr server stop ``` +When Herdr starts again, it restores the saved workspaces, tabs, panes, cwd, layout, and focus. + +Pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. Enable it from Settings > Experiments > pane screen history or with `[experimental] pane_history = true`. Herdr stores saved pane history in `session-history.json` next to `session.json`; treat the Herdr config/session directory like terminal history. + ## Named sessions Use named sessions when you want independent Herdr servers. 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, }; } @@ -469,6 +508,57 @@ mod tests { assert_eq!(state.mode, Mode::Settings); } + #[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"]); @@ -499,6 +589,23 @@ mod tests { assert_eq!(app.state.settings.list.selected, 0); } + #[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"]); @@ -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::(); 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, pub(crate) selection_autoscroll_deadline: Option, pub(crate) session_save_deadline: Option, + pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option, 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") { @@ -1847,6 +1861,31 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[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(); 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, @@ -764,6 +766,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(); @@ -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, + history_ansi: Option<&'a str>, +} + fn active_pending_release( pending_release: &Mutex>, now: std::time::Instant, @@ -383,6 +389,34 @@ impl PaneRuntime { events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, + ) -> std::io::Result { + 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, + render_notify: Arc, + render_dirty: Arc, ) -> std::io::Result { let shell = pane_shell(default_shell); let mut cmd = CommandBuilder::new(&shell); @@ -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, render_dirty: Arc, ) -> std::io::Result { - 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, cmd: CommandBuilder, spawn_error_message: &'static str, - initial_detected_agent: Option, + initial_state: SpawnInitialState<'_>, ) -> std::io::Result { 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 { + 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 { 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 @@ -1847,6 +1860,24 @@ mod tests { assert!(end.request_render); } + #[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::(); + assert_eq!(row, "restored history"); + } + #[test] fn render_leaves_unknown_host_default_background_transparent() { let (tx, _rx) = mpsc::channel(4); 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 { } pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> { + save_json_to_path(path, snapshot) +} + +fn save_json_to_path(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 { let path = session_path(); if !path.exists() { @@ -106,10 +141,44 @@ pub fn load() -> Option { } } +pub fn load_history() -> Option { + 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, +} /// 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, + agent_restore: &mut AgentRestoreState<'_>, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -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 @@ -30,6 +30,24 @@ pub struct SessionSnapshot { pub collapsed_space_keys: std::collections::HashSet, } +#[derive(Serialize, Deserialize)] +pub struct SessionHistorySnapshot { + /// Format version follows the matching session snapshot version. + #[serde(default)] + pub version: u32, + pub workspaces: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct WorkspaceHistorySnapshot { + pub tabs: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct TabHistorySnapshot { + pub panes: HashMap, +} + #[derive(Serialize, Deserialize)] pub struct WorkspaceSnapshot { #[serde(default)] @@ -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 { + 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 { + 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 { migrate_snapshot(raw) } +pub(super) fn parse_history_snapshot(content: &str) -> Result { + let snapshot = + serde_json::from_str::(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 { serde_json::from_str::(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 { match &tab.layout { LayoutSnapshot::Split { ratio, .. } => Some(*ratio), @@ -600,6 +696,42 @@ mod tests { assert_eq!(restored.sidebar_section_split, None); } + #[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(); @@ -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, + render_notify: Arc, + render_dirty: Arc, + ) -> std::io::Result { + 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 { + self.0.snapshot_history() + } + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option { 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::(); + + 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::(); + + assert!(rendered.contains("pane screen history [ ]")); + } +} diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index f48619d3..f9084f6a 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -106,7 +106,27 @@ fn wait_for_socket(path: &Path, timeout: Duration) { } fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr { - spawn_herdr_with_path(config_home, runtime_dir, socket_path, None) + spawn_herdr_with_config( + config_home, + runtime_dir, + socket_path, + None, + "onboarding = false\n", + ) +} + +fn spawn_herdr_with_pane_history( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, +) -> SpawnedHerdr { + spawn_herdr_with_config( + config_home, + runtime_dir, + socket_path, + None, + "onboarding = false\n[experimental]\npane_history = true\n", + ) } fn app_dir_name() -> &'static str { @@ -200,12 +220,28 @@ fn spawn_herdr_with_path( socket_path: &Path, path_override: Option<&Path>, ) -> SpawnedHerdr { - fs::create_dir_all(config_home.join("herdr")).unwrap(); + spawn_herdr_with_config( + config_home, + runtime_dir, + socket_path, + path_override, + "onboarding = false\n", + ) +} + +fn spawn_herdr_with_config( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, + path_override: Option<&Path>, + config_toml: &str, +) -> SpawnedHerdr { + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); fs::create_dir_all(runtime_dir).unwrap(); register_runtime_dir(runtime_dir); fs::write( - config_home.join("herdr/config.toml"), - "onboarding = false\n", + config_home.join(app_dir_name()).join("config.toml"), + config_toml, ) .unwrap(); @@ -284,6 +320,28 @@ fn parse_cli_json_output(args: &[&str], output: std::process::Output) -> serde_j }) } +fn wait_until(timeout: Duration, interval: Duration, mut condition: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if condition() { + return true; + } + thread::sleep(interval); + } + false +} + +fn pane_read_recent_contains(socket_path: &Path, pane_id: &str, expected: &str) -> bool { + let output = run_cli( + socket_path, + &["pane", "read", pane_id, "--source", "recent"], + ); + if !output.status.success() { + return false; + } + String::from_utf8_lossy(&output.stdout).contains(expected) +} + fn process_exists(pid: u32) -> bool { let result = unsafe { libc::kill(pid as i32, 0) }; if result == 0 { @@ -1210,6 +1268,98 @@ fn server_stop_command_shuts_down_running_server() { cleanup_spawned_herdr(herdr, base); } +#[test] +fn server_stop_then_restart_restores_pane_history() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let marker = "PERSISTED_HISTORY_AFTER_STOP"; + + let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let created = run_cli_json( + &socket_path, + &[ + "workspace", + "create", + "--cwd", + base.to_str().expect("test path should be utf-8"), + "--label", + "history-restart", + ], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("workspace create should return root pane id") + .to_string(); + let sent = run_cli( + &socket_path, + &["pane", "send-text", &pane_id, &format!("echo {marker}\n")], + ); + assert!( + sent.status.success(), + "stderr: {}", + String::from_utf8_lossy(&sent.stderr) + ); + assert!( + wait_until(Duration::from_secs(3), Duration::from_millis(25), || { + pane_read_recent_contains(&socket_path, &pane_id, marker) + }), + "pane should contain marker before server stop" + ); + + let stopped = run_cli(&socket_path, &["server", "stop"]); + assert!( + stopped.status.success(), + "stderr: {}", + String::from_utf8_lossy(&stopped.stderr) + ); + + let pid = herdr.child.process_id(); + let exit_status = herdr.child.wait().unwrap(); + unregister_spawned_herdr_pid(pid); + assert!(exit_status.success(), "server stop should exit cleanly"); + drop(herdr); + + let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); + let workspace_id = workspaces["result"]["workspaces"] + .as_array() + .expect("workspace.list should return workspaces") + .iter() + .find(|workspace| workspace["label"] == "history-restart") + .and_then(|workspace| workspace["workspace_id"].as_str()) + .expect("restored workspace should exist") + .to_string(); + let panes = run_cli_json( + &socket_path, + &["pane", "list", "--workspace", &workspace_id], + ); + let restored_pane_id = panes["result"]["panes"] + .as_array() + .expect("pane.list should return panes") + .first() + .and_then(|pane| pane["pane_id"].as_str()) + .expect("restored pane should exist") + .to_string(); + + assert!( + wait_until(Duration::from_secs(3), Duration::from_millis(25), || { + pane_read_recent_contains(&socket_path, &restored_pane_id, marker) + }), + "restarted server should restore saved pane history" + ); + + cleanup_spawned_herdr(restarted, base); +} + #[test] fn workspace_and_pane_management_commands_work() { let base = unique_test_dir();