aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-30 03:59:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-30 04:00:33 +0300
commitf54d8e8c0aee5d3ec9724234d1bcd418bff8ff2f (patch)
tree67eb7fd76f668feab4b5a4ef245eea854a04d42d /src
parent5e2dc40691e30f90c5fe3b1e60d5074ceb358556 (diff)
feat: add hidden collapsed sidebar mode
refs #842
Diffstat (limited to 'src')
-rw-r--r--src/app/input/sidebar.rs14
-rw-r--r--src/app/mod.rs27
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config.rs6
-rw-r--r--src/config/model.rs30
-rw-r--r--src/main.rs3
-rw-r--r--src/ui.rs37
-rw-r--r--src/ui/sidebar.rs4
8 files changed, 115 insertions, 8 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index fd631cd4..39affc83 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -497,6 +497,7 @@ mod tests {
use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path};
use crate::{
app::state::{AgentPanelSort, DragTarget, Mode},
+ config::SidebarCollapsedModeConfig,
detect::Agent,
workspace::Workspace,
};
@@ -969,6 +970,19 @@ mod tests {
}
#[test]
+ fn hidden_collapsed_sidebar_has_no_mouse_expand_hotspot() {
+ let mut app = app_for_mouse_test();
+ app.state.sidebar_collapsed = true;
+ app.state.sidebar_collapsed_mode = SidebarCollapsedModeConfig::Hidden;
+ app.state.view.sidebar_rect = Rect::new(0, 0, 0, 20);
+ app.state.view.terminal_area = Rect::new(0, 0, 80, 20);
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 19));
+
+ assert!(app.state.sidebar_collapsed);
+ }
+
+ #[test]
fn clicking_expanded_sidebar_toggle_collapses_sidebar() {
let mut app = app_for_mouse_test();
app.state.sidebar_collapsed = false;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 2860664f..05518134 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -588,6 +588,7 @@ impl App {
sidebar_width_source,
sidebar_width_auto: false,
sidebar_collapsed: false,
+ sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode,
sidebar_section_split,
agent_panel_sort,
next_agent_state_change_seq: 0,
@@ -1340,6 +1341,7 @@ impl App {
}
self.state.sidebar_min_width = config.ui.sidebar_min_width;
self.state.sidebar_max_width = config.ui.sidebar_max_width;
+ self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode;
self.state.mobile_width_threshold = config.ui.mobile_width_threshold;
// Re-clamp the live width to the new bounds. No source guard — bounds
// always apply, including to widths owned by Persisted or Manual.
@@ -2500,6 +2502,31 @@ mod tests {
}
#[test]
+ fn reload_config_updates_sidebar_collapsed_mode() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-sidebar-collapsed-mode");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ assert_eq!(
+ app.state.sidebar_collapsed_mode,
+ crate::config::SidebarCollapsedModeConfig::Compact
+ );
+
+ std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(
+ app.state.sidebar_collapsed_mode,
+ crate::config::SidebarCollapsedModeConfig::Hidden
+ );
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn reload_config_updates_sidebar_bounds_and_reclamps() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-sidebar-bounds");
diff --git a/src/app/state.rs b/src/app/state.rs
index 9e5dbed1..694ff252 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1349,6 +1349,7 @@ pub struct AppState {
pub sidebar_width_source: SidebarWidthSource,
pub sidebar_width_auto: bool,
pub sidebar_collapsed: bool,
+ pub sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig,
/// Ratio of sidebar height allocated to the workspaces section.
pub sidebar_section_split: f32,
pub agent_panel_sort: AgentPanelSort,
@@ -1708,6 +1709,7 @@ impl AppState {
sidebar_width_source: SidebarWidthSource::ConfigDefault,
sidebar_width_auto: false,
sidebar_collapsed: false,
+ sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact,
sidebar_section_split: 0.5,
agent_panel_sort: AgentPanelSort::Spaces,
next_agent_state_change_seq: 0,
diff --git a/src/config.rs b/src/config.rs
index 12e9cf1c..5ddc4c60 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -19,9 +19,9 @@ pub use self::{
},
model::{
validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport,
- ConfigReloadStatus, NewTerminalCwdConfig, ShellModeConfig, ToastClipboardPosition,
- ToastConfig, ToastDelivery, ToastHerdrPosition, UpdateChannelConfig,
- MAX_TOAST_DELAY_SECONDS,
+ ConfigReloadStatus, NewTerminalCwdConfig, ShellModeConfig, SidebarCollapsedModeConfig,
+ ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition,
+ UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
diff --git a/src/config/model.rs b/src/config/model.rs
index 92192c26..301b58ca 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -106,6 +106,14 @@ impl AgentPanelSortConfig {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SidebarCollapsedModeConfig {
+ #[default]
+ Compact,
+ Hidden,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RightClickPassthroughModifierConfig(Option<KeyModifiers>);
@@ -763,6 +771,8 @@ pub struct UiConfig {
pub sidebar_min_width: u16,
/// Maximum sidebar width (columns) when expanded. Default: 36.
pub sidebar_max_width: u16,
+ /// Collapsed sidebar presentation. Default: compact.
+ pub sidebar_collapsed_mode: SidebarCollapsedModeConfig,
/// Terminal width at or below which Herdr uses the mobile single-column layout. Default: 64.
pub mobile_width_threshold: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
@@ -963,6 +973,7 @@ impl Default for UiConfig {
sidebar_width: 26,
sidebar_min_width: 18,
sidebar_max_width: 36,
+ sidebar_collapsed_mode: SidebarCollapsedModeConfig::Compact,
mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD,
mouse_capture: true,
right_click_passthrough_modifier: RightClickPassthroughModifierConfig::default(),
@@ -1311,6 +1322,25 @@ mobile_width_threshold = 96
}
#[test]
+ fn sidebar_collapsed_mode_defaults_compact_and_parses_hidden() {
+ let default_config = Config::default();
+ assert_eq!(
+ default_config.ui.sidebar_collapsed_mode,
+ SidebarCollapsedModeConfig::Compact
+ );
+
+ let toml = r#"
+[ui]
+sidebar_collapsed_mode = "hidden"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(
+ config.ui.sidebar_collapsed_mode,
+ SidebarCollapsedModeConfig::Hidden
+ );
+ }
+
+ #[test]
fn validated_sidebar_bounds_rejects_inverted() {
assert_eq!(validated_sidebar_bounds(18, 36), Some((18, 36)));
assert_eq!(validated_sidebar_bounds(20, 20), Some((20, 20)));
diff --git a/src/main.rs b/src/main.rs
index ea46585f..6a44131c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -244,6 +244,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Maximum sidebar width when expanded (columns)
# sidebar_max_width = 36
+# Collapsed sidebar presentation: "compact" keeps the narrow status rail, "hidden" uses zero width.
+# sidebar_collapsed_mode = "compact"
+
# Terminal width at or below which Herdr uses the mobile single-column layout.
# Increase this for foldables, tablets, or wide phone terminals.
# mobile_width_threshold = 64
diff --git a/src/ui.rs b/src/ui.rs
index 041cb232..a48ded04 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -182,7 +182,10 @@ fn compute_view_internal(
}
let sidebar_w = if app.sidebar_collapsed {
- COLLAPSED_WIDTH
+ match app.sidebar_collapsed_mode {
+ crate::config::SidebarCollapsedModeConfig::Compact => COLLAPSED_WIDTH,
+ crate::config::SidebarCollapsedModeConfig::Hidden => 0,
+ }
} else {
app.sidebar_width
.clamp(app.sidebar_min_width, app.sidebar_max_width)
@@ -385,10 +388,12 @@ pub fn render_with_runtime_registry(
if app.view.layout == ViewLayout::Mobile {
render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect);
- } else if app.sidebar_collapsed {
- render_sidebar_collapsed(app, frame, sidebar_area);
- } else {
- render_sidebar(app, terminal_runtimes, frame, sidebar_area);
+ } else if sidebar_area.width > 0 {
+ if app.sidebar_collapsed {
+ render_sidebar_collapsed(app, frame, sidebar_area);
+ } else {
+ render_sidebar(app, terminal_runtimes, frame, sidebar_area);
+ }
}
if app.view.layout != ViewLayout::Mobile {
render_tab_bar(app, frame, tab_bar_area);
@@ -791,6 +796,28 @@ mod tests {
}
#[test]
+ fn hidden_collapsed_sidebar_uses_full_width_terminal_area() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.sidebar_collapsed = true;
+ app.sidebar_collapsed_mode = crate::config::SidebarCollapsedModeConfig::Hidden;
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+
+ assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 0, 20));
+ assert_eq!(app.view.tab_bar_rect, Rect::new(0, 0, 80, 1));
+ assert_eq!(app.view.terminal_area, Rect::new(0, 1, 80, 19));
+ assert!(app.view.workspace_card_areas.is_empty());
+
+ let backend = TestBackend::new(80, 20);
+ let mut terminal = Terminal::new(backend).unwrap();
+ terminal.draw(|frame| render(&app, frame)).unwrap();
+ }
+
+ #[test]
fn collapsed_sidebar_keeps_active_workspace_highlight_in_terminal_mode() {
let mut app = crate::app::state::AppState::test_new();
app.sidebar_collapsed = true;
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index a7b0ca92..72b344fb 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -636,6 +636,10 @@ pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect
/// Collapsed sidebar: workspace glance on top, compact agent list below.
pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+
let is_navigating = matches!(app.mode, Mode::Navigate);
let p = &app.palette;