mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user