aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorzhugehyuk <icedac@users.noreply.github.com>2026-05-25 03:28:33 +0900
committerGitHub <noreply@github.com>2026-05-24 21:28:33 +0300
commit43c6ca3a5dbdcefc6c6dc29be9ce81d990baf39e (patch)
tree73be12f81129dab324ea08901c733a6a56ec0e61 /src/app
parent5ce738cf5397d0d8a50ed1aa6335297073cc0f0f (diff)
feat: persist pane history across restarts (#248)
refs #217 Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/app')
-rw-r--r--src/app/config_io.rs8
-rw-r--r--src/app/input/mod.rs3
-rw-r--r--src/app/input/settings.rs126
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/app/session.rs5
-rw-r--r--src/app/state.rs9
6 files changed, 185 insertions, 5 deletions
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(),