aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input/sidebar.rs37
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/config/sidebar.rs11
-rw-r--r--src/main.rs4
-rw-r--r--src/ui.rs2
-rw-r--r--src/ui/sidebar.rs177
-rw-r--r--src/ui/sidebar/tokens.rs9
7 files changed, 202 insertions, 42 deletions
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index acbdfb9a..9ae30763 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -448,21 +448,20 @@ impl AppState {
}
let mut row_y = body.y;
- for detail in crate::ui::agent_panel_entries(self)
- .into_iter()
- .skip(self.agent_panel_scroll)
- {
- let height = crate::ui::agent_entry_height_in_body(self, &detail, body.height);
- if row_y.saturating_add(height) > body.y + body.height {
+ let body_bottom = body.y + body.height;
+ let entries = crate::ui::agent_panel_entries(self);
+ for (index, detail) in entries.iter().enumerate().skip(self.agent_panel_scroll) {
+ let height = crate::ui::agent_entry_height_in_body(self, detail, body.height);
+ if row_y.saturating_add(height) > body_bottom {
break;
}
if row >= row_y && row < row_y.saturating_add(height) {
return Some((detail.ws_idx, detail.tab_idx, detail.pane_id));
}
- row_y = row_y.saturating_add(height);
- if row_y < body.y + body.height {
- row_y = row_y.saturating_add(1);
- }
+ row_y = row_y
+ .saturating_add(height)
+ .saturating_add(crate::ui::agent_entry_gap(self, index, entries.len()))
+ .min(body_bottom);
}
None
}
@@ -721,6 +720,7 @@ mod tests {
vec![crate::config::AgentSidebarToken::Workspace],
],
);
+ app.state.sidebar_agents.row_gap = 1;
let detail_area = app.state.agent_panel_rect();
let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area);
let body = crate::ui::agent_panel_body_rect(
@@ -737,6 +737,12 @@ mod tests {
app.state.agent_detail_target_at(body.y + 3),
Some((1, 0, second_pane))
);
+
+ app.state.sidebar_agents.row_gap = 0;
+ assert_eq!(
+ app.state.agent_detail_target_at(body.y + 1),
+ Some((1, 0, second_pane))
+ );
}
#[test]
@@ -1219,11 +1225,19 @@ mod tests {
Workspace::test_new("b"),
Workspace::test_new("c"),
];
+ app.state.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
+ app.state.sidebar_spaces.row_gap = 0;
let active_id = app.state.workspaces[1].id.clone();
let selected_id = app.state.workspaces[2].id.clone();
app.state.active = Some(1);
app.state.selected = 2;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y;
+ assert_eq!(
+ app.state.workspace_drop_index_at_row(packed_boundary_row),
+ Some(2)
+ );
+
let source_row = app.state.view.workspace_card_areas[1].rect.y;
let target_row = crate::ui::workspace_drop_indicator_row(
&app.state.view.workspace_card_areas,
@@ -1454,6 +1468,7 @@ mod tests {
.get_mut(&second_terminal_id)
.unwrap()
.cwd = second_repo.clone();
+ app.state.sidebar_spaces.row_gap = 1;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0));
@@ -1473,7 +1488,7 @@ mod tests {
Workspace::test_new("b"),
Workspace::test_new("c"),
];
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24));
let cards = &app.state.view.workspace_card_areas;
let bottom_slot = crate::ui::workspace_drop_indicator_row(
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 6a7470be..cd402460 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2714,7 +2714,7 @@ mod tests {
std::fs::write(
&path,
- "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\n",
+ "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n",
)
.unwrap();
app.state.agent_panel_scroll = 5;
@@ -2735,6 +2735,7 @@ mod tests {
crate::config::AgentSidebarToken::TerminalTitleStripped,
]]
);
+ assert_eq!(app.state.sidebar_agents.row_gap, 1);
assert_eq!(
app.state.sidebar_spaces.rows,
vec![vec![
@@ -2742,6 +2743,7 @@ mod tests {
crate::config::SpaceSidebarToken::Custom("jj_status".into()),
]]
);
+ assert_eq!(app.state.sidebar_spaces.row_gap, 3);
let previous_agents = app.state.sidebar_agents.clone();
std::fs::write(
diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs
index d0ae5240..e583f5af 100644
--- a/src/config/sidebar.rs
+++ b/src/config/sidebar.rs
@@ -6,6 +6,7 @@ use crate::detect::Agent;
const MAX_SIDEBAR_ROWS: usize = 16;
const MAX_SIDEBAR_TOKENS_PER_ROW: usize = 16;
+const DEFAULT_SIDEBAR_ROW_GAP: u16 = 0;
fn deserialize_sidebar_rows<'de, D, T>(deserializer: D) -> Result<Vec<Vec<T>>, D::Error>
where
@@ -198,6 +199,7 @@ pub struct AgentsSidebarConfig {
pub rows: AgentSidebarRows,
#[serde(default, deserialize_with = "deserialize_rows_by_agent")]
pub rows_by_agent: BTreeMap<String, AgentSidebarRows>,
+ pub row_gap: u16,
}
impl AgentsSidebarConfig {
@@ -220,6 +222,7 @@ impl Default for AgentsSidebarConfig {
vec![AgentSidebarToken::Agent],
],
rows_by_agent: BTreeMap::new(),
+ row_gap: DEFAULT_SIDEBAR_ROW_GAP,
}
}
}
@@ -229,6 +232,7 @@ impl Default for AgentsSidebarConfig {
pub struct SpacesSidebarConfig {
#[serde(deserialize_with = "deserialize_sidebar_rows")]
pub rows: SpaceSidebarRows,
+ pub row_gap: u16,
}
impl Default for SpacesSidebarConfig {
@@ -238,6 +242,7 @@ impl Default for SpacesSidebarConfig {
vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace],
vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus],
],
+ row_gap: DEFAULT_SIDEBAR_ROW_GAP,
}
}
}
@@ -268,6 +273,7 @@ mod tests {
]
);
assert!(config.agents.rows_by_agent.is_empty());
+ assert_eq!(config.agents.row_gap, 0);
assert_eq!(
config.spaces.rows,
vec![
@@ -275,6 +281,7 @@ mod tests {
vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus],
]
);
+ assert_eq!(config.spaces.row_gap, 0);
}
#[test]
@@ -283,12 +290,14 @@ mod tests {
r#"
[ui.sidebar.agents]
rows = [["state_icon", "workspace"], ["state_text", "agent", "$summary"], ["terminal_title", "terminal_title_stripped", "$terminal_title"]]
+row_gap = 1
[ui.sidebar.agents.rows_by_agent]
claude = [["terminal_title_stripped"], ["agent", "$model"]]
[ui.sidebar.spaces]
rows = [["workspace"], ["$jj_status"]]
+row_gap = 3
"#,
)
.expect("sidebar token config");
@@ -319,10 +328,12 @@ rows = [["workspace"], ["$jj_status"]]
],
]
);
+ assert_eq!(config.ui.sidebar.agents.row_gap, 1);
assert_eq!(
config.ui.sidebar.spaces.rows[1],
vec![SpaceSidebarToken::Custom("jj_status".into())]
);
+ assert_eq!(config.ui.sidebar.spaces.row_gap, 3);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 06424fea..68e47401 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -309,6 +309,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# terminal_title, and terminal_title_stripped.
# Custom values reported through pane metadata use a $name token.
# [ui.sidebar.agents]
+# Blank rows between agent entries. Set to 1 to restore the previous spacing.
+# row_gap = 0
# rows = [["state_icon", "workspace", "tab"], ["agent"]]
# Optional canonical agent IDs replace the default rows for matching agents.
# [ui.sidebar.agents.rows_by_agent]
@@ -317,6 +319,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status.
# Custom values reported through workspace metadata use a $name token, for example $jj_status.
# [ui.sidebar.spaces]
+# Blank rows between space entries. Set to 1 to restore the previous spacing.
+# row_gap = 0
# rows = [["state_icon", "workspace"], ["branch", "git_status"]]
# Accent color for highlights, borders, and navigation UI.
diff --git a/src/ui.rs b/src/ui.rs
index 76e1caa2..349a033a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -69,7 +69,7 @@ pub(crate) use self::{
SETTINGS_POPUP_WIDTH,
},
sidebar::{
- agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries,
+ agent_entry_gap, agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries,
agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect,
agent_panel_toggle_rect, collapsed_sidebar_sections, collapsed_sidebar_toggle_rect,
compute_workspace_card_areas, expanded_sidebar_sections, expanded_sidebar_toggle_rect,
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 68e5549d..ebe7d0a1 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -218,11 +218,19 @@ fn workspace_row_height_in_body(
workspace_row_height(app, workspace, indented).min(body_height)
}
-fn workspace_entry_gap(entries: &[WorkspaceListEntry], entry_idx: usize, indented: bool) -> u16 {
- u16::from(
- entry_idx + 1 < entries.len()
- && !(indented && next_entry_is_indented_workspace(entries, entry_idx)),
- )
+fn workspace_entry_gap(
+ app: &AppState,
+ entries: &[WorkspaceListEntry],
+ entry_idx: usize,
+ indented: bool,
+) -> u16 {
+ if entry_idx + 1 < entries.len()
+ && !(indented && next_entry_is_indented_workspace(entries, entry_idx))
+ {
+ app.sidebar_spaces.row_gap
+ } else {
+ 0
+ }
}
fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 {
@@ -456,7 +464,7 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us
};
(
workspace_row_height_in_body(app, ws, *indented, body.height),
- workspace_entry_gap(&entries, entry_idx, *indented),
+ workspace_entry_gap(app, &entries, entry_idx, *indented),
)
}
};
@@ -465,9 +473,7 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us
}
used_rows = used_rows.saturating_add(row_height);
visible += 1;
- if gap > 0 && used_rows < body.height {
- used_rows = used_rows.saturating_add(1);
- }
+ used_rows = used_rows.saturating_add(gap).min(body.height);
}
visible
}
@@ -482,7 +488,7 @@ fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize {
let Some(workspace) = app.workspaces.get(*ws_idx) else {
continue;
};
- let gap = workspace_entry_gap(&entries, entry_idx, *indented);
+ let gap = workspace_entry_gap(app, &entries, entry_idx, *indented);
let needed = workspace_row_height_in_body(app, workspace, *indented, body.height)
.saturating_add(gap);
if used_rows.saturating_add(needed) > body.height {
@@ -552,6 +558,14 @@ pub(crate) fn agent_entry_height_in_body(
.min(body_height)
}
+pub(crate) fn agent_entry_gap(app: &AppState, entry_idx: usize, entry_count: usize) -> u16 {
+ if entry_idx + 1 < entry_count {
+ app.sidebar_agents.row_gap
+ } else {
+ 0
+ }
+}
+
fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize {
let body = agent_panel_body_rect(area, false);
if body.width == 0 || body.height == 0 {
@@ -560,16 +574,17 @@ fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) ->
let mut used_rows = 0u16;
let mut visible = 0usize;
- for entry in agent_panel_entries(app).iter().skip(scroll) {
+ let entries = agent_panel_entries(app);
+ for (index, entry) in entries.iter().enumerate().skip(scroll) {
let height = agent_entry_height_in_body(app, entry, body.height);
if used_rows.saturating_add(height) > body.height {
break;
}
used_rows = used_rows.saturating_add(height);
visible += 1;
- if used_rows < body.height {
- used_rows = used_rows.saturating_add(1);
- }
+ used_rows = used_rows
+ .saturating_add(agent_entry_gap(app, index, entries.len()))
+ .min(body.height);
}
visible
}
@@ -580,7 +595,7 @@ fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize {
let mut used_rows = 0u16;
let mut start = entries.len();
for (index, entry) in entries.iter().enumerate().rev() {
- let gap = u16::from(index + 1 < entries.len());
+ let gap = agent_entry_gap(app, index, entries.len());
let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap);
if used_rows.saturating_add(needed) > body.height {
break;
@@ -664,7 +679,7 @@ pub(crate) fn compute_workspace_list_areas(
continue;
};
let row_height = workspace_row_height_in_body(app, ws, *indented, body.height);
- let gap = workspace_entry_gap(&entries, entry_idx, *indented);
+ let gap = workspace_entry_gap(app, &entries, entry_idx, *indented);
if row_y.saturating_add(row_height) > body_bottom {
break;
}
@@ -673,10 +688,10 @@ pub(crate) fn compute_workspace_list_areas(
rect: Rect::new(body.x, row_y, body.width, row_height),
indented: *indented,
});
- row_y = row_y.saturating_add(row_height);
- if gap > 0 && row_y < body_bottom {
- row_y = row_y.saturating_add(1);
- }
+ row_y = row_y
+ .saturating_add(row_height)
+ .saturating_add(gap)
+ .min(body_bottom);
}
}
}
@@ -1298,7 +1313,7 @@ fn render_agent_detail(
let mut row_y = body.y;
let body_bottom = body.y + body.height;
- for detail in details.iter().skip(app.agent_panel_scroll) {
+ for (index, detail) in details.iter().enumerate().skip(app.agent_panel_scroll) {
let label_color = state_label_color(detail.state, detail.seen, p);
let rows = resolved_agent_rows(app, detail);
let height = (rows.len().max(1) as u16).min(body.height);
@@ -1343,10 +1358,10 @@ fn render_agent_detail(
Rect::new(body.x, row_y + row_index as u16, body.width, 1),
);
}
- row_y = row_y.saturating_add(height);
- if row_y < body_bottom {
- row_y += 1;
- }
+ row_y = row_y
+ .saturating_add(height)
+ .saturating_add(agent_entry_gap(app, index, details.len()))
+ .min(body_bottom);
}
if let Some(track) = scrollbar_rect {
@@ -1446,6 +1461,36 @@ mod tests {
}
#[test]
+ fn default_agent_row_gap_packs_rendering_and_scroll_geometry() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ app.ensure_test_terminals();
+ for (workspace, agent) in app.workspaces.iter().zip([Agent::Pi, Agent::Claude]) {
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent);
+ }
+ app.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
+ assert_eq!(app.sidebar_agents.row_gap, 0);
+
+ let area = Rect::new(0, 0, 20, 5);
+ let metrics = agent_panel_scroll_metrics(&app, area);
+ let body = agent_panel_body_rect(area, false);
+ let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap();
+ terminal
+ .draw(|frame| render_agent_detail(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+
+ assert_eq!(metrics.viewport_rows, 2);
+ assert_eq!(metrics.max_offset_from_bottom, 0);
+ assert_eq!(row_text(buffer, body.y, body.width), " pi");
+ assert_eq!(row_text(buffer, body.y + 1, body.width), " claude");
+ }
+
+ #[test]
fn narrow_agent_rows_preserve_later_tab_tokens() {
let mut app = crate::app::state::AppState::test_new();
let mut workspace = Workspace::test_new("very-long-workspace-name");
@@ -2020,6 +2065,7 @@ mod tests {
workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
];
+ app.sidebar_spaces.row_gap = 1;
let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20));
@@ -2032,6 +2078,87 @@ mod tests {
}
#[test]
+ fn space_row_gap_preserves_compact_worktree_children() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"),
+ Workspace::test_new("notes"),
+ ];
+ app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
+ app.sidebar_spaces.row_gap = 2;
+
+ let (spacious, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30));
+ assert_eq!(
+ spacious[1].rect.y,
+ spacious[0].rect.y + spacious[0].rect.height + 2
+ );
+ assert_eq!(
+ spacious[2].rect.y,
+ spacious[1].rect.y + spacious[1].rect.height
+ );
+ assert_eq!(
+ spacious[3].rect.y,
+ spacious[2].rect.y + spacious[2].rect.height + 2
+ );
+ let spacious_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7));
+ assert_eq!(spacious_metrics.viewport_rows, 2);
+ assert_eq!(spacious_metrics.max_offset_from_bottom, 2);
+
+ app.sidebar_spaces.row_gap = 0;
+ let (packed, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30));
+ assert!(packed
+ .windows(2)
+ .all(|pair| pair[1].rect.y == pair[0].rect.y + pair[0].rect.height));
+ let packed_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7));
+ assert_eq!(packed_metrics.viewport_rows, 4);
+ assert_eq!(packed_metrics.max_offset_from_bottom, 0);
+ }
+
+ #[test]
+ fn packed_workspace_drag_indicator_overlays_an_internal_boundary() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ Workspace::test_new("a"),
+ Workspace::test_new("b"),
+ Workspace::test_new("c"),
+ ];
+ app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
+ app.sidebar_spaces.row_gap = 0;
+ let area = Rect::new(0, 0, 30, 20);
+ app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
+ let list_area = workspace_list_rect(area, app.sidebar_section_split);
+ let indicator_row =
+ workspace_drop_indicator_row(&app.view.workspace_card_areas, list_area, 2).unwrap();
+ assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y);
+ app.drag = Some(crate::app::state::DragState {
+ target: crate::app::state::DragTarget::WorkspaceReorder {
+ source_ws_idx: 0,
+ insert_idx: Some(2),
+ },
+ });
+
+ let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
+ terminal
+ .draw(|frame| {
+ render_workspace_list(
+ &app,
+ &TerminalRuntimeRegistry::new(),
+ frame,
+ list_area,
+ false,
+ )
+ })
+ .unwrap();
+
+ assert_eq!(
+ terminal.backend().buffer()[(list_area.x, indicator_row)].symbol(),
+ "─"
+ );
+ }
+
+ #[test]
fn linked_only_worktree_members_do_not_form_parentless_group() {
let mut app = AppState::test_new();
app.workspaces = vec![
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
index 43c24d05..5c003617 100644
--- a/src/ui/sidebar/tokens.rs
+++ b/src/ui/sidebar/tokens.rs
@@ -153,7 +153,7 @@ mod tests {
vec![AgentSidebarToken::Custom("missing".into())],
vec![AgentSidebarToken::Agent],
],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
let rows = agent_rows(&config, &entry, "working");
@@ -174,7 +174,7 @@ mod tests {
AgentSidebarToken::StateText,
AgentSidebarToken::Custom("summary".into()),
]],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
assert_eq!(
@@ -200,7 +200,7 @@ mod tests {
AgentSidebarToken::TerminalTitleStripped,
AgentSidebarToken::Custom("terminal_title".into()),
]],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
assert_eq!(
@@ -217,7 +217,7 @@ mod tests {
fn known_agent_override_replaces_default_rows() {
let mut config = AgentsSidebarConfig {
rows: vec![vec![AgentSidebarToken::Workspace]],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
config
.rows_by_agent
@@ -265,6 +265,7 @@ mod tests {
let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]);
let config = SpacesSidebarConfig {
rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]],
+ ..Default::default()
};
assert_eq!(