aboutsummaryrefslogtreecommitdiff
path: root/src/ui/sidebar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/ui/sidebar.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/ui/sidebar.rs')
-rw-r--r--src/ui/sidebar.rs3042
1 files changed, 80 insertions, 2962 deletions
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index a6dfbcd3..3da1e9f9 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -1,71 +1,68 @@
mod tokens;
use ratatui::{
- layout::{Alignment, Rect},
- style::{Color, Modifier, Style},
- text::{Line, Span},
- widgets::Paragraph,
- Frame,
+ layout::Rect,
+ style::{Modifier, Style},
+ text::Span,
};
-use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext};
-use super::scrollbar::{render_scrollbar, should_show_scrollbar};
-use super::status::{state_icon, state_label, state_label_color};
-use super::text::{display_width, display_width_u16, truncate_end};
-use crate::app::state::{AgentPanelSort, Palette};
-use crate::app::{AppState, Mode};
+pub(crate) use self::tokens::{
+ agent_rows as sidebar_agent_rows, space_rows as sidebar_space_rows, AgentTokenContext,
+ ResolvedToken, ResolvedTokenKind, SpaceTokenContext,
+};
+use super::text::{display_width, truncate_end};
+use crate::app::state::Palette;
+use crate::app::AppState;
use crate::detect::AgentState;
use crate::terminal::TerminalRuntimeRegistry;
-const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
-const AGENT_PANEL_HEADER_ROWS: u16 = 3;
-
pub(crate) struct AgentPanelEntry {
pub ws_idx: usize,
pub tab_idx: usize,
pub pane_id: crate::layout::PaneId,
- pub primary_label: String,
- pub primary_tab_label: Option<String>,
- pub pane_label: Option<String>,
- pub terminal_title: Option<String>,
- pub terminal_title_stripped: Option<String>,
- pub agent_label: Option<String>,
pub agent_kind_label: Option<String>,
- pub agent: Option<crate::detect::Agent>,
pub state: AgentState,
pub seen: bool,
pub last_agent_state_change_seq: Option<u64>,
- pub state_labels: std::collections::HashMap<String, String>,
pub tokens: std::collections::HashMap<String, String>,
}
-fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
- if total_h == 0 {
+fn sidebar_section_heights(total_height: u16, split_ratio: f32) -> (u16, u16) {
+ if total_height == 0 {
return (0, 0);
}
-
- if total_h < 6 {
- let ws_h = total_h.div_ceil(2);
- return (ws_h, total_h.saturating_sub(ws_h));
+ if total_height < 6 {
+ let workspace_height = total_height.div_ceil(2);
+ return (
+ workspace_height,
+ total_height.saturating_sub(workspace_height),
+ );
}
- let ratio = split_ratio.clamp(0.1, 0.9);
- let ws_h = ((total_h as f32) * ratio).round() as u16;
- let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
- let detail_h = total_h.saturating_sub(ws_h);
- (ws_h, detail_h)
+ let workspace_height = ((total_height as f32) * split_ratio.clamp(0.1, 0.9)).round() as u16;
+ let workspace_height = workspace_height.clamp(3, total_height.saturating_sub(3));
+ (
+ workspace_height,
+ total_height.saturating_sub(workspace_height),
+ )
}
pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
+ if content.is_empty() {
return (Rect::default(), Rect::default());
}
- let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
- let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
- (ws_area, detail_area)
+ let (workspace_height, detail_height) = sidebar_section_heights(content.height, split_ratio);
+ (
+ Rect::new(content.x, content.y, content.width, workspace_height),
+ Rect::new(
+ content.x,
+ content.y + workspace_height,
+ content.width,
+ detail_height,
+ ),
+ )
}
pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
@@ -74,949 +71,46 @@ pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect
return Rect::default();
}
- let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
- Rect::new(content.x, content.y + ws_h, content.width, 1)
-}
-
-fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str {
- match sort {
- AgentPanelSort::Spaces => "grouped",
- AgentPanelSort::Priority => "priority",
- }
-}
-
-pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect {
- agent_panel_header_label_rect(area, agent_panel_sort_label(sort))
-}
-
-fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect {
- if area.width == 0 || area.height < 2 {
- return Rect::default();
- }
-
- let width = display_width_u16(label).min(area.width);
- Rect::new(
- area.x + area.width.saturating_sub(width),
- area.y + 1,
- width,
- 1,
- )
-}
-
-fn active_agent_view_label(app: &AppState) -> Option<&str> {
- app.agent_view_override
- .as_ref()
- .map(|view| view.label.as_deref().unwrap_or("filtered"))
-}
-
-pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
- agent_panel_entries_with_runtimes(app, None)
-}
-
-pub(crate) fn all_agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
- collect_agent_panel_entries_with_runtimes(app, None)
+ let (workspace_height, _) = sidebar_section_heights(content.height, split_ratio);
+ Rect::new(content.x, content.y + workspace_height, content.width, 1)
}
pub(crate) fn agent_panel_entries_from(
app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
-) -> Vec<AgentPanelEntry> {
- agent_panel_entries_with_runtimes(app, Some(terminal_runtimes))
-}
-
-fn agent_panel_entries_with_runtimes(
- app: &AppState,
- terminal_runtimes: Option<&TerminalRuntimeRegistry>,
+ _terminal_runtimes: &TerminalRuntimeRegistry,
) -> Vec<AgentPanelEntry> {
- let mut entries = collect_agent_panel_entries_with_runtimes(app, terminal_runtimes);
- crate::app::agent_view::apply_agent_view(app, &mut entries);
- entries
-}
-
-fn collect_agent_panel_entries_with_runtimes(
- app: &AppState,
- terminal_runtimes: Option<&TerminalRuntimeRegistry>,
-) -> Vec<AgentPanelEntry> {
- let empty_runtimes;
- let terminal_runtimes = match terminal_runtimes {
- Some(terminal_runtimes) => terminal_runtimes,
- None => {
- empty_runtimes = TerminalRuntimeRegistry::new();
- &empty_runtimes
- }
- };
-
- app.workspaces
+ let mut entries = app
+ .workspaces
.iter()
.enumerate()
- .flat_map(|(ws_idx, ws)| {
- let multi_tab = ws.tabs.len() > 1;
- let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes);
- ws.pane_details(&app.terminals)
+ .flat_map(|(ws_idx, workspace)| {
+ workspace
+ .pane_details(&app.terminals)
.into_iter()
- .map(move |detail| {
- let show_tab = multi_tab
- || ws
- .tabs
- .get(detail.tab_idx)
- .is_some_and(|tab| !tab.is_auto_named());
- AgentPanelEntry {
- ws_idx,
- tab_idx: detail.tab_idx,
- pane_id: detail.pane_id,
- primary_label: workspace_label.clone(),
- primary_tab_label: show_tab.then_some(detail.tab_label),
- pane_label: detail.pane_label,
- terminal_title: detail.terminal_title,
- terminal_title_stripped: detail.terminal_title_stripped,
- agent_label: Some(detail.agent_label),
- agent_kind_label: detail.agent_kind_label,
- agent: detail.agent,
- state: detail.state,
- seen: detail.seen,
- last_agent_state_change_seq: detail.last_agent_state_change_seq,
- state_labels: detail.state_labels,
- tokens: detail.tokens,
- }
- })
- })
- .collect()
-}
-
-pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str {
- match (state, seen) {
- (AgentState::Idle, false) => "done",
- (AgentState::Idle, true) => "idle",
- (AgentState::Working, _) => "working",
- (AgentState::Blocked, _) => "blocked",
- (AgentState::Unknown, _) => "unknown",
- }
-}
-
-fn workspace_row_height(app: &AppState, ws: &crate::workspace::Workspace, indented: bool) -> u16 {
- let (state, seen) = ws.aggregate_state(&app.terminals);
- let label = if indented {
- grouped_child_display_label(
- &ws.display_name_from_terminals(&app.terminals),
- ws.branch().as_deref(),
- ws.custom_name.is_some(),
- )
- } else {
- ws.display_name_from_terminals(&app.terminals)
- };
- let token_values = ws.metadata_tokens.values();
- tokens::space_rows(
- &app.sidebar_spaces,
- SpaceTokenContext {
- workspace: &label,
- branch: ws.branch().as_deref(),
- state_text: state_label(state, seen),
- ahead_behind: ws.git_ahead_behind(),
- tokens: &token_values,
- suppress_git_details: indented,
- },
- )
- .len()
- .max(1)
- .min(u16::MAX as usize) as u16
-}
-
-fn workspace_row_height_in_body(
- app: &AppState,
- workspace: &crate::workspace::Workspace,
- indented: bool,
- body_height: u16,
-) -> u16 {
- workspace_row_height(app, workspace, indented).min(body_height)
-}
-
-fn workspace_entry_gap(app: &AppState, entries: &[WorkspaceListEntry], entry_idx: usize) -> u16 {
- if entry_idx + 1 < entries.len() && !next_entry_is_indented_workspace(entries, entry_idx) {
- app.sidebar_spaces.row_gap
- } else {
- 0
- }
-}
-
-fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 {
- match (state, seen) {
- (AgentState::Blocked, _) => 4,
- (AgentState::Idle, false) => 3,
- (AgentState::Working, _) => 2,
- (AgentState::Idle, true) => 1,
- (AgentState::Unknown, _) => 0,
- }
-}
-
-fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) {
- app.workspaces
- .iter()
- .filter(|ws| ws.worktree_space().is_some_and(|space| space.key == key))
- .map(|ws| ws.aggregate_state(&app.terminals))
- .max_by_key(|(state, seen)| workspace_attention_priority(*state, *seen))
- .unwrap_or((AgentState::Unknown, true))
-}
-
-pub(crate) fn workspace_parent_group_state(
- app: &AppState,
- ws_idx: usize,
-) -> Option<(String, bool)> {
- let space = app.workspaces.get(ws_idx)?.worktree_space()?;
- if space.is_linked_worktree {
- return None;
- }
- let member_count = app
- .workspaces
- .iter()
- .filter(|ws| {
- ws.worktree_space()
- .is_some_and(|member| member.key == space.key)
- })
- .count();
- (member_count >= 2).then(|| {
- (
- space.key.clone(),
- app.collapsed_space_keys.contains(&space.key),
- )
- })
-}
-
-pub(crate) fn grouped_child_display_label(
- label: &str,
- branch: Option<&str>,
- has_custom_name: bool,
-) -> String {
- if has_custom_name {
- return label.to_string();
- }
- let Some(branch) = branch else {
- return label.to_string();
- };
- branch
- .strip_prefix("worktree/")
- .unwrap_or(branch)
- .to_string()
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum WorkspaceListEntry {
- Workspace { ws_idx: usize, indented: bool },
-}
-
-pub(crate) fn next_entry_is_indented_workspace(entries: &[WorkspaceListEntry], idx: usize) -> bool {
- matches!(
- entries.get(idx.saturating_add(1)),
- Some(WorkspaceListEntry::Workspace { indented: true, .. })
- )
-}
-
-pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: usize) -> usize {
- let ws_area = workspace_list_rect(area, app.sidebar_section_split);
- let body = workspace_list_body_rect(ws_area, false);
- if body.height == 0 {
- return requested;
- }
-
- if workspace_list_entries(app).is_empty() {
- 0
- } else {
- requested.min(workspace_list_bottom_start(app, ws_area))
- }
-}
-
-pub(crate) fn workspace_list_entries(app: &AppState) -> Vec<WorkspaceListEntry> {
- workspace_list_entries_inner(app, false)
-}
-
-/// Like [`workspace_list_entries`] but always expands worktree groups, ignoring
-/// `collapsed_space_keys`. The mobile switcher has no collapse affordance and
-/// always shows the full worktree tree.
-pub(crate) fn workspace_list_entries_expanded(app: &AppState) -> Vec<WorkspaceListEntry> {
- workspace_list_entries_inner(app, true)
-}
-
-fn workspace_list_entries_inner(app: &AppState, force_expanded: bool) -> Vec<WorkspaceListEntry> {
- let mut members_by_key = std::collections::HashMap::<String, Vec<usize>>::new();
- for (ws_idx, ws) in app.workspaces.iter().enumerate() {
- if let Some(space) = ws.worktree_space() {
- members_by_key
- .entry(space.key.clone())
- .or_default()
- .push(ws_idx);
- }
- }
- let grouped_keys = members_by_key
- .iter()
- .filter(|(_, members)| {
- members.len() >= 2
- && members.iter().any(|idx| {
- app.workspaces
- .get(*idx)
- .and_then(|ws| ws.worktree_space())
- .is_some_and(|space| !space.is_linked_worktree)
+ .map(move |detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ agent_kind_label: detail.agent_kind_label,
+ state: detail.state,
+ seen: detail.seen,
+ last_agent_state_change_seq: detail.last_agent_state_change_seq,
+ tokens: detail.tokens,
})
})
- .map(|(key, _)| key.clone())
- .collect::<std::collections::HashSet<_>>();
-
- let visible_group_idx = if matches!(app.mode, Mode::Navigate) {
- Some(app.selected)
- } else {
- app.active
- };
- let active_group = visible_group_idx.and_then(|idx| {
- app.workspaces
- .get(idx)
- .and_then(|ws| ws.worktree_space())
- .map(|space| space.key.clone())
- });
-
- let mut emitted_groups = std::collections::HashSet::<String>::new();
- let mut entries = Vec::new();
- for (ws_idx, ws) in app.workspaces.iter().enumerate() {
- let Some(space) = ws
- .worktree_space()
- .filter(|space| grouped_keys.contains(&space.key))
- else {
- entries.push(WorkspaceListEntry::Workspace {
- ws_idx,
- indented: false,
- });
- continue;
- };
-
- if !emitted_groups.insert(space.key.clone()) {
- continue;
- }
-
- let Some(members) = members_by_key.get(&space.key) else {
- continue;
- };
- let Some(parent_idx) = members.iter().copied().find(|idx| {
- app.workspaces
- .get(*idx)
- .and_then(|member| member.worktree_space())
- .is_some_and(|member_space| !member_space.is_linked_worktree)
- }) else {
- entries.push(WorkspaceListEntry::Workspace {
- ws_idx,
- indented: false,
- });
- continue;
- };
- let collapsed = !force_expanded && app.collapsed_space_keys.contains(&space.key);
- entries.push(WorkspaceListEntry::Workspace {
- ws_idx: parent_idx,
- indented: false,
- });
-
- if collapsed {
- if let Some(active_idx) = visible_group_idx
- .filter(|idx| *idx != parent_idx)
- .filter(|_| active_group.as_deref() == Some(space.key.as_str()))
- {
- entries.push(WorkspaceListEntry::Workspace {
- ws_idx: active_idx,
- indented: true,
- });
- }
- } else {
- for member_idx in members {
- if *member_idx == parent_idx {
- continue;
- }
- entries.push(WorkspaceListEntry::Workspace {
- ws_idx: *member_idx,
- indented: true,
- });
- }
- }
- }
+ .collect();
+ crate::app::agent_view::apply_agent_view(app, &mut entries);
entries
}
-pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
- let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
- ws_area
-}
-
-pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
- if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS {
- return Rect::default();
- }
-
- let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS);
- let footer_y = area.y + area.height.saturating_sub(1);
- let body_height = footer_y.saturating_sub(body_y);
- let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
- Rect::new(area.x, body_y, body_width, body_height)
-}
-
-fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize {
- let body = workspace_list_body_rect(area, false);
- if body.width == 0 || body.height == 0 {
- return 0;
- }
-
- let mut used_rows = 0u16;
- let mut visible = 0usize;
- let entries = workspace_list_entries(app);
- for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) {
- let (row_height, gap) = match entry {
- WorkspaceListEntry::Workspace { ws_idx, indented } => {
- let Some(ws) = app.workspaces.get(*ws_idx) else {
- continue;
- };
- (
- workspace_row_height_in_body(app, ws, *indented, body.height),
- workspace_entry_gap(app, &entries, entry_idx),
- )
- }
- };
- if used_rows.saturating_add(row_height) > body.height {
- break;
- }
- used_rows = used_rows.saturating_add(row_height);
- visible += 1;
- used_rows = used_rows.saturating_add(gap).min(body.height);
- }
- visible
-}
-
-fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize {
- let body = workspace_list_body_rect(area, false);
- let entries = workspace_list_entries(app);
- let mut used_rows = 0u16;
- let mut start = entries.len();
- for (entry_idx, entry) in entries.iter().enumerate().rev() {
- let WorkspaceListEntry::Workspace { ws_idx, indented } = entry;
- let Some(workspace) = app.workspaces.get(*ws_idx) else {
- continue;
- };
- let gap = workspace_entry_gap(app, &entries, entry_idx);
- let needed = workspace_row_height_in_body(app, workspace, *indented, body.height)
- .saturating_add(gap);
- if used_rows.saturating_add(needed) > body.height {
- break;
- }
- used_rows = used_rows.saturating_add(needed);
- start = entry_idx;
- }
- start.min(entries.len().saturating_sub(1))
-}
-
-pub(crate) fn workspace_list_scroll_metrics(
- app: &AppState,
- area: Rect,
-) -> crate::pane::ScrollMetrics {
- let max_scroll = workspace_list_bottom_start(app, area);
- let scroll = app.workspace_scroll.min(max_scroll);
- let viewport_rows = workspace_list_visible_count(app, area, scroll);
-
- crate::pane::ScrollMetrics {
- offset_from_bottom: max_scroll.saturating_sub(scroll),
- max_offset_from_bottom: max_scroll,
- viewport_rows,
- }
-}
-
-pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
- let metrics = workspace_list_scroll_metrics(app, area);
- let body = workspace_list_body_rect(area, true);
- (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
- area.x + area.width.saturating_sub(1),
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
- if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS {
- return Rect::default();
- }
-
- let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS);
- let body_height = (area.y + area.height).saturating_sub(body_y);
- let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
- Rect::new(area.x, body_y, body_width, body_height)
-}
-
-fn resolved_agent_rows(app: &AppState, entry: &AgentPanelEntry) -> Vec<Vec<ResolvedToken>> {
- let label = entry
- .state_labels
- .get(agent_panel_status_key(entry.state, entry.seen))
- .map(String::as_str)
- .unwrap_or_else(|| state_label(entry.state, entry.seen));
- tokens::agent_rows(&app.sidebar_agents, entry, label)
-}
-
-pub(crate) fn agent_entry_height_in_body(
- app: &AppState,
- entry: &AgentPanelEntry,
- body_height: u16,
-) -> u16 {
- (resolved_agent_rows(app, entry)
- .len()
- .max(1)
- .min(u16::MAX as usize) as u16)
- .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 {
- return 0;
- }
-
- let mut used_rows = 0u16;
- let mut visible = 0usize;
- 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;
- used_rows = used_rows
- .saturating_add(agent_entry_gap(app, index, entries.len()))
- .min(body.height);
- }
- visible
-}
-
-fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize {
- let body = agent_panel_body_rect(area, false);
- let entries = agent_panel_entries(app);
- let mut used_rows = 0u16;
- let mut start = entries.len();
- for (index, entry) in entries.iter().enumerate().rev() {
- 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;
- }
- used_rows = used_rows.saturating_add(needed);
- start = index;
- }
- start.min(entries.len().saturating_sub(1))
-}
-
-pub(crate) fn agent_panel_scroll_for_target(
- app: &AppState,
- area: Rect,
- current_scroll: usize,
- target: usize,
-) -> usize {
- let max_scroll = agent_panel_bottom_start(app, area);
- if target < current_scroll {
- return target.min(max_scroll);
- }
- let mut scroll = current_scroll.min(max_scroll);
- while scroll < target {
- let visible = agent_panel_visible_count_from(app, area, scroll);
- if visible > 0 && target < scroll.saturating_add(visible) {
- break;
- }
- scroll += 1;
- }
- scroll.min(max_scroll)
-}
-
-pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
- let max_scroll = agent_panel_bottom_start(app, area);
- let scroll = app.agent_panel_scroll.min(max_scroll);
- let viewport_rows = agent_panel_visible_count_from(app, area, scroll);
-
- crate::pane::ScrollMetrics {
- offset_from_bottom: max_scroll.saturating_sub(scroll),
- max_offset_from_bottom: max_scroll,
- viewport_rows,
- }
-}
-
-pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
- let metrics = agent_panel_scroll_metrics(app, area);
- let body = agent_panel_body_rect(area, true);
- (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
- area.x + area.width.saturating_sub(1),
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn compute_workspace_list_areas(
- app: &AppState,
- area: Rect,
-) -> (Vec<crate::app::state::WorkspaceCardArea>, Vec<()>) {
- let ws_area = workspace_list_rect(area, app.sidebar_section_split);
- if ws_area == Rect::default() {
- return (Vec::new(), Vec::new());
- }
-
- let metrics = workspace_list_scroll_metrics(app, ws_area);
- let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
- if body.width == 0 || body.height == 0 {
- return (Vec::new(), Vec::new());
- }
-
- let scroll = app.workspace_scroll;
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- let mut cards = Vec::new();
- let headers = Vec::new();
-
- let entries = workspace_list_entries(app);
- for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) {
- match entry {
- WorkspaceListEntry::Workspace { ws_idx, indented } => {
- let Some(ws) = app.workspaces.get(*ws_idx) else {
- continue;
- };
- let row_height = workspace_row_height_in_body(app, ws, *indented, body.height);
- let gap = workspace_entry_gap(app, &entries, entry_idx);
- if row_y.saturating_add(row_height) > body_bottom {
- break;
- }
- cards.push(crate::app::state::WorkspaceCardArea {
- ws_idx: *ws_idx,
- rect: Rect::new(body.x, row_y, body.width, row_height),
- indented: *indented,
- });
- row_y = row_y
- .saturating_add(row_height)
- .saturating_add(gap)
- .min(body_bottom);
- }
- }
- }
-
- (cards, headers)
-}
-
-pub(crate) fn compute_workspace_card_areas(
- app: &AppState,
- area: Rect,
-) -> Vec<crate::app::state::WorkspaceCardArea> {
- compute_workspace_list_areas(app, area).0
-}
-
-pub(crate) fn workspace_group_chevron_rect(card: &crate::app::state::WorkspaceCardArea) -> Rect {
- if card.rect.width == 0 || card.rect.height == 0 {
- return Rect::default();
- }
-
- Rect::new(
- card.rect.x + card.rect.width.saturating_sub(1),
- card.rect.y,
- 1,
- 1,
- )
-}
-
-/// Auto-scale sidebar width based on workspace identity + agent summary.
-pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
- return (Rect::default(), None, Rect::default());
- }
-
- if content.height < 7 {
- return (content, None, Rect::default());
- }
-
- let total_h = content.height as usize;
- let ws_h = total_h.div_ceil(2);
- let detail_h = total_h.saturating_sub(ws_h + 1);
- if ws_h == 0 || detail_h == 0 {
- return (content, None, Rect::default());
- }
-
- let divider_y = content.y + ws_h as u16;
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
- let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16);
- (ws_area, Some(divider_y), detail_area)
-}
-
-fn workspace_selection_background(p: &Palette, is_active: bool) -> Color {
- if is_active && p.selection_bg == Color::Reset {
- p.active_row_bg
- } else {
- p.selection_bg
- }
-}
-
-/// Collapsed sidebar: workspace glance on top, compact agent list below.
-pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.width == 0 || area.height == 0 {
- return;
- }
-
- let is_navigating = matches!(app.mode, Mode::Navigate);
-
- let p = &app.palette;
- frame
- .buffer_mut()
- .set_style(area, Style::default().bg(p.sidebar_bg));
- let sep_style = if is_navigating {
- Style::default().fg(p.accent)
- } else {
- Style::default().fg(p.surface_dim)
- };
- let sep_x = area.x + area.width.saturating_sub(1);
- let buf = frame.buffer_mut();
- for y in area.y..area.y + area.height {
- buf[(sep_x, y)].set_symbol("│");
- buf[(sep_x, y)].set_style(sep_style);
- }
-
- let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
- if ws_area == Rect::default() {
- render_sidebar_toggle(app, frame, area, true, p);
- return;
- }
-
- for (visible_idx, ws) in app.workspaces.iter().enumerate() {
- let y = ws_area.y + visible_idx as u16;
- if y >= ws_area.y + ws_area.height {
- break;
- }
- let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals);
- let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p);
- let is_selected = visible_idx == app.selected && is_navigating;
- let is_active = Some(visible_idx) == app.active;
- let selection_bg = workspace_selection_background(p, is_active);
- let row_style = if is_selected {
- Style::default().bg(selection_bg)
- } else if is_active {
- Style::default().bg(p.active_row_bg)
- } else {
- Style::default()
- };
- let num_style = if is_selected {
- Style::default().fg(p.overlay1).bg(selection_bg)
- } else if is_active {
- Style::default().fg(p.text).bg(p.active_row_bg)
- } else {
- Style::default().fg(p.overlay0)
- };
-
- if is_selected || is_active {
- let buf = frame.buffer_mut();
- for x in ws_area.x..ws_area.x + ws_area.width {
- buf[(x, y)].set_style(row_style);
- }
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(format!("{:<2}", visible_idx + 1), num_style),
- Span::styled(icon, icon_style),
- ])),
- Rect::new(ws_area.x, y, ws_area.width, 1),
- );
- }
-
- if let Some(divider_y) = divider_y {
- let buf = frame.buffer_mut();
- let divider_color = if app.agent_view_override.is_some() {
- p.accent
- } else {
- p.surface_dim
- };
- for x in ws_area.x..ws_area.x + ws_area.width {
- buf[(x, divider_y)].set_symbol("─");
- buf[(x, divider_y)].set_style(Style::default().fg(divider_color));
- }
- }
-
- let detail_content_area = Rect::new(
- detail_area.x,
- detail_area.y,
- detail_area.width,
- detail_area.height.saturating_sub(1),
- );
- if detail_content_area != Rect::default() {
- for (detail_idx, detail) in agent_panel_entries(app).iter().enumerate() {
- let y = detail_content_area.y + detail_idx as u16;
- if y >= detail_content_area.y + detail_content_area.height {
- break;
- }
- let position = detail_idx + 1;
- let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id);
- let position_style = if is_active {
- Style::default().fg(p.text).bg(p.active_row_bg)
- } else {
- Style::default().fg(p.overlay0)
- };
- let (icon, icon_style) =
- state_icon(detail.state, detail.seen, app.status_indicators, p);
-
- if is_active {
- let buf = frame.buffer_mut();
- for x in detail_content_area.x..detail_content_area.x + detail_content_area.width {
- buf[(x, y)].set_style(Style::default().bg(p.active_row_bg));
- }
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(format!("{position:<2}"), position_style),
- Span::styled(icon, icon_style),
- ])),
- Rect::new(detail_content_area.x, y, detail_content_area.width, 1),
- );
- }
- }
-
- render_sidebar_toggle(app, frame, area, true, p);
-}
-
-pub(crate) fn workspace_drop_slots(
- app: &AppState,
- cards: &[crate::app::state::WorkspaceCardArea],
- area: Rect,
-) -> Vec<(crate::app::state::WorkspaceDropTarget, u16)> {
- if area.height == 0 || cards.is_empty() {
- return Vec::new();
- }
- let list_bottom = area.y + area.height.saturating_sub(1);
- let entries = workspace_list_entries(app);
- let entry_position = |ws_idx| {
- entries.iter().position(|entry| {
- matches!(
- entry,
- WorkspaceListEntry::Workspace {
- ws_idx: entry_ws_idx,
- ..
- } if *entry_ws_idx == ws_idx
- )
- })
- };
- let block_root_at = |entry_idx: usize| {
- entries[..=entry_idx]
- .iter()
- .rev()
- .find_map(|entry| match entry {
- WorkspaceListEntry::Workspace {
- ws_idx,
- indented: false,
- } => Some(*ws_idx),
- WorkspaceListEntry::Workspace { .. } => None,
- })
- };
-
- let mut slots = Vec::new();
- let mut previous_root = None;
- for card in cards {
- let Some(entry_idx) = entry_position(card.ws_idx) else {
- continue;
- };
- let Some(root_idx) = block_root_at(entry_idx) else {
- continue;
- };
- if previous_root == Some(root_idx) {
- continue;
- }
- previous_root = Some(root_idx);
- if let Some(row) = card.rect.y.checked_sub(1).filter(|row| *row < list_bottom) {
- slots.push((
- crate::app::state::WorkspaceDropTarget::Before(root_idx),
- row,
- ));
- }
- }
-
- let Some(last) = cards.last() else {
- return slots;
- };
- let Some(last_entry_idx) = entry_position(last.ws_idx) else {
- return slots;
- };
- let next_entry = entries.get(last_entry_idx.saturating_add(1));
- if matches!(
- next_entry,
- Some(WorkspaceListEntry::Workspace { indented: true, .. })
- ) {
- return slots;
- }
- let target = match next_entry {
- Some(WorkspaceListEntry::Workspace { ws_idx, .. }) => {
- crate::app::state::WorkspaceDropTarget::Before(*ws_idx)
- }
- None => crate::app::state::WorkspaceDropTarget::End,
- };
- let row = last.rect.y.saturating_add(last.rect.height);
- if row < list_bottom
- && slots
- .last()
- .is_none_or(|(last_target, _)| *last_target != target)
- {
- slots.push((target, row));
- }
- slots
-}
-
-pub(crate) fn workspace_drop_indicator_row(
- app: &AppState,
- cards: &[crate::app::state::WorkspaceCardArea],
- area: Rect,
- target: crate::app::state::WorkspaceDropTarget,
-) -> Option<u16> {
- workspace_drop_slots(app, cards, area)
- .into_iter()
- .find_map(|(candidate, row)| (candidate == target).then_some(row))
-}
-
-pub(super) fn render_sidebar(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- let p = &app.palette;
- frame
- .buffer_mut()
- .set_style(area, Style::default().bg(p.sidebar_bg));
- let is_navigating = matches!(app.mode, Mode::Navigate);
- let sep_style = if is_navigating {
- Style::default().fg(p.accent)
- } else {
- Style::default().fg(p.surface_dim)
- };
-
- let sep_x = area.x + area.width.saturating_sub(1);
- let buf = frame.buffer_mut();
- for y in area.y..area.y + area.height {
- buf[(sep_x, y)].set_symbol("│");
- buf[(sep_x, y)].set_style(sep_style);
- }
-
- let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
-
- render_workspace_list(app, terminal_runtimes, frame, ws_area, is_navigating);
- render_agent_detail(app, terminal_runtimes, frame, detail_area);
- render_sidebar_toggle(app, frame, area, false, p);
-}
-
-fn resolved_token_spans(
+pub(crate) fn resolved_token_spans(
resolved: &[ResolvedToken],
state_icon: (&str, Style),
state_text_style: Style,
workspace_style: Style,
secondary_style: Style,
custom_style: Style,
- p: &Palette,
+ palette: &Palette,
max_width: usize,
) -> Vec<Span<'static>> {
let fixed_widths = resolved
@@ -1116,6 +210,7 @@ fn resolved_token_spans(
break;
}
}
+
let mut spans = Vec::new();
for (position, index) in visible_indices.iter().copied().enumerate() {
let token = &resolved[index];
@@ -1123,42 +218,36 @@ fn resolved_token_spans(
let previous = &resolved[visible_indices[position - 1]];
spans.push(Span::styled(
tokens::separator(previous, token),
- Style::default().fg(p.overlay0).add_modifier(Modifier::DIM),
+ Style::default()
+ .fg(palette.overlay0)
+ .add_modifier(Modifier::DIM),
));
}
match &token.kind {
- ResolvedTokenKind::StateIcon => {
- spans.push(Span::styled(
- state_icon.0.to_string(),
- apply_token_style(state_icon.1, token.style),
- ));
- }
- ResolvedTokenKind::StateText(text) => {
- spans.push(Span::styled(
- truncate_end(text, budgets[index]),
- apply_token_style(state_text_style, token.style),
- ));
- }
- ResolvedTokenKind::Workspace(text) => {
- spans.push(Span::styled(
- truncate_end(text, budgets[index]),
- apply_token_style(workspace_style, token.style),
- ));
- }
+ ResolvedTokenKind::StateIcon => spans.push(Span::styled(
+ state_icon.0.to_string(),
+ apply_token_style(state_icon.1, token.style),
+ )),
+ ResolvedTokenKind::StateText(text) => spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ apply_token_style(state_text_style, token.style),
+ )),
+ ResolvedTokenKind::Workspace(text) => spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ apply_token_style(workspace_style, token.style),
+ )),
ResolvedTokenKind::Tab(text)
| ResolvedTokenKind::Pane(text)
| ResolvedTokenKind::Agent(text)
- | ResolvedTokenKind::Branch(text) => {
- spans.push(Span::styled(
- truncate_end(text, budgets[index]),
- apply_token_style(secondary_style, token.style),
- ));
- }
+ | ResolvedTokenKind::Branch(text) => spans.push(Span::styled(
+ truncate_end(text, budgets[index]),
+ apply_token_style(secondary_style, token.style),
+ )),
ResolvedTokenKind::GitStatus { ahead, behind } => {
if *ahead > 0 {
spans.push(Span::styled(
format!("↑{ahead}"),
- apply_token_style(Style::default().fg(p.green), token.style),
+ apply_token_style(Style::default().fg(palette.green), token.style),
));
}
if *ahead > 0 && *behind > 0 {
@@ -1170,7 +259,7 @@ fn resolved_token_spans(
if *behind > 0 {
spans.push(Span::styled(
format!("↓{behind}"),
- apply_token_style(Style::default().fg(p.red), token.style),
+ apply_token_style(Style::default().fg(palette.red), token.style),
));
}
}
@@ -1186,8 +275,8 @@ fn resolved_token_spans(
}
fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) -> Style {
- if let Some(fg) = patch.fg {
- style = style.fg(fg.ratatui());
+ if let Some(foreground) = patch.fg {
+ style = style.fg(foreground.ratatui());
}
if let Some(bold) = patch.bold {
style = if bold {
@@ -1205,1974 +294,3 @@ fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle)
}
style
}
-
-fn render_workspace_list(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
- is_navigating: bool,
-) {
- let p = &app.palette;
- let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) {
- Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => {
- Some(*source_ws_idx)
- }
- _ => None,
- };
- let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) {
- Some(crate::app::state::DragTarget::WorkspaceReorder {
- drop_target: Some(drop_target),
- ..
- }) => workspace_drop_indicator_row(app, &app.view.workspace_card_areas, area, *drop_target),
- _ => None,
- };
-
- let list_bottom = area.y + area.height.saturating_sub(1);
- if area.height > 0 {
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " spaces",
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- )])),
- Rect::new(area.x, area.y, area.width, 1),
- );
- }
-
- let metrics = workspace_list_scroll_metrics(app, area);
- let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
- let cards = &app.view.workspace_card_areas;
- let entries = workspace_list_entries(app);
-
- for card in cards {
- let i = card.ws_idx;
- let ws = &app.workspaces[i];
- let row_y = card.rect.y;
- let row_height = card.rect.height;
- let selected = i == app.selected && is_navigating;
- let is_active = Some(i) == app.active;
- let is_dragged = dragged_ws_idx == Some(i);
- let highlighted = selected || is_active || is_dragged;
- let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals);
-
- if highlighted {
- let bg = if selected {
- workspace_selection_background(p, is_active)
- } else if is_dragged {
- p.surface1
- } else {
- p.active_row_bg
- };
- let buf = frame.buffer_mut();
- for y in row_y..row_y + row_height {
- if y >= list_bottom {
- break;
- }
- for x in card.rect.x..card.rect.x + card.rect.width {
- buf[(x, y)].set_style(Style::default().bg(bg));
- }
- }
- }
-
- let name_style = if selected || is_active || is_dragged {
- Style::default().fg(p.text).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.subtext0)
- };
-
- let label = ws.display_name_from(&app.terminals, terminal_runtimes);
- let display_label = if card.indented {
- grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some())
- } else {
- label
- };
- let parent_group = (!card.indented)
- .then(|| workspace_parent_group_state(app, i))
- .flatten();
- let is_last_child = card.indented
- && entries
- .iter()
- .position(|entry| {
- matches!(
- entry,
- WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == i
- )
- })
- .is_none_or(|entry_idx| !next_entry_is_indented_workspace(&entries, entry_idx));
- let (display_state, display_seen) = parent_group
- .as_ref()
- .filter(|(_, collapsed)| *collapsed)
- .map(|(key, _)| space_aggregate_state(app, key))
- .unwrap_or((agg_state, agg_seen));
- let state_icon = state_icon(display_state, display_seen, app.status_indicators, p);
- let state_text_style = Style::default()
- .fg(state_label_color(display_state, display_seen, p))
- .add_modifier(Modifier::DIM);
- let branch_style = Style::default().fg(if selected || is_active {
- p.mauve
- } else {
- p.overlay0
- });
- let token_values = ws.metadata_tokens.values();
- let rows = tokens::space_rows(
- &app.sidebar_spaces,
- SpaceTokenContext {
- workspace: &display_label,
- branch: ws.branch().as_deref(),
- state_text: state_label(display_state, display_seen),
- ahead_behind: ws.git_ahead_behind(),
- tokens: &token_values,
- suppress_git_details: card.indented,
- },
- );
-
- for (row_index, resolved) in rows.iter().enumerate() {
- if row_index as u16 >= row_height || row_y + row_index as u16 >= list_bottom {
- break;
- }
- let mut spans = Vec::new();
- let prefix_width = if card.indented {
- spans.push(Span::raw(" "));
- if row_index == 0 {
- spans.push(Span::styled(
- if is_last_child { "└─ " } else { "├─ " },
- Style::default().fg(p.overlay0),
- ));
- 6
- } else if is_last_child {
- spans.push(Span::raw(" "));
- 8
- } else {
- spans.push(Span::styled("│", Style::default().fg(p.overlay0)));
- spans.push(Span::raw(" "));
- 8
- }
- } else if row_index == 0 {
- spans.push(Span::raw(" "));
- 1
- } else {
- spans.push(Span::raw(" "));
- 3
- };
- let trailing_width = if row_index == 0 && parent_group.is_some() {
- 2
- } else {
- 0
- };
- spans.extend(resolved_token_spans(
- resolved,
- state_icon,
- state_text_style,
- name_style,
- branch_style,
- branch_style,
- p,
- card.rect
- .width
- .saturating_sub(prefix_width + trailing_width) as usize,
- ));
- frame.render_widget(
- Paragraph::new(Line::from(spans)),
- Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1),
- );
- }
-
- if let Some((_, collapsed)) = parent_group {
- frame.render_widget(
- Paragraph::new(Span::styled(
- if collapsed { "▸" } else { "▾" },
- Style::default().fg(p.accent),
- )),
- workspace_group_chevron_rect(card),
- );
- }
- }
-
- if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
- let indicator_right = scrollbar_rect
- .map(|rect| rect.x)
- .unwrap_or(area.x + area.width);
- let buf = frame.buffer_mut();
- for x in area.x..indicator_right {
- buf[(x, y)].set_symbol("─");
- buf[(x, y)].set_style(Style::default().fg(p.accent));
- }
- }
-
- if let Some(track) = scrollbar_rect {
- render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
- }
-
- if app.mouse_capture && list_bottom > area.y {
- let new_rect = app.sidebar_new_button_rect();
- frame.render_widget(
- Paragraph::new(Span::styled(" new", Style::default().fg(p.overlay0))),
- new_rect,
- );
-
- let menu_rect = app.global_launcher_rect();
- let menu_line = if app.global_menu_attention_badge_visible() {
- Line::from(vec![
- Span::styled(
- "● ",
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- Span::styled("menu", Style::default().fg(p.overlay0)),
- ])
- } else {
- Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))])
- };
- frame.render_widget(
- Paragraph::new(menu_line).alignment(Alignment::Right),
- menu_rect,
- );
- }
-}
-
-fn render_agent_detail(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- let p = &app.palette;
-
- if area.height < 3 {
- return;
- }
-
- let sep_line = "─".repeat(area.width as usize);
- frame.render_widget(
- Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))),
- Rect::new(area.x, area.y, area.width, 1),
- );
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " agents",
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- )])),
- Rect::new(area.x, area.y + 1, area.width, 1),
- );
- let control_label = active_agent_view_label(app)
- .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort));
- let toggle_rect = agent_panel_header_label_rect(area, control_label);
- if toggle_rect != Rect::default() {
- let color = if app.agent_view_override.is_some() {
- p.accent
- } else {
- p.overlay0
- };
- frame.render_widget(
- Paragraph::new(Span::styled(
- control_label,
- Style::default().fg(color).add_modifier(Modifier::BOLD),
- ))
- .alignment(Alignment::Right),
- toggle_rect,
- );
- }
-
- let details = agent_panel_entries_from(app, terminal_runtimes);
- let metrics = agent_panel_scroll_metrics(app, area);
- let scrollbar_rect = agent_panel_scrollbar_rect(app, area);
- let body = agent_panel_body_rect(area, should_show_scrollbar(metrics));
- if body == Rect::default() {
- return;
- }
- if details.is_empty() && app.agent_view_override.is_some() {
- frame.render_widget(
- Paragraph::new(" no matching agents")
- .style(Style::default().fg(p.overlay0).add_modifier(Modifier::DIM)),
- Rect::new(body.x, body.y, body.width, 1),
- );
- return;
- }
-
- let scroll = app.agent_panel_scroll.min(metrics.max_offset_from_bottom);
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- for (index, detail) in details.iter().enumerate().skip(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);
- if row_y.saturating_add(height) > body_bottom {
- break;
- }
-
- let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id);
- let row_style = if is_active {
- Style::default().bg(p.active_row_bg)
- } else {
- Style::default()
- };
- let name_style = if is_active {
- Style::default().fg(p.text).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD)
- };
- let status_style = if is_active {
- Style::default().fg(label_color)
- } else {
- Style::default().fg(label_color).add_modifier(Modifier::DIM)
- };
- let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
- let state_icon = state_icon(detail.state, detail.seen, app.status_indicators, p);
-
- for (row_index, resolved) in rows.iter().take(height as usize).enumerate() {
- let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })];
- spans.extend(resolved_token_spans(
- resolved,
- state_icon,
- status_style,
- name_style,
- agent_style,
- agent_style,
- p,
- body.width
- .saturating_sub(if row_index == 0 { 1 } else { 3 }) as usize,
- ));
- frame.render_widget(
- Paragraph::new(Line::from(spans)).style(row_style),
- Rect::new(body.x, row_y + row_index as u16, body.width, 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 {
- render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
- }
-}
-
-pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect {
- let bottom_y = area.y + area.height.saturating_sub(1);
- let content_w = area.width.saturating_sub(1);
- if content_w == 0 || area.height == 0 {
- return Rect::default();
- }
- let x = area.x + content_w / 2;
- Rect::new(x, bottom_y, 1, 1)
-}
-
-pub(crate) fn expanded_sidebar_toggle_rect(area: Rect) -> Rect {
- if area.width <= 1 || area.height == 0 {
- return Rect::default();
- }
- Rect::new(
- area.x + area.width.saturating_sub(2),
- area.y + area.height.saturating_sub(1),
- 1,
- 1,
- )
-}
-
-fn render_sidebar_toggle(
- app: &AppState,
- frame: &mut Frame,
- area: Rect,
- collapsed: bool,
- p: &Palette,
-) {
- let toggle_area = if collapsed {
- collapsed_sidebar_toggle_rect(area)
- } else {
- expanded_sidebar_toggle_rect(area)
- };
- if toggle_area == Rect::default() {
- return;
- }
- let icon = if collapsed { "»" } else { "«" };
- let icon_style = if collapsed && app.global_menu_attention_badge_visible() {
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.overlay0)
- };
- frame.render_widget(Paragraph::new(Span::styled(icon, icon_style)), toggle_area);
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::{detect::Agent, layout::PaneId, workspace::Workspace};
- use ratatui::{backend::TestBackend, layout::Direction, Terminal};
-
- fn row_text(buffer: &ratatui::buffer::Buffer, row: u16, width: u16) -> String {
- (0..width)
- .map(|x| buffer[(x, row)].symbol())
- .collect::<String>()
- .trim_end()
- .to_string()
- }
-
- fn find_symbol_x(buffer: &ratatui::buffer::Buffer, row: u16, width: u16, symbol: &str) -> u16 {
- (0..width)
- .find(|x| buffer[(*x, row)].symbol() == symbol)
- .unwrap_or_else(|| {
- panic!(
- "missing symbol {symbol:?} in row {}",
- row_text(buffer, row, width)
- )
- })
- }
-
- #[test]
- fn expanded_and_collapsed_sidebars_use_custom_background() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces.clear();
- app.active = None;
- app.palette.sidebar_bg = ratatui::style::Color::Rgb(12, 34, 56);
- let area = Rect::new(0, 0, 26, 20);
-
- let mut expanded = Terminal::new(TestBackend::new(26, 20)).unwrap();
- expanded
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- assert!(expanded
- .backend()
- .buffer()
- .content
- .iter()
- .all(|cell| cell.bg == app.palette.sidebar_bg));
-
- let mut collapsed = Terminal::new(TestBackend::new(26, 20)).unwrap();
- collapsed
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .unwrap();
- assert!(collapsed
- .backend()
- .buffer()
- .content
- .iter()
- .all(|cell| cell.bg == app.palette.sidebar_bg));
- }
-
- #[test]
- fn default_agent_rows_remove_redundant_state_text() {
- let mut app = crate::app::state::AppState::test_new();
- let workspace = Workspace::test_new("one");
- let pane_id = workspace.tabs[0].root_pane;
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.active = Some(0);
- let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- let terminal_state = app.terminals.get_mut(&terminal_id).unwrap();
- terminal_state.detected_agent = Some(Agent::Pi);
- terminal_state.state = AgentState::Working;
-
- let area = Rect::new(0, 0, 26, 20);
- let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let buffer = terminal.backend().buffer();
- let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
- let body = agent_panel_body_rect(agent_area, false);
-
- let first = row_text(buffer, body.y, 25);
- let second = row_text(buffer, body.y + 1, 25);
- assert!(first.contains("one"));
- assert_eq!(second, " pi");
- assert!(!first.contains("working"));
- assert!(!second.contains("working"));
-
- let workspace_x = find_symbol_x(buffer, body.y, body.width, "o");
- let workspace_style = buffer[(workspace_x, body.y)].style();
- assert_eq!(workspace_style.fg, Some(app.palette.text));
- assert!(workspace_style.add_modifier.contains(Modifier::BOLD));
- assert!(!workspace_style.add_modifier.contains(Modifier::DIM));
- assert_eq!(workspace_style.bg, Some(app.palette.active_row_bg));
-
- let agent_x = find_symbol_x(buffer, body.y + 1, body.width, "p");
- let agent_style = buffer[(agent_x, body.y + 1)].style();
- assert_eq!(agent_style.fg, Some(app.palette.overlay0));
- assert!(agent_style.add_modifier.contains(Modifier::DIM));
- assert!(!agent_style.add_modifier.contains(Modifier::BOLD));
- assert_eq!(agent_style.bg, Some(app.palette.active_row_bg));
- }
-
- #[test]
- fn occurrence_false_removes_default_workspace_bold_and_agent_dim() {
- let config: crate::config::Config = toml::from_str(
- r##"
-[ui.sidebar.agents]
-rows = [[{ token = "workspace", bold = false }, { token = "agent", dim = false }]]
-"##,
- )
- .unwrap();
- let mut app = crate::app::state::AppState::test_new();
- app.sidebar_agents = config.ui.sidebar.agents;
- let workspace = Workspace::test_new("one");
- let pane_id = workspace.tabs[0].root_pane;
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.active = Some(0);
- let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi);
-
- let area = Rect::new(0, 0, 26, 20);
- let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
- let body = agent_panel_body_rect(agent_area, false);
- let buffer = terminal.backend().buffer();
- let workspace = buffer[(find_symbol_x(buffer, body.y, body.width, "o"), body.y)].style();
- let agent = buffer[(find_symbol_x(buffer, body.y, body.width, "p"), body.y)].style();
-
- assert_eq!(workspace.fg, Some(app.palette.text));
- assert!(!workspace.add_modifier.contains(Modifier::BOLD));
- assert_eq!(agent.fg, Some(app.palette.overlay0));
- assert!(!agent.add_modifier.contains(Modifier::DIM));
- }
-
- #[test]
- fn default_space_workspace_style_tracks_active_state() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
- app.active = Some(0);
- app.mode = Mode::Terminal;
- let area = Rect::new(0, 0, 26, 20);
- app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
- let first_row = app.view.workspace_card_areas[0].rect.y;
- let second_row = app.view.workspace_card_areas[1].rect.y;
- let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let buffer = terminal.backend().buffer();
-
- let active = buffer[(find_symbol_x(buffer, first_row, 25, "o"), first_row)].style();
- assert_eq!(active.fg, Some(app.palette.text));
- assert!(active.add_modifier.contains(Modifier::BOLD));
- assert!(!active.add_modifier.contains(Modifier::DIM));
- assert_eq!(active.bg, Some(app.palette.active_row_bg));
-
- let inactive = buffer[(find_symbol_x(buffer, second_row, 25, "t"), second_row)].style();
- assert_eq!(inactive.fg, Some(app.palette.subtext0));
- assert!(!inactive
- .add_modifier
- .intersects(Modifier::BOLD | Modifier::DIM));
- assert_eq!(inactive.bg, Some(ratatui::style::Color::Reset));
- }
-
- #[test]
- fn navigate_selection_keeps_its_existing_background_beside_active_workspace() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
- app.active = Some(0);
- app.selected = 1;
- app.mode = Mode::Navigate;
- let area = Rect::new(0, 0, 26, 20);
- app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
- let active_row = app.view.workspace_card_areas[0].rect.y;
- let selected_row = app.view.workspace_card_areas[1].rect.y;
- let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let buffer = terminal.backend().buffer();
-
- assert_eq!(
- buffer[(0, active_row)].bg,
- app.palette.active_row_bg,
- "active workspace should keep its dedicated background"
- );
- assert_eq!(
- buffer[(0, selected_row)].bg,
- app.palette.selection_bg,
- "navigate selection should use its dedicated cursor background"
- );
- }
-
- #[test]
- fn selected_active_workspace_resolves_expanded_background() {
- let mut app = crate::app::state::AppState::test_new();
- app.palette = crate::app::state::Palette::terminal();
- app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Navigate;
- let area = Rect::new(0, 0, 26, 20);
- app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
- let active_row = app.view.workspace_card_areas[0].rect.y;
- let inactive_row = app.view.workspace_card_areas[1].rect.y;
- let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
-
- assert_eq!(
- terminal.backend().buffer()[(0, active_row)].bg,
- app.palette.active_row_bg
- );
-
- app.selected = 1;
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- assert_eq!(
- terminal.backend().buffer()[(0, active_row)].bg,
- app.palette.active_row_bg
- );
- assert_eq!(
- terminal.backend().buffer()[(0, inactive_row)].bg,
- app.palette.selection_bg
- );
-
- app.palette = crate::app::state::Palette::catppuccin();
- app.selected = 0;
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- assert_eq!(
- terminal.backend().buffer()[(0, active_row)].bg,
- app.palette.selection_bg
- );
- }
-
- #[test]
- fn selected_active_workspace_resolves_collapsed_background() {
- let mut app = crate::app::state::AppState::test_new();
- app.palette = crate::app::state::Palette::terminal();
- app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Navigate;
- let area = Rect::new(0, 0, 5, 8);
- let mut terminal = Terminal::new(TestBackend::new(5, 8)).unwrap();
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .unwrap();
-
- let (workspace_area, _, _) = collapsed_sidebar_sections(area);
- assert_eq!(
- terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg,
- app.palette.active_row_bg
- );
-
- app.selected = 1;
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .unwrap();
- assert_eq!(
- terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg,
- app.palette.active_row_bg
- );
- assert_eq!(
- terminal.backend().buffer()[(workspace_area.x, workspace_area.y + 1)].bg,
- app.palette.selection_bg
- );
-
- app.palette = crate::app::state::Palette::catppuccin();
- app.selected = 0;
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .unwrap();
- assert_eq!(
- terminal.backend().buffer()[(workspace_area.x, workspace_area.y)].bg,
- app.palette.selection_bg
- );
- }
-
- #[test]
- fn space_occurrence_style_applies_without_styling_separator() {
- let config: crate::config::Config = toml::from_str(
- r##"
-[ui.sidebar.spaces]
-rows = [[{ token = "$hype", fg = "#abcdef", bold = true, dim = false }, "workspace"]]
-"##,
- )
- .unwrap();
- let mut app = crate::app::state::AppState::test_new();
- app.sidebar_spaces = config.ui.sidebar.spaces;
- app.workspaces = vec![Workspace::test_new("one")];
- app.active = Some(0);
- app.mode = Mode::Terminal;
- app.workspaces[0].metadata_tokens.patch(
- std::collections::HashMap::from([("hype".into(), Some("HI".into()))]),
- None,
- std::time::Instant::now(),
- );
-
- let area = Rect::new(0, 0, 26, 20);
- app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
- let row = app.view.workspace_card_areas[0].rect.y;
- let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let buffer = terminal.backend().buffer();
- let h = buffer[(find_symbol_x(buffer, row, 25, "H"), row)].style();
- let i = buffer[(find_symbol_x(buffer, row, 25, "I"), row)].style();
- let separator = buffer[(find_symbol_x(buffer, row, 25, "·"), row)].style();
-
- for style in [h, i] {
- assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(0xab, 0xcd, 0xef)));
- assert!(style.add_modifier.contains(Modifier::BOLD));
- assert!(!style.add_modifier.contains(Modifier::DIM));
- assert_eq!(style.bg, Some(app.palette.active_row_bg));
- }
- assert_eq!(separator.fg, Some(app.palette.overlay0));
- assert!(separator.add_modifier.contains(Modifier::DIM));
- assert!(!separator.add_modifier.contains(Modifier::BOLD));
- assert_eq!(separator.bg, Some(app.palette.active_row_bg));
- }
-
- #[test]
- fn occurrence_foreground_flattens_composite_git_status_colors() {
- let config: crate::config::Config = toml::from_str(
- r##"[ui.sidebar.spaces]
-rows = [[{ token = "git_status", fg = "#123456" }]]
-"##,
- )
- .unwrap();
- let spans = resolved_token_spans(
- &[ResolvedToken {
- kind: ResolvedTokenKind::GitStatus {
- ahead: 2,
- behind: 1,
- },
- style: config.ui.sidebar.spaces.rows[0][0].parts().1,
- }],
- ("", Style::default()),
- Style::default(),
- Style::default(),
- Style::default(),
- Style::default(),
- &crate::app::state::AppState::test_new().palette,
- 20,
- );
-
- assert_eq!(
- spans
- .iter()
- .map(|span| span.content.as_ref())
- .collect::<String>(),
- "↑2 ↓1"
- );
- assert!(spans
- .iter()
- .all(|span| { span.style.fg == Some(ratatui::style::Color::Rgb(0x12, 0x34, 0x56)) }));
- }
-
- #[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");
- let tab_idx = workspace.test_add_tab(Some("logs"));
- let pane_id = workspace.tabs[tab_idx].root_pane;
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- let terminal_id = app.workspaces[0].tabs[tab_idx].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi);
-
- let area = Rect::new(0, 0, 18, 20);
- let mut terminal = Terminal::new(TestBackend::new(18, 20)).unwrap();
- terminal
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let buffer = terminal.backend().buffer();
- let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
- let body = agent_panel_body_rect(agent_area, false);
- let first = row_text(buffer, body.y, 17);
-
- assert!(first.contains("logs"), "rendered row: {first:?}");
- assert!(first.contains('·'), "rendered row: {first:?}");
- }
-
- #[test]
- fn stripped_terminal_title_renders_with_unicode_width_truncation() {
- let mut app = crate::app::state::AppState::test_new();
- let workspace = Workspace::test_new("one");
- let pane_id = workspace.tabs[0].root_pane;
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- let terminal = app.terminals.get_mut(&terminal_id).unwrap();
- terminal.detected_agent = Some(Agent::Claude);
- terminal.set_terminal_title(Some("⠋ 修复🙂标题很长".into()));
- app.sidebar_agents.rows = vec![vec![
- crate::config::AgentSidebarToken::TerminalTitleStripped,
- ]];
-
- let area = Rect::new(0, 0, 10, 12);
- let mut renderer = Terminal::new(TestBackend::new(10, 12)).unwrap();
- renderer
- .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
- .unwrap();
- let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
- let body = agent_panel_body_rect(agent_area, false);
- let rendered = row_text(renderer.backend().buffer(), body.y, 9);
-
- assert!(!rendered.contains('⠋'));
- assert!(rendered.contains('修') && rendered.contains('复'));
-
- let spans = resolved_token_spans(
- &[ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle(
- "修复🙂标题很长".into(),
- ))],
- ("", Style::default()),
- Style::default(),
- Style::default(),
- Style::default(),
- Style::default(),
- &app.palette,
- 8,
- );
- let text = spans
- .iter()
- .map(|span| span.content.as_ref())
- .collect::<String>();
- assert!(display_width(&text) <= 8, "resolved title: {text:?}");
- }
-
- #[test]
- fn variable_agent_heights_pack_the_bottom_and_reveal_targets() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![
- Workspace::test_new("one"),
- Workspace::test_new("two"),
- Workspace::test_new("three"),
- ];
- app.ensure_test_terminals();
- for workspace in &app.workspaces {
- 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::Pi);
- }
- let first_pane = app.workspaces[0].tabs[0].root_pane;
- let first_terminal = app.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.terminals
- .get_mut(&first_terminal)
- .unwrap()
- .metadata_tokens
- .patch(
- std::collections::HashMap::from([
- ("a".into(), Some("a".into())),
- ("b".into(), Some("b".into())),
- ]),
- None,
- std::time::Instant::now(),
- );
- app.sidebar_agents.rows = vec![
- vec![crate::config::AgentSidebarToken::Agent],
- vec![crate::config::AgentSidebarToken::Custom("a".into())],
- vec![crate::config::AgentSidebarToken::Custom("b".into())],
- ];
- let area = Rect::new(0, 0, 20, 6);
-
- let metrics = agent_panel_scroll_metrics(&app, area);
- assert_eq!(metrics.max_offset_from_bottom, 1);
- assert_eq!(agent_panel_scroll_for_target(&app, area, 0, 2), 1);
- }
-
- #[test]
- fn oversized_space_layout_is_clipped_to_the_section_body() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
- app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]; 6];
- let area = Rect::new(0, 0, 20, 10);
- let workspace_area = workspace_list_rect(area, app.sidebar_section_split);
- let body = workspace_list_body_rect(workspace_area, false);
-
- let metrics = workspace_list_scroll_metrics(&app, workspace_area);
- let (cards, _) = compute_workspace_list_areas(&app, area);
-
- assert_eq!(metrics.viewport_rows, 1);
- assert_eq!(cards.len(), 1);
- assert_eq!(cards[0].ws_idx, 0);
- assert_eq!(cards[0].rect.height, body.height);
- }
-
- #[test]
- fn oversized_agent_override_is_clipped_to_the_panel_body() {
- let mut app = crate::app::state::AppState::test_new();
- let workspace = Workspace::test_new("one");
- let pane_id = workspace.tabs[0].root_pane;
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude);
- app.sidebar_agents.rows_by_agent.insert(
- "claude".into(),
- vec![vec![crate::config::AgentSidebarToken::Agent]; 6],
- );
- let panel = Rect::new(0, 0, 20, 5);
-
- let metrics = agent_panel_scroll_metrics(&app, panel);
-
- assert_eq!(metrics.viewport_rows, 1);
- assert_eq!(metrics.max_offset_from_bottom, 0);
- let entry = agent_panel_entries(&app).pop().unwrap();
- assert_eq!(
- agent_entry_height_in_body(&app, &entry, agent_panel_body_rect(panel, false).height),
- agent_panel_body_rect(panel, false).height
- );
- }
-
- #[test]
- fn render_sidebar_toggle_draws_expanded_collapse_icon() {
- let app = crate::app::state::AppState::test_new();
- let area = Rect::new(0, 0, 26, 20);
- let mut terminal =
- Terminal::new(TestBackend::new(26, 20)).expect("test terminal should initialize");
-
- terminal
- .draw(|frame| render_sidebar_toggle(&app, frame, area, false, &app.palette))
- .expect("sidebar toggle should render");
-
- let toggle = expanded_sidebar_toggle_rect(area);
- assert_eq!(
- terminal.backend().buffer()[(toggle.x, toggle.y)].symbol(),
- "«"
- );
- }
-
- #[test]
- fn expanded_sidebar_toggle_sits_inside_sidebar_content() {
- let area = Rect::new(0, 0, 26, 20);
- let toggle = expanded_sidebar_toggle_rect(area);
-
- assert_eq!(toggle.x, area.x + area.width - 2);
- assert_eq!(toggle.y, area.y + area.height - 1);
- }
-
- #[test]
- fn agent_panel_tab_label_visibility_tracks_tab_identity() {
- let mut app = crate::app::state::AppState::test_new();
- let single_auto = Workspace::test_new("auto");
- let mut single_custom = Workspace::test_new("custom");
- single_custom.tabs[0].set_custom_name("focus".into());
- let mut multi = Workspace::test_new("multi");
- multi.test_add_tab(Some("logs"));
-
- app.workspaces = vec![single_auto, single_custom, multi];
- app.ensure_test_terminals();
- for (ws_idx, tab_idx, agent) in [
- (0, 0, Agent::Pi),
- (1, 0, Agent::Claude),
- (2, 0, Agent::Codex),
- (2, 1, Agent::Pi),
- ] {
- let pane_id = app.workspaces[ws_idx].tabs[tab_idx].root_pane;
- let terminal_id = app.workspaces[ws_idx].tabs[tab_idx].panes[&pane_id]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent);
- }
-
- let entries = agent_panel_entries(&app);
- let labels: Vec<_> = entries
- .iter()
- .map(|entry| {
- (
- entry.primary_label.as_str(),
- entry.primary_tab_label.as_deref(),
- )
- })
- .collect();
-
- assert_eq!(
- labels,
- [
- ("auto", None),
- ("custom", Some("focus")),
- ("multi", Some("1")),
- ("multi", Some("logs")),
- ]
- );
- }
-
- #[test]
- fn priority_agent_panel_sort_uses_attention_then_space_order() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![
- Workspace::test_new("one"),
- Workspace::test_new("two"),
- Workspace::test_new("three"),
- Workspace::test_new("four"),
- ];
- app.ensure_test_terminals();
- app.active = Some(0);
- app.selected = 0;
- app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
-
- let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, state| {
- let pane = app.workspaces[ws_idx].tabs[0].root_pane;
- let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane]
- .attached_terminal_id
- .clone();
- let terminal = app.terminals.get_mut(&terminal_id).unwrap();
- terminal.detected_agent = Some(Agent::Claude);
- terminal.state = state;
- };
- set_state(&mut app, 0, AgentState::Working);
- set_state(&mut app, 1, AgentState::Idle);
- set_state(&mut app, 2, AgentState::Working);
- set_state(&mut app, 3, AgentState::Blocked);
-
- let done_pane = app.workspaces[1].tabs[0].root_pane;
- app.workspaces[1].tabs[0]
- .panes
- .get_mut(&done_pane)
- .unwrap()
- .seen = false;
-
- let labels: Vec<String> = agent_panel_entries(&app)
- .into_iter()
- .map(|entry| entry.primary_label)
- .collect();
-
- assert_eq!(labels, ["four", "two", "one", "three"]);
- }
-
- #[test]
- fn collapsed_sidebar_numbers_grouped_agents_by_list_position() {
- 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 ws_idx in 0..app.workspaces.len() {
- let pane = app.workspaces[ws_idx].tabs[0].root_pane;
- let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude);
- }
-
- let area = Rect::new(0, 0, 4, 12);
- let (_, _, detail_area) = collapsed_sidebar_sections(area);
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height))
- .expect("test terminal should initialize");
-
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .expect("collapsed sidebar should render");
-
- let buffer = terminal.backend().buffer();
- assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1");
- assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2");
- }
-
- /// Two agent panes in one workspace plus a second workspace, so the
- /// assertions can tell pane-level highlighting apart from workspace-level.
- fn collapsed_agent_app() -> (crate::app::state::AppState, PaneId, PaneId) {
- let mut app = crate::app::state::AppState::test_new();
- let mut first = Workspace::test_new("one");
- let second_pane = first.test_split(Direction::Horizontal);
- let first_pane = first.tabs[0].root_pane;
- app.workspaces = vec![first, Workspace::test_new("two")];
- app.ensure_test_terminals();
-
- let terminal_ids: Vec<_> = app
- .workspaces
- .iter()
- .flat_map(|ws| ws.tabs.iter())
- .flat_map(|tab| tab.panes.values())
- .map(|pane| pane.attached_terminal_id.clone())
- .collect();
- for terminal_id in terminal_ids {
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude);
- }
-
- (app, first_pane, second_pane)
- }
-
- fn collapsed_agent_row_styles(
- app: &crate::app::state::AppState,
- area: Rect,
- detail_area: Rect,
- rows: u16,
- ) -> Vec<Vec<ratatui::style::Style>> {
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height))
- .expect("test terminal should initialize");
- terminal
- .draw(|frame| render_sidebar_collapsed(app, frame, area))
- .expect("collapsed sidebar should render");
- let buffer = terminal.backend().buffer();
- (0..rows)
- .map(|row| {
- (detail_area.x..detail_area.x + detail_area.width)
- .map(|x| buffer[(x, detail_area.y + row)].style())
- .collect()
- })
- .collect()
- }
-
- #[test]
- fn collapsed_sidebar_highlights_only_the_focused_agent_pane() {
- let (mut app, first_pane, second_pane) = collapsed_agent_app();
- app.active = Some(0);
- app.workspaces[0].tabs[0].layout.focus_pane(second_pane);
- assert!(app.is_active_pane(0, 0, second_pane));
- assert!(!app.is_active_pane(0, 0, first_pane));
-
- let area = Rect::new(0, 0, 4, 14);
- let (_, _, detail_area) = collapsed_sidebar_sections(area);
- let rows = collapsed_agent_row_styles(&app, area, detail_area, 3);
-
- let highlighted: Vec<_> = rows
- .iter()
- .filter(|cells| {
- cells
- .iter()
- .all(|style| style.bg == Some(app.palette.active_row_bg))
- })
- .collect();
- assert_eq!(
- highlighted.len(),
- 1,
- "only the focused agent pane should be highlighted, across the whole row"
- );
- assert_eq!(highlighted[0][0].fg, Some(app.palette.text));
-
- let muted = rows
- .iter()
- .filter(|cells| cells[0].fg == Some(app.palette.overlay0))
- .count();
- assert_eq!(
- muted, 2,
- "the sibling pane in the active workspace and the other workspace stay muted"
- );
- }
-
- #[test]
- fn collapsed_sidebar_does_not_highlight_agents_without_active_workspace() {
- let (mut app, _, _) = collapsed_agent_app();
- app.active = None;
-
- let area = Rect::new(0, 0, 4, 14);
- let (_, _, detail_area) = collapsed_sidebar_sections(area);
- let rows = collapsed_agent_row_styles(&app, area, detail_area, 3);
-
- for cells in rows {
- assert_eq!(cells[0].fg, Some(app.palette.overlay0));
- for style in cells {
- assert_ne!(style.bg, Some(app.palette.active_row_bg));
- }
- }
- }
-
- #[test]
- fn collapsed_sidebar_keeps_workspace_status_visible_for_two_digit_positions() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = (1..=10)
- .map(|idx| Workspace::test_new(&format!("workspace-{idx}")))
- .collect();
- app.ensure_test_terminals();
-
- for ws_idx in 0..app.workspaces.len() {
- let pane = app.workspaces[ws_idx].tabs[0].root_pane;
- let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude);
- }
-
- let area = Rect::new(0, 0, 4, 25);
- let (workspace_area, _, _) = collapsed_sidebar_sections(area);
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height))
- .expect("test terminal should initialize");
-
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .expect("collapsed sidebar should render");
-
- let tenth_row = workspace_area.y + 9;
- let buffer = terminal.backend().buffer();
- assert_eq!(buffer[(workspace_area.x, workspace_area.y)].symbol(), "1");
- assert_eq!(
- buffer[(workspace_area.x + 1, workspace_area.y)].symbol(),
- " "
- );
- assert_eq!(
- buffer[(workspace_area.x + 2, workspace_area.y)].symbol(),
- "·"
- );
- assert_eq!(buffer[(workspace_area.x, tenth_row)].symbol(), "1");
- assert_eq!(buffer[(workspace_area.x + 1, tenth_row)].symbol(), "0");
- assert_eq!(buffer[(workspace_area.x + 2, tenth_row)].symbol(), "·");
- }
-
- #[test]
- fn collapsed_sidebar_keeps_status_visible_for_two_digit_positions() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = (1..=10)
- .map(|idx| Workspace::test_new(&format!("workspace-{idx}")))
- .collect();
- app.ensure_test_terminals();
-
- for ws_idx in 0..app.workspaces.len() {
- let pane = app.workspaces[ws_idx].tabs[0].root_pane;
- let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane]
- .attached_terminal_id
- .clone();
- app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Claude);
- }
-
- let area = Rect::new(0, 0, 4, 25);
- let (_, _, detail_area) = collapsed_sidebar_sections(area);
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height))
- .expect("test terminal should initialize");
-
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .expect("collapsed sidebar should render");
-
- let tenth_row = detail_area.y + 9;
- let buffer = terminal.backend().buffer();
- assert_eq!(buffer[(detail_area.x, tenth_row)].symbol(), "1");
- assert_eq!(buffer[(detail_area.x + 1, tenth_row)].symbol(), "0");
- assert_eq!(buffer[(detail_area.x + 2, tenth_row)].symbol(), "·");
- }
-
- #[test]
- fn collapsed_sidebar_numbers_priority_agents_by_list_position() {
- let first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
- let mut second = Workspace::test_new("two");
- let second_pane = second.tabs[0].root_pane;
- let urgent_pane = second.test_split(ratatui::layout::Direction::Horizontal);
-
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![first, second];
- app.ensure_test_terminals();
- app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
- app.status_indicators = crate::config::StatusIndicatorStyle::Symbols;
-
- let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, pane_id, state| {
- let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- let terminal = app.terminals.get_mut(&terminal_id).unwrap();
- terminal.detected_agent = Some(Agent::Claude);
- terminal.state = state;
- };
- set_state(&mut app, 0, first_pane, AgentState::Idle);
- set_state(&mut app, 1, second_pane, AgentState::Working);
- set_state(&mut app, 1, urgent_pane, AgentState::Blocked);
- app.workspaces[0].tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .seen = false;
-
- assert_eq!(app.workspaces[1].public_pane_number(urgent_pane), Some(2));
- assert_eq!(agent_panel_entries(&app)[0].pane_id, urgent_pane);
-
- let area = Rect::new(0, 0, 4, 16);
- let (_, _, detail_area) = collapsed_sidebar_sections(area);
- let mut terminal = Terminal::new(TestBackend::new(area.width, area.height))
- .expect("test terminal should initialize");
-
- terminal
- .draw(|frame| render_sidebar_collapsed(&app, frame, area))
- .expect("collapsed sidebar should render");
-
- let buffer = terminal.backend().buffer();
- assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1");
- assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2");
- assert_eq!(buffer[(detail_area.x, detail_area.y + 2)].symbol(), "3");
- assert_eq!(buffer[(detail_area.x + 2, detail_area.y)].symbol(), "×");
- assert_eq!(
- buffer[(detail_area.x + 2, detail_area.y)].style().fg,
- Some(app.palette.red)
- );
- assert_eq!(buffer[(detail_area.x + 2, detail_area.y + 1)].symbol(), "✓");
- assert_eq!(
- buffer[(detail_area.x + 2, detail_area.y + 1)].style().fg,
- Some(app.palette.teal)
- );
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() {
- let unique = format!(
- "herdr-agent-panel-runtime-cwd-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- );
- let root = std::env::temp_dir().join(unique);
- let stale_cwd = root.join("issue-264-nix-support");
- let live_cwd = root.join("herdr");
- std::fs::create_dir_all(stale_cwd.join(".git")).unwrap();
- std::fs::create_dir_all(live_cwd.join(".git")).unwrap();
-
- let mut app = crate::app::state::AppState::test_new();
- let mut workspace = Workspace::test_new("stale-name");
- workspace.custom_name = None;
- workspace.identity_cwd = stale_cwd.clone();
- let pane = workspace.tabs[0].root_pane;
-
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- let terminal_id = app.workspaces[0].tabs[0].panes[&pane]
- .attached_terminal_id
- .clone();
- let terminal = app.terminals.get_mut(&terminal_id).unwrap();
- terminal.cwd = stale_cwd;
- terminal.detected_agent = Some(Agent::Pi);
- app.active = Some(0);
- app.selected = 0;
-
- let (events, _) = tokio::sync::mpsc::channel(4);
- let runtime = crate::terminal::TerminalRuntime::spawn(
- pane,
- 24,
- 80,
- live_cwd.clone(),
- 0,
- crate::terminal_theme::TerminalTheme::default(),
- None,
- crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
- &crate::pane::PaneLaunchEnv::default(),
- events,
- std::sync::Arc::new(tokio::sync::Notify::new()),
- std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
- )
- .unwrap();
-
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
- while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline {
- tokio::time::sleep(std::time::Duration::from_millis(10)).await;
- }
-
- let mut runtime_registry = TerminalRuntimeRegistry::new();
- runtime_registry.insert(terminal_id, runtime);
- let entries = agent_panel_entries_from(&app, &runtime_registry);
- let primary_label = entries[0].primary_label.clone();
-
- for (_, runtime) in runtime_registry.drain() {
- runtime.shutdown();
- }
- let _ = std::fs::remove_dir_all(root);
-
- assert_eq!(primary_label, "herdr");
- }
-
- #[test]
- fn all_workspaces_agent_panel_entries_prefer_agent_names_for_agent_identity() {
- let mut app = crate::app::state::AppState::test_new();
- let workspace = Workspace::test_new("bridge");
- let first_pane = workspace.tabs[0].root_pane;
-
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- let first_terminal_id = app.workspaces[0].tabs[0].panes[&first_pane]
- .attached_terminal_id
- .clone();
- app.terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- app.terminals
- .get_mut(&first_terminal_id)
- .unwrap()
- .set_agent_name("planner".into());
- app.active = Some(0);
- app.selected = 0;
-
- let entries = agent_panel_entries(&app);
- assert_eq!(entries[0].primary_label, "bridge");
- assert_eq!(entries[0].agent_label.as_deref(), Some("planner"));
- }
-
- #[test]
- fn expanded_sidebar_sections_handle_tiny_heights() {
- let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
-
- assert_eq!(ws_area, Rect::new(0, 0, 19, 3));
- assert_eq!(detail_area, Rect::new(0, 3, 19, 2));
- }
-
- #[test]
- fn sidebar_section_divider_is_hidden_for_tiny_heights() {
- let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5);
-
- assert_eq!(divider, Rect::default());
- }
-
- #[test]
- fn grouped_child_label_keeps_custom_workspace_name() {
- assert_eq!(
- grouped_child_display_label("renamed issue", Some("worktree/issue-137"), true),
- "renamed issue"
- );
- }
-
- #[test]
- fn grouped_child_label_uses_short_branch_for_auto_named_workspace() {
- assert_eq!(
- grouped_child_display_label("herdr-issue", Some("worktree/issue-137"), false),
- "issue-137"
- );
- }
-
- #[test]
- fn workspace_list_truncates_cjk_branch_without_panic() {
- let mut app = crate::app::state::AppState::test_new();
- let mut ws = Workspace::test_new("repo");
- ws.cached_git_branch = Some("feature/中文-分支-644".into());
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app.view.workspace_card_areas = vec![crate::app::state::WorkspaceCardArea {
- ws_idx: 0,
- rect: Rect::new(0, 1, 15, 2),
- indented: false,
- }];
-
- let mut terminal = Terminal::new(TestBackend::new(15, 6)).expect("test terminal");
- let runtimes = crate::terminal::TerminalRuntimeRegistry::new();
-
- terminal
- .draw(|frame| {
- render_workspace_list(&app, &runtimes, frame, Rect::new(0, 0, 15, 6), false)
- })
- .expect("workspace list should render");
- }
-
- fn workspace_with_worktree_space(
- name: &str,
- key: Option<&str>,
- checkout_key: &str,
- ) -> crate::workspace::Workspace {
- let mut ws = crate::workspace::Workspace::test_new(name);
- if let Some(key) = key {
- ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: key.into(),
- label: "herdr".into(),
- repo_root: std::path::PathBuf::from("/repo/herdr"),
- checkout_path: std::path::PathBuf::from(checkout_key),
- is_linked_worktree: name != "main",
- });
- }
- ws
- }
-
- fn workspace_with_git_space(name: &str, key: &str) -> crate::workspace::Workspace {
- let mut ws = crate::workspace::Workspace::test_new(name);
- ws.cached_git_space = Some(crate::workspace::GitSpaceMetadata {
- key: key.into(),
- checkout_key: format!("/repo/{name}"),
- repo_name: "herdr".into(),
- repo_root: std::path::PathBuf::from(format!("/repo/{name}")),
- is_linked_worktree: false,
- });
- ws
- }
-
- #[test]
- fn desktop_worktree_tree_aligns_parents_and_marks_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::StateIcon,
- 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 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();
-
- let buffer = terminal.backend().buffer();
- let cards = &app.view.workspace_card_areas;
- let parent_name_x = find_symbol_x(buffer, cards[0].rect.y, cards[0].rect.width, "m");
- let plain_name_x = find_symbol_x(buffer, cards[3].rect.y, cards[3].rect.width, "n");
- assert_eq!(parent_name_x, plain_name_x);
- assert_eq!(buffer[(cards[1].rect.x + 3, cards[1].rect.y)].symbol(), "├");
- assert_eq!(buffer[(cards[2].rect.x + 3, cards[2].rect.y)].symbol(), "└");
- assert_eq!(
- buffer[(cards[0].rect.x + cards[0].rect.width - 1, cards[0].rect.y)].symbol(),
- "▾"
- );
- }
-
- #[test]
- fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() {
- 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"),
- ];
- app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
- app.sidebar_spaces.row_gap = 0;
- let area = Rect::new(0, 0, 30, 10);
- app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
- assert_eq!(app.view.workspace_card_areas.len(), 2);
- let list_area = workspace_list_rect(area, app.sidebar_section_split);
-
- 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();
-
- let child = app.view.workspace_card_areas[1];
- assert_eq!(
- terminal.backend().buffer()[(child.rect.x + 3, child.rect.y)].symbol(),
- "├"
- );
- }
-
- #[test]
- fn parent_workspace_row_stays_clickable_when_grouped() {
- 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"),
- ];
- app.sidebar_spaces.row_gap = 1;
-
- let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20));
-
- assert!(headers.is_empty());
- assert_eq!(cards[0].ws_idx, 0);
- assert!(!cards[0].indented);
- assert_eq!(cards[1].ws_idx, 1);
- assert!(cards[1].indented);
- assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height);
- }
-
- #[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
- );
- 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, 3);
- 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,
- &app.view.workspace_card_areas,
- list_area,
- crate::app::state::WorkspaceDropTarget::Before(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_id: 0,
- source_ws_idx: 0,
- drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(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![
- workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
- workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"),
- ];
-
- let entries = workspace_list_entries(&app);
-
- assert_eq!(
- entries,
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: false
- },
- ]
- );
- }
-
- #[test]
- fn compact_space_group_scroll_clamps_when_all_entries_fit() {
- let mut app = AppState::test_new();
- app.workspaces = vec![
- workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
- workspace_with_worktree_space("one", Some("repo-key"), "/repo/herdr-one"),
- workspace_with_worktree_space("two", Some("repo-key"), "/repo/herdr-two"),
- ];
- let area = Rect::new(0, 0, 30, 20);
- app.workspace_scroll = normalized_workspace_scroll(&app, area, 2);
-
- let (cards, headers) = compute_workspace_list_areas(&app, area);
-
- assert!(headers.is_empty());
- assert_eq!(app.workspace_scroll, 0);
- assert_eq!(cards.len(), 3);
- assert_eq!(cards[2].ws_idx, 2);
- }
-
- #[test]
- fn workspace_scroll_metrics_count_display_entries_not_raw_workspaces() {
- 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::test_new("notes"),
- ];
- for workspace in &mut app.workspaces {
- workspace.cached_git_branch = Some("main".into());
- }
- app.collapsed_space_keys.insert("repo-key".into());
- app.active = None;
- app.mode = Mode::Terminal;
-
- let ws_area = Rect::new(0, 0, 30, 6);
- let metrics = workspace_list_scroll_metrics(&app, ws_area);
-
- assert_eq!(metrics.viewport_rows, 1);
- assert_eq!(metrics.max_offset_from_bottom, 1);
- assert_eq!(metrics.offset_from_bottom, 1);
- }
-
- #[test]
- fn workspace_scroll_offset_applies_to_group_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::test_new("notes"),
- ];
- app.collapsed_space_keys.insert("repo-key".into());
- app.active = None;
- app.mode = Mode::Terminal;
- app.workspace_scroll = 1;
-
- let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 12));
-
- assert!(headers.is_empty());
- assert_eq!(cards.len(), 1);
- assert_eq!(cards[0].ws_idx, 2);
- }
-
- #[test]
- fn workspace_list_entries_group_multiple_workspaces_in_same_git_space() {
- 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"),
- ];
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: true,
- },
- ]
- );
- }
-
- #[test]
- fn workspace_list_entries_group_non_contiguous_explicit_members() {
- let mut app = AppState::test_new();
- app.workspaces = vec![
- workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
- workspace_with_git_space("normal", "other-key"),
- workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
- ];
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 2,
- indented: true,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: false,
- },
- ]
- );
- }
-
- #[test]
- fn workspace_list_entries_do_not_group_normal_git_workspaces() {
- let mut app = AppState::test_new();
- app.workspaces = vec![
- workspace_with_git_space("one", "repo-key"),
- workspace_with_git_space("two", "repo-key"),
- ];
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: false,
- },
- ]
- );
- }
-
- #[test]
- fn workspace_list_entries_do_not_auto_attach_normal_git_workspace_to_group() {
- let mut app = AppState::test_new();
- app.workspaces = vec![
- workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
- workspace_with_git_space("scratch", "repo-key"),
- workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
- ];
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 2,
- indented: true,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: false,
- },
- ]
- );
- }
-
- #[test]
- fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() {
- let mut app = AppState::test_new();
- app.workspaces = vec![
- workspace_with_git_space("one", "repo-key"),
- workspace_with_worktree_space("notes", None, "/notes"),
- ];
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: false,
- },
- ]
- );
- }
-
- #[test]
- fn collapsed_group_hides_inactive_children_but_keeps_active_visible() {
- 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"),
- ];
- app.active = Some(1);
- app.mode = Mode::Terminal;
- app.collapsed_space_keys.insert("repo-key".into());
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: true,
- },
- ]
- );
-
- app.active = None;
- app.mode = Mode::Terminal;
- assert_eq!(
- workspace_list_entries(&app),
- vec![WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- }]
- );
- }
-
- #[test]
- fn collapsed_group_keeps_selected_child_visible_in_navigate_mode() {
- 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"),
- ];
- app.mode = Mode::Navigate;
- app.selected = 1;
- app.active = Some(1);
- app.collapsed_space_keys.insert("repo-key".into());
-
- assert_eq!(
- workspace_list_entries(&app),
- vec![
- WorkspaceListEntry::Workspace {
- ws_idx: 0,
- indented: false,
- },
- WorkspaceListEntry::Workspace {
- ws_idx: 1,
- indented: true,
- },
- ]
- );
- }
-}