aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/CHANGELOG.md4
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx4
-rw-r--r--docs/next/website/src/content/docs/ja/configuration.mdx4
-rw-r--r--docs/next/website/src/content/docs/zh-cn/configuration.mdx4
-rw-r--r--docs/next/website/src/data/config-reference.json12
-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
12 files changed, 230 insertions, 42 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 384118f6..f6c060bf 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,9 +3,13 @@
## Unreleased
### Added
+- Added independent `row_gap` settings for expanded Space and Agent sidebar entries. (#873)
- Copy mode now supports literal smart-case search with `/` and `?`, repeating with `n` and `N`, match highlighting, and tmux-style cross-line `w`/`b`/`e` word motions. (#1230)
- Added maki detection with idle, working, and blocked screen states. (#1301, thanks @tontinton)
+### Changed
+- Expanded Space and Agent sidebar entries now use a packed layout by default; set the corresponding `row_gap` to `1` to restore the previous spacing. (#873)
+
### Fixed
- Outer-terminal focus gained and lost reports now reach the focused pane when its application enables focus reporting, restoring Neovim file autoreload and other focus-aware terminal behavior. (#1337)
- Native Windows servers now detach from the terminal console that launched them, so closing WezTerm, Windows Terminal, or another host terminal no longer stops persistent pane processes. (#1329)
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 9455f8c3..01d82547 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -236,12 +236,14 @@ The expanded desktop sidebar renders each inner array in `rows` as one line. The
```toml
[ui.sidebar.agents]
+row_gap = 0
rows = [
["state_icon", "workspace", "tab"],
["agent"],
]
[ui.sidebar.spaces]
+row_gap = 0
rows = [
["state_icon", "workspace"],
["branch", "git_status"],
@@ -271,6 +273,8 @@ Space rows accept these built-in tokens:
Tokens render in their configured order. Herdr normally separates adjacent values with ` · ` and uses a single space after `state_icon`. Missing values and their separators disappear; a row disappears when none of its tokens have a value. Each layout may contain at most 16 rows, with at most 16 tokens in each row.
+`row_gap` controls the blank terminal rows between entries, independently for the Agent and Space panels. It defaults to `0`, which packs entries together; set it to `1` to restore the previous spacing. It does not add spacing between the content lines declared in `rows`. Consecutive indented worktree children remain packed as one Space group.
+
Override the complete Agent layout for a known agent under `rows_by_agent`:
```toml
diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx
index ffc142f4..7105efcd 100644
--- a/docs/next/website/src/content/docs/ja/configuration.mdx
+++ b/docs/next/website/src/content/docs/ja/configuration.mdx
@@ -235,12 +235,14 @@ yellow = "#f9e2af"
```toml
[ui.sidebar.agents]
+row_gap = 0
rows = [
["state_icon", "workspace", "tab"],
["agent"],
]
[ui.sidebar.spaces]
+row_gap = 0
rows = [
["state_icon", "workspace"],
["branch", "git_status"],
@@ -270,6 +272,8 @@ Space の行では、次の組み込みトークンを使えます:
トークンは設定された順序で描画されます。Herdr は通常、隣接する値を ` · ` で区切り、`state_icon` の後には空白を 1 つ入れます。値がない場合、その値と区切りは表示されません。すべてのトークンに値がない行は表示されません。各レイアウトは最大 16 行、各行は最大 16 トークンです。
+`row_gap` は、Agent パネルと Space パネルごとに、エントリ間の空白行数を指定します。デフォルトは `0` で、エントリを詰めて表示します。以前の間隔に戻すには `1` に設定します。`rows` で定義したコンテンツ行の間隔には影響しません。連続するインデントされた worktree の子は、1 つの Space グループとして詰めて表示されます。
+
既知のエージェントについて Agent の完全なレイアウトを上書きするには、`rows_by_agent` を使います:
```toml
diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
index ccf6c312..8a232cc7 100644
--- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
@@ -235,12 +235,14 @@ yellow = "#f9e2af"
```toml
[ui.sidebar.agents]
+row_gap = 0
rows = [
["state_icon", "workspace", "tab"],
["agent"],
]
[ui.sidebar.spaces]
+row_gap = 0
rows = [
["state_icon", "workspace"],
["branch", "git_status"],
@@ -270,6 +272,8 @@ Space 行支持以下内置 token:
token 会按配置顺序渲染。Herdr 通常使用 ` · ` 分隔相邻值,并在 `state_icon` 后使用一个空格。缺失值及其分隔符会消失;当一行中的所有 token 都没有值时,该行会消失。每个布局最多可包含 16 行,每行最多可包含 16 个 token。
+`row_gap` 分别控制 Agent 和 Space 面板中条目之间的空白终端行数。默认值为 `0`,会紧密排列条目;设为 `1` 可恢复之前的间距。它不会在 `rows` 声明的内容行之间添加间距。连续缩进的 worktree 子项仍会作为一个 Space 组紧密排列。
+
在 `rows_by_agent` 下为已知智能体覆盖完整的 Agent 布局:
```toml
diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json
index f84e6da1..65a6f29e 100644
--- a/docs/next/website/src/data/config-reference.json
+++ b/docs/next/website/src/data/config-reference.json
@@ -669,6 +669,12 @@
]
},
{
+ "key": "ui.sidebar.agents.row_gap",
+ "type": "integer",
+ "default": "0",
+ "description": "Blank terminal rows between expanded Agent sidebar entries. Set to 1 to restore the previous spacing."
+ },
+ {
"key": "ui.sidebar.agents.rows",
"type": "list of token rows",
"default": "[[\"state_icon\", \"workspace\", \"tab\"], [\"agent\"]]",
@@ -681,6 +687,12 @@
"description": "Complete Agent-row overrides keyed by strict canonical agent id. Agents without an override use ui.sidebar.agents.rows."
},
{
+ "key": "ui.sidebar.spaces.row_gap",
+ "type": "integer",
+ "default": "0",
+ "description": "Blank terminal rows between expanded Space sidebar entries. Set to 1 to restore the previous spacing. Consecutive indented worktree children remain packed as one group."
+ },
+ {
"key": "ui.sidebar.spaces.rows",
"type": "list of token rows",
"default": "[[\"state_icon\", \"workspace\"], [\"branch\", \"git_status\"]]",
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!(