Skip to content

Commit 02fe7d7

Browse files
authored
feat: support bottom tab bar placement (#2118)
* feat: support bottom tab bar placement * fix: preserve mouse cleanup under bottom mode bar refs #2117
1 parent b1d14fe commit 02fe7d7

10 files changed

Lines changed: 197 additions & 11 deletions

File tree

docs/next/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
### Added
6+
- Added `ui.tab_bar_position = "bottom"` to place the desktop tab row below terminal panes.
7+
58
### Changed
69
- Agent status indicators now use the same static workspace marks across the sidebar, navigator, and mobile views, eliminating continuous spinner rendering while agents work.
710
- Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0.

docs/next/website/src/content/docs/configuration.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ Color values accept hex, named colors, `rgb(r,g,b)`, or reset aliases like `rese
256256

257257
The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference](/docs/config-reference/) for sizing, collapsed mode, Agent panel ordering, mouse behavior, pane borders, and other presentation settings.
258258

259+
Set `tab_bar_position = "bottom"` under `[ui]` to place the desktop tab row below the terminal panes. Prefix, Navigate, Copy, and Resize mode bars temporarily replace the bottom tab row while active. The default is `"top"`.
260+
259261
### Sidebar row layouts
260262

261263
The expanded desktop sidebar renders each inner array in `rows` as one line. These are the complete default layouts:

docs/next/website/src/data/config-reference.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,16 @@
670670
"default": "false",
671671
"description": "Hide the tab row when the workspace has one tab."
672672
},
673+
{
674+
"key": "ui.tab_bar_position",
675+
"type": "enum",
676+
"default": "\"top\"",
677+
"description": "Place the desktop tab row above or below the terminal panes.",
678+
"values": [
679+
"top",
680+
"bottom"
681+
]
682+
},
673683
{
674684
"key": "ui.agent_panel_sort",
675685
"type": "enum",

src/app/input/mouse.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,10 @@ impl AppState {
483483
}
484484
}
485485

486+
if self.mode_bar_covers_tab_row(mouse.column, mouse.row) {
487+
return None;
488+
}
489+
486490
if self.on_tab_scroll_left_button(mouse.column, mouse.row) {
487491
self.scroll_tabs_left();
488492
return None;
@@ -912,6 +916,9 @@ impl AppState {
912916
}
913917
}
914918

919+
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
920+
if self.mode_bar_covers_tab_row(mouse.column, mouse.row) => {}
921+
915922
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
916923
if self.on_tab_bar(mouse.column, mouse.row) =>
917924
{
@@ -1063,7 +1070,8 @@ impl AppState {
10631070
}
10641071

10651072
MouseEventKind::Down(MouseButton::Right)
1066-
if self.tab_at(mouse.column, mouse.row).is_some() =>
1073+
if !self.mode_bar_covers_tab_row(mouse.column, mouse.row)
1074+
&& self.tab_at(mouse.column, mouse.row).is_some() =>
10671075
{
10681076
if let (Some(ws_idx), Some(tab_idx)) =
10691077
(self.active, self.tab_at(mouse.column, mouse.row))
@@ -1272,6 +1280,15 @@ impl AppState {
12721280
})
12731281
}
12741282

1283+
fn mode_bar_covers_tab_row(&self, col: u16, row: u16) -> bool {
1284+
self.tab_bar_position == crate::config::TabBarPositionConfig::Bottom
1285+
&& matches!(
1286+
self.mode,
1287+
Mode::Navigate | Mode::Prefix | Mode::Copy | Mode::Resize
1288+
)
1289+
&& self.on_tab_bar(col, row)
1290+
}
1291+
12751292
pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool {
12761293
let area = self.view.tab_bar_rect;
12771294
area.width > 0
@@ -3392,6 +3409,63 @@ mod tests {
33923409
assert_eq!(app.state.workspaces[0].active_tab, 0);
33933410
}
33943411

3412+
#[test]
3413+
fn bottom_mode_bar_consumes_hidden_tab_mouse_actions() {
3414+
let mut app = app_for_mouse_test();
3415+
let mut ws = Workspace::test_new("one");
3416+
ws.test_add_tab(Some("two"));
3417+
app.state.workspaces = vec![ws];
3418+
app.state.active = Some(0);
3419+
app.state.selected = 0;
3420+
app.state.mode = Mode::Prefix;
3421+
app.state.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
3422+
3423+
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
3424+
let second_tab = app.state.view.tab_hit_areas[1];
3425+
let new_tab = app.state.view.new_tab_hit_area;
3426+
3427+
app.handle_mouse(mouse(
3428+
MouseEventKind::Down(MouseButton::Left),
3429+
second_tab.x,
3430+
second_tab.y,
3431+
));
3432+
app.handle_mouse(mouse(
3433+
MouseEventKind::Up(MouseButton::Left),
3434+
second_tab.x,
3435+
second_tab.y,
3436+
));
3437+
app.handle_mouse(mouse(
3438+
MouseEventKind::ScrollDown,
3439+
second_tab.x,
3440+
second_tab.y,
3441+
));
3442+
app.handle_mouse(mouse(
3443+
MouseEventKind::Down(MouseButton::Right),
3444+
second_tab.x,
3445+
second_tab.y,
3446+
));
3447+
app.handle_mouse(mouse(
3448+
MouseEventKind::Down(MouseButton::Left),
3449+
new_tab.x,
3450+
new_tab.y,
3451+
));
3452+
3453+
app.state.drag = Some(DragState {
3454+
target: DragTarget::SidebarDivider,
3455+
});
3456+
app.handle_mouse(mouse(
3457+
MouseEventKind::Up(MouseButton::Left),
3458+
second_tab.x,
3459+
second_tab.y,
3460+
));
3461+
3462+
assert_eq!(app.state.workspaces[0].active_tab, 0);
3463+
assert_eq!(app.state.workspaces[0].tabs.len(), 2);
3464+
assert!(app.state.context_menu.is_none());
3465+
assert!(app.state.tab_press.is_none());
3466+
assert!(app.state.drag.is_none());
3467+
}
3468+
33953469
#[test]
33963470
fn right_click_inactive_tab_opens_menu_without_switching_tabs() {
33973471
let mut app = app_for_mouse_test();

src/app/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ impl App {
642642
pane_gaps: config.ui.pane_gaps,
643643
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
644644
hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab,
645+
tab_bar_position: config.ui.tab_bar_position,
645646
pane_history_persistence: config.experimental.pane_history,
646647
reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime,
647648
cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(),
@@ -1450,6 +1451,7 @@ impl App {
14501451
self.state.show_agent_labels_on_pane_borders =
14511452
config.ui.show_agent_labels_on_pane_borders;
14521453
self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab;
1454+
self.state.tab_bar_position = config.ui.tab_bar_position;
14531455
self.state.agent_panel_sort =
14541456
agent_panel_sort_from_config(config.ui.agent_panel_sort);
14551457
self.state.sidebar_agents = config.ui.sidebar.agents.clone();

src/app/state.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig, ToastDelivery};
1+
use crate::config::{
2+
Keybinds, NewTerminalCwdConfig, SoundConfig, TabBarPositionConfig, ToastConfig, ToastDelivery,
3+
};
24
use crossterm::event::{KeyCode, KeyModifiers};
35
use ratatui::layout::{Direction, Rect};
46
use ratatui::style::Color;
@@ -1525,6 +1527,7 @@ pub struct AppState {
15251527
pub pane_gaps: bool,
15261528
pub show_agent_labels_on_pane_borders: bool,
15271529
pub hide_tab_bar_when_single_tab: bool,
1530+
pub tab_bar_position: TabBarPositionConfig,
15281531
pub pane_history_persistence: bool,
15291532
/// Expose the focused pane's cursor anchor to the outer terminal even when
15301533
/// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`.
@@ -1899,6 +1902,7 @@ impl AppState {
18991902
pane_gaps: false,
19001903
show_agent_labels_on_pane_borders: false,
19011904
hide_tab_bar_when_single_tab: false,
1905+
tab_bar_position: TabBarPositionConfig::Top,
19021906
pane_history_persistence: false,
19031907
reveal_hidden_cursor_for_cjk_ime: false,
19041908
cjk_ime_agent_filter_configured: false,

src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ pub use self::{
2121
model::{
2222
validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport,
2323
ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig,
24-
SidebarCollapsedModeConfig, ToastClipboardPosition, ToastConfig, ToastDelivery,
25-
ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
24+
SidebarCollapsedModeConfig, TabBarPositionConfig, ToastClipboardPosition, ToastConfig,
25+
ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
2626
},
2727
sidebar::{
2828
AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle,

src/config/model.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,14 @@ pub struct WorktreesConfig {
772772
pub directory: String,
773773
}
774774

775+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
776+
#[serde(rename_all = "snake_case")]
777+
pub enum TabBarPositionConfig {
778+
#[default]
779+
Top,
780+
Bottom,
781+
}
782+
775783
#[derive(Debug, Deserialize)]
776784
#[serde(default)]
777785
pub struct UiConfig {
@@ -812,6 +820,8 @@ pub struct UiConfig {
812820
pub show_agent_labels_on_pane_borders: bool,
813821
/// Hide the tab row when the workspace has one tab. Default: false.
814822
pub hide_tab_bar_when_single_tab: bool,
823+
/// Desktop tab row placement. Default: top.
824+
pub tab_bar_position: TabBarPositionConfig,
815825
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
816826
pub agent_panel_sort: AgentPanelSortConfig,
817827
/// Expanded sidebar row composition.
@@ -1014,6 +1024,7 @@ impl Default for UiConfig {
10141024
pane_gaps: true,
10151025
show_agent_labels_on_pane_borders: false,
10161026
hide_tab_bar_when_single_tab: false,
1027+
tab_bar_position: TabBarPositionConfig::Top,
10171028
agent_panel_sort: AgentPanelSortConfig::Spaces,
10181029
sidebar: SidebarConfig::default(),
10191030
accent: "cyan".into(),
@@ -1245,19 +1256,25 @@ agent_panel_scope = "current"
12451256
assert!(default_config.ui.pane_gaps);
12461257
assert!(!default_config.ui.show_agent_labels_on_pane_borders);
12471258
assert!(!default_config.ui.hide_tab_bar_when_single_tab);
1259+
assert_eq!(
1260+
default_config.ui.tab_bar_position,
1261+
TabBarPositionConfig::Top
1262+
);
12481263

12491264
let toml = r#"
12501265
[ui]
12511266
pane_borders = false
12521267
pane_gaps = true
12531268
show_agent_labels_on_pane_borders = true
12541269
hide_tab_bar_when_single_tab = true
1270+
tab_bar_position = "bottom"
12551271
"#;
12561272
let config: Config = toml::from_str(toml).unwrap();
12571273
assert!(!config.ui.pane_borders);
12581274
assert!(config.ui.pane_gaps);
12591275
assert!(config.ui.show_agent_labels_on_pane_borders);
12601276
assert!(config.ui.hide_tab_bar_when_single_tab);
1277+
assert_eq!(config.ui.tab_bar_position, TabBarPositionConfig::Bottom);
12611278
}
12621279

12631280
#[test]

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
315315
# New tabs can still be created with the configured keybinding.
316316
# hide_tab_bar_when_single_tab = false
317317
318+
# Desktop tab row placement: "top" or "bottom".
319+
# tab_bar_position = "top"
320+
318321
# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue).
319322
# "workspaces" is accepted as an alias for "spaces".
320323
# agent_panel_sort = "spaces"

src/ui.rs

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,18 @@ fn desktop_tab_bar_and_terminal_area(
195195
) -> (Rect, Rect) {
196196
let hide_single_tab_bar = app.hide_tab_bar_when_single_tab && ws.tabs.len() == 1;
197197
if !hide_single_tab_bar && main_area.height > 1 {
198-
let [tab_bar_rect, terminal_area] =
199-
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
200-
(tab_bar_rect, terminal_area)
198+
match app.tab_bar_position {
199+
crate::config::TabBarPositionConfig::Top => {
200+
let [tab_bar_rect, terminal_area] =
201+
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
202+
(tab_bar_rect, terminal_area)
203+
}
204+
crate::config::TabBarPositionConfig::Bottom => {
205+
let [terminal_area, tab_bar_rect] =
206+
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(main_area);
207+
(tab_bar_rect, terminal_area)
208+
}
209+
}
201210
} else {
202211
(Rect::default(), main_area)
203212
}
@@ -410,17 +419,26 @@ pub fn render_with_runtime_registry(
410419
render_notifications(app, frame, terminal_area);
411420
render_popup_pane(app, terminal_runtimes, frame, terminal_area);
412421

422+
let mode_bar_area = if app.view.layout == ViewLayout::Desktop
423+
&& app.tab_bar_position == crate::config::TabBarPositionConfig::Bottom
424+
&& tab_bar_area.height > 0
425+
{
426+
tab_bar_area
427+
} else {
428+
terminal_area
429+
};
430+
413431
match app.mode {
414432
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
415433
Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
416434
Mode::ProductAnnouncement => render_product_announcement_overlay(app, frame, frame.area()),
417435
Mode::Navigate if app.view.layout == ViewLayout::Mobile => {
418436
render_mobile_panel(app, terminal_runtimes, frame, frame.area())
419437
}
420-
Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
421-
Mode::Prefix => render_prefix_overlay(app, frame, terminal_area),
422-
Mode::Copy => render_copy_mode_overlay(app, frame, terminal_area),
423-
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
438+
Mode::Navigate => render_navigate_overlay(app, frame, mode_bar_area),
439+
Mode::Prefix => render_prefix_overlay(app, frame, mode_bar_area),
440+
Mode::Copy => render_copy_mode_overlay(app, frame, mode_bar_area),
441+
Mode::Resize => render_resize_overlay(app, frame, mode_bar_area),
424442
Mode::ConfirmClose => {
425443
render_confirm_close_overlay(app, terminal_runtimes, frame, terminal_area)
426444
}
@@ -793,6 +811,35 @@ mod tests {
793811
assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18));
794812
}
795813

814+
#[test]
815+
fn desktop_tab_bar_position_controls_geometry_and_mode_bar_placement() {
816+
let mut app = crate::app::state::AppState::test_new();
817+
app.workspaces = vec![Workspace::test_new("one")];
818+
app.active = Some(0);
819+
app.selected = 0;
820+
app.mode = Mode::Prefix;
821+
822+
compute_view(&mut app, Rect::new(0, 0, 80, 20));
823+
assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1));
824+
assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19));
825+
826+
app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
827+
compute_view(&mut app, Rect::new(0, 0, 80, 20));
828+
assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 19));
829+
assert_eq!(app.view.tab_bar_rect, Rect::new(26, 19, 54, 1));
830+
assert!(app.view.tab_hit_areas.iter().all(|rect| rect.y == 19));
831+
assert_eq!(app.view.new_tab_hit_area.y, 19);
832+
833+
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
834+
terminal.draw(|frame| render(&app, frame)).unwrap();
835+
let mode_row = buffer_row_text(
836+
terminal.backend().buffer(),
837+
app.view.tab_bar_rect,
838+
app.view.tab_bar_rect.y,
839+
);
840+
assert!(mode_row.contains("PREFIX"), "{mode_row}");
841+
}
842+
796843
#[test]
797844
fn hide_tab_bar_when_single_tab_toggles_geometry_with_tab_count() {
798845
let mut app = crate::app::state::AppState::test_new();
@@ -827,6 +874,30 @@ mod tests {
827874
assert_eq!(app.view.new_tab_hit_area, Rect::default());
828875
}
829876

877+
#[test]
878+
fn bottom_tab_bar_still_hides_when_single_tab() {
879+
let mut app = crate::app::state::AppState::test_new();
880+
app.hide_tab_bar_when_single_tab = true;
881+
app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
882+
app.workspaces = vec![Workspace::test_new("one")];
883+
app.active = Some(0);
884+
app.selected = 0;
885+
app.mode = Mode::Prefix;
886+
887+
compute_view(&mut app, Rect::new(0, 0, 80, 20));
888+
assert_eq!(app.view.tab_bar_rect, Rect::default());
889+
assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 20));
890+
891+
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
892+
terminal.draw(|frame| render(&app, frame)).unwrap();
893+
let mode_row = buffer_row_text(
894+
terminal.backend().buffer(),
895+
app.view.terminal_area,
896+
app.view.terminal_area.y + app.view.terminal_area.height - 1,
897+
);
898+
assert!(mode_row.contains("PREFIX"), "{mode_row}");
899+
}
900+
830901
#[tokio::test]
831902
async fn hide_tab_bar_when_single_tab_resizes_background_tabs_per_workspace() {
832903
let mut app = crate::app::state::AppState::test_new();

0 commit comments

Comments
 (0)