diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 6 | ||||
| -rw-r--r-- | src/app/config_io.rs | 8 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 126 | ||||
| -rw-r--r-- | src/app/mod.rs | 39 | ||||
| -rw-r--r-- | src/app/session.rs | 5 | ||||
| -rw-r--r-- | src/app/state.rs | 9 | ||||
| -rw-r--r-- | src/config/model.rs | 17 | ||||
| -rw-r--r-- | src/main.rs | 2 | ||||
| -rw-r--r-- | src/pane.rs | 83 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 31 | ||||
| -rw-r--r-- | src/persist.rs | 6 | ||||
| -rw-r--r-- | src/persist/io.rs | 134 | ||||
| -rw-r--r-- | src/persist/restore.rs | 206 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 210 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 37 | ||||
| -rw-r--r-- | src/ui/settings.rs | 97 |
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 [ ]")); + } +} |
