feat: hide single-tab tab row

refs #448
This commit is contained in:
Ogulcan Celik
2026-06-30 14:10:12 +03:00
parent f54d8e8c0a
commit 2a1a8d64de
8 changed files with 154 additions and 23 deletions
+1
View File
@@ -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.
@@ -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.
+2
View File
@@ -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;
+2
View File
@@ -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,
+6
View File
@@ -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]
+4
View File
@@ -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"
+136 -22
View File
@@ -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();
-1
View File
@@ -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(