From 2a1a8d64de678fa0e9e2e2f097e8520095b11c5a Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 30 Jun 2026 14:09:09 +0300 Subject: [PATCH] feat: hide single-tab tab row refs #448 --- docs/next/CHANGELOG.md | 1 + .../src/content/docs/configuration.mdx | 3 + src/app/mod.rs | 2 + src/app/state.rs | 2 + src/config/model.rs | 6 + src/main.rs | 4 + src/ui.rs | 158 +++++++++++++++--- src/ui/tabs.rs | 1 - 8 files changed, 154 insertions(+), 23 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 5fc04839..0ab56198 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `herdr api schema` to inspect the bundled socket API schema, with `--json` for the full JSON Schema document and `--output PATH` for file output. - Added `herdr terminal session observe` for read-only live ANSI terminal streams that bridge processes can consume as newline-delimited JSON. - Added `herdr terminal session control` for bridge processes that need live ANSI frames plus input, resize, scroll, release, and takeover authority. +- Added `ui.hide_tab_bar_when_single_tab` to hide the tab row when a workspace has one tab. (#448) ### Changed - Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index e2c5dc6a..5425a3a5 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -311,6 +311,7 @@ prompt_new_tab_name = true pane_borders = true pane_gaps = true show_agent_labels_on_pane_borders = false +hide_tab_bar_when_single_tab = false agent_panel_sort = "spaces" accent = "cyan" ``` @@ -337,6 +338,8 @@ Set `pane_borders = false` to remove split pane borders. Set `pane_gaps = false` Set `show_agent_labels_on_pane_borders = true` if you want detected agent labels in split pane borders when no manual pane label is set. +Set `hide_tab_bar_when_single_tab = true` to hide the tab row when the active workspace has exactly one tab. New tabs can still be created with the configured keybinding. When a second tab appears, Herdr restores the tab row and resizes panes to make room for it. + ## Notifications Herdr can show popup notifications when agents finish or need input. diff --git a/src/app/mod.rs b/src/app/mod.rs index 05518134..cfcbe07e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -602,6 +602,7 @@ impl App { pane_borders: config.ui.pane_borders, pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, + hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, 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(), @@ -1363,6 +1364,7 @@ impl App { self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; + self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); self.state.agent_panel_scroll = 0; diff --git a/src/app/state.rs b/src/app/state.rs index 694ff252..99ab648b 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1366,6 +1366,7 @@ pub struct AppState { pub pane_borders: bool, pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, + pub hide_tab_bar_when_single_tab: 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`. @@ -1723,6 +1724,7 @@ impl AppState { pane_borders: true, pane_gaps: false, show_agent_labels_on_pane_borders: false, + hide_tab_bar_when_single_tab: false, pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, diff --git a/src/config/model.rs b/src/config/model.rs index 301b58ca..d81a6e64 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -793,6 +793,8 @@ pub struct UiConfig { pub pane_gaps: bool, /// Show agent labels in split pane borders when no manual pane label is set. Default: false. pub show_agent_labels_on_pane_borders: bool, + /// Hide the tab row when the workspace has one tab. Default: false. + pub hide_tab_bar_when_single_tab: bool, /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". pub agent_panel_sort: AgentPanelSortConfig, /// Accent color for highlights, borders, and navigation UI. @@ -984,6 +986,7 @@ impl Default for UiConfig { pane_borders: true, pane_gaps: true, show_agent_labels_on_pane_borders: false, + hide_tab_bar_when_single_tab: false, agent_panel_sort: AgentPanelSortConfig::Spaces, accent: "cyan".into(), toast: ToastConfig::default(), @@ -1195,17 +1198,20 @@ agent_panel_scope = "current" assert!(default_config.ui.pane_borders); assert!(default_config.ui.pane_gaps); assert!(!default_config.ui.show_agent_labels_on_pane_borders); + assert!(!default_config.ui.hide_tab_bar_when_single_tab); let toml = r#" [ui] pane_borders = false pane_gaps = true show_agent_labels_on_pane_borders = true +hide_tab_bar_when_single_tab = true "#; let config: Config = toml::from_str(toml).unwrap(); assert!(!config.ui.pane_borders); assert!(config.ui.pane_gaps); assert!(config.ui.show_agent_labels_on_pane_borders); + assert!(config.ui.hide_tab_bar_when_single_tab); } #[test] diff --git a/src/main.rs b/src/main.rs index 6a44131c..26639b3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -284,6 +284,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Show detected/reported agent labels in split pane borders when no manual pane name is set. # show_agent_labels_on_pane_borders = false +# Hide the tab row when a workspace has exactly one tab. +# New tabs can still be created with the configured keybinding. +# hide_tab_bar_when_single_tab = false + # Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue). # "workspaces" is accepted as an alias for "spaces". # agent_panel_sort = "spaces" diff --git a/src/ui.rs b/src/ui.rs index a48ded04..12acc8b6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -153,7 +153,7 @@ pub(crate) fn compute_view_without_resizing_panes( ); } -fn resize_background_tab_panes_to_terminal_area( +fn resize_background_tab_panes_to_area( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, terminal_area: Rect, @@ -169,6 +169,38 @@ fn resize_background_tab_panes_to_terminal_area( } } +fn resize_background_tab_panes_for_desktop( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + main_area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) { + for (ws_idx, ws) in app.workspaces.iter().enumerate() { + let (_, terminal_area) = desktop_tab_bar_and_terminal_area(app, ws, main_area); + for (tab_idx, tab) in ws.tabs.iter().enumerate() { + if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() { + continue; + } + resize_tab_panes(app, terminal_runtimes, tab, terminal_area, cell_size); + } + } +} + +fn desktop_tab_bar_and_terminal_area( + app: &AppState, + ws: &crate::workspace::Workspace, + main_area: Rect, +) -> (Rect, Rect) { + let hide_single_tab_bar = app.hide_tab_bar_when_single_tab && ws.tabs.len() == 1; + if !hide_single_tab_bar && main_area.height > 1 { + let [tab_bar_rect, terminal_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area); + (tab_bar_rect, terminal_area) + } else { + (Rect::default(), main_area) + } +} + fn compute_view_internal( app: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -194,14 +226,11 @@ fn compute_view_internal( let [sidebar_area, main_area] = Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area); - let has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some(); - let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 { - let [tab_bar_rect, terminal_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area); - (tab_bar_rect, terminal_area) - } else { - (Rect::default(), main_area) - }; + let (tab_bar_rect, terminal_area) = app + .active + .and_then(|i| app.workspaces.get(i)) + .map(|ws| desktop_tab_bar_and_terminal_area(app, ws, main_area)) + .unwrap_or((Rect::default(), main_area)); if !app.sidebar_collapsed { app.workspace_scroll = normalized_workspace_scroll(app, sidebar_area, app.workspace_scroll); @@ -223,7 +252,7 @@ fn compute_view_internal( let tab_bar_view = app .active - .and_then(|i| app.workspaces.get(i)) + .and_then(|ws_idx| app.workspaces.get(ws_idx)) .map(|ws| { compute_tab_bar_view( ws, @@ -256,12 +285,7 @@ fn compute_view_internal( cell_size, ); if resize_panes { - resize_background_tab_panes_to_terminal_area( - app, - terminal_runtimes, - terminal_area, - cell_size, - ); + resize_background_tab_panes_for_desktop(app, terminal_runtimes, main_area, cell_size); } let toast_hit_area = app @@ -337,12 +361,7 @@ fn compute_mobile_view( cell_size, ); if resize_panes { - resize_background_tab_panes_to_terminal_area( - app, - terminal_runtimes, - terminal_area, - cell_size, - ); + resize_background_tab_panes_to_area(app, terminal_runtimes, terminal_area, cell_size); } let header_hits = compute_mobile_header_hit_areas(app, header_rect); @@ -724,6 +743,101 @@ mod tests { assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18)); } + #[test] + fn hide_tab_bar_when_single_tab_toggles_geometry_with_tab_count() { + let mut app = crate::app::state::AppState::test_new(); + app.hide_tab_bar_when_single_tab = true; + 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)); + let single_tab_terminal_area = app.view.terminal_area; + assert_eq!(app.view.tab_bar_rect, Rect::default()); + assert_eq!(single_tab_terminal_area, Rect::new(26, 0, 54, 20)); + assert!(app.view.tab_hit_areas.is_empty()); + assert_eq!(app.view.new_tab_hit_area, Rect::default()); + + app.workspaces[0].test_add_tab(Some("logs")); + compute_view(&mut app, Rect::new(0, 0, 80, 20)); + + assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1)); + assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19)); + assert_eq!(app.view.tab_hit_areas.len(), 2); + assert!(app.view.tab_hit_areas.iter().all(|rect| rect.width > 0)); + assert!(app.view.new_tab_hit_area.width > 0); + + assert!(app.workspaces[0].close_tab(1)); + compute_view(&mut app, Rect::new(0, 0, 80, 20)); + + assert_eq!(app.view.terminal_area, single_tab_terminal_area); + assert_eq!(app.view.tab_bar_rect, Rect::default()); + assert!(app.view.tab_hit_areas.is_empty()); + assert_eq!(app.view.new_tab_hit_area, Rect::default()); + } + + #[tokio::test] + async fn hide_tab_bar_when_single_tab_resizes_background_tabs_per_workspace() { + let mut app = crate::app::state::AppState::test_new(); + app.hide_tab_bar_when_single_tab = true; + + let mut one_tab_workspace = Workspace::test_new("one"); + let one_tab_pane = one_tab_workspace.tabs[0].root_pane; + let one_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); + one_tab_workspace.tabs[0] + .runtimes + .insert(one_tab_pane, one_tab_runtime); + + let mut two_tab_workspace = Workspace::test_new("two"); + let background_tab = two_tab_workspace.test_add_tab(Some("logs")); + let two_tab_pane = two_tab_workspace.tabs[background_tab].root_pane; + let two_tab_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); + two_tab_workspace.tabs[background_tab] + .runtimes + .insert(two_tab_pane, two_tab_runtime); + + app.workspaces = vec![one_tab_workspace, two_tab_workspace]; + app.active = Some(0); + app.selected = 0; + app.mode = Mode::Terminal; + + compute_view(&mut app, Rect::new(0, 0, 80, 20)); + + let one_tab_size = app.workspaces[0].tabs[0].runtimes[&one_tab_pane].current_size(); + let two_tab_size = + app.workspaces[1].tabs[background_tab].runtimes[&two_tab_pane].current_size(); + assert_eq!(one_tab_size, (20, 53)); + assert_eq!(two_tab_size, (19, 53)); + } + + #[tokio::test] + async fn mobile_background_tabs_use_mobile_terminal_area() { + let mut app = crate::app::state::AppState::test_new(); + + let mut workspace = Workspace::test_new("mobile"); + let background_tab = workspace.test_add_tab(Some("logs")); + let background_pane = workspace.tabs[background_tab].root_pane; + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""); + workspace.tabs[background_tab] + .runtimes + .insert(background_pane, runtime); + + app.workspaces = vec![workspace]; + app.active = Some(0); + app.selected = 0; + app.mode = Mode::Terminal; + + compute_view(&mut app, Rect::new(0, 0, 44, 20)); + + assert_eq!(app.view.layout, ViewLayout::Mobile); + assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18)); + assert_eq!( + app.workspaces[0].tabs[background_tab].runtimes[&background_pane].current_size(), + (18, 43) + ); + } + #[test] fn product_announcement_renders_above_config_diagnostic() { let mut app = crate::app::state::AppState::test_new(); diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 4032d353..02289581 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -257,7 +257,6 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { let Some(ws) = app.workspaces.get(active_ws_idx) else { return; }; - let p = &app.palette; frame.render_widget(