aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config/model.rs6
-rw-r--r--src/main.rs4
-rw-r--r--src/ui.rs158
-rw-r--r--src/ui/tabs.rs1
6 files changed, 150 insertions, 23 deletions
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);
@@ -725,6 +744,101 @@ mod tests {
}
#[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();
app.workspaces = vec![Workspace::test_new("one")];
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(