diff options
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/mobile.rs | 10 | ||||
| -rw-r--r-- | src/ui/navigator.rs | 3 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 888 | ||||
| -rw-r--r-- | src/ui/sidebar/tokens.rs | 285 |
4 files changed, 936 insertions, 250 deletions
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index f1aae316..d1e6ac61 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -731,10 +731,6 @@ fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { if let Some(agent_label) = entry.agent_label.as_deref() { parts.push(agent_label.to_string()); } - if let Some(custom_status) = entry.custom_status.as_deref() { - parts.push(custom_status.to_string()); - } - format!(" {}", parts.join(" · ")) } @@ -1140,12 +1136,16 @@ mod tests { pane_id: PaneId::from_raw(1), primary_label: "herdr".into(), primary_tab_label: primary_tab_label.map(str::to_string), + pane_label: None, + terminal_title: None, + terminal_title_stripped: None, agent_label: agent_label.map(str::to_string), + agent: agent_label.and_then(crate::detect::parse_agent_label), state: AgentState::Idle, seen: true, last_agent_state_change_seq: None, - custom_status: None, state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), } } diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs index 8bfe6531..0ad336f6 100644 --- a/src/ui/navigator.rs +++ b/src/ui/navigator.rs @@ -427,9 +427,6 @@ fn pane_detail( } else { parts.push("shell".to_string()); } - if let Some(status) = terminal.effective_custom_status() { - parts.push(status.to_string()); - } } } parts.join(" · ") diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index ca23af0c..18d13b26 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,3 +1,5 @@ +mod tokens; + use ratatui::{ layout::{Alignment, Rect}, style::{Modifier, Style}, @@ -6,6 +8,7 @@ use ratatui::{ Frame, }; +use self::tokens::{ResolvedToken, SpaceTokenContext}; use super::scrollbar::{render_scrollbar, should_show_scrollbar}; use super::status::{agent_icon, state_dot, state_label, state_label_color}; use super::text::{display_width, display_width_u16, truncate_end}; @@ -23,12 +26,16 @@ pub(crate) struct AgentPanelEntry { 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: Option<crate::detect::Agent>, pub state: AgentState, pub seen: bool, pub last_agent_state_change_seq: Option<u64>, - pub custom_status: Option<String>, 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) { @@ -131,12 +138,16 @@ fn agent_panel_entries_with_runtimes( pane_id: detail.pane_id, primary_label: workspace_label.clone(), primary_tab_label: multi_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: detail.agent, state: detail.state, seen: detail.seen, last_agent_state_change_seq: detail.last_agent_state_change_seq, - custom_status: detail.custom_status, state_labels: detail.state_labels, + tokens: detail.tokens, }) }) .collect(); @@ -163,56 +174,48 @@ pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static } } -fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String { - let Some(tab_label) = entry.primary_tab_label.as_deref() else { - return truncate_end(&entry.primary_label, max_width); +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(), + ws.branch().as_deref(), + ws.custom_name.is_some(), + ) + } else { + ws.display_name() }; - - let separator = " · "; - let separator_width = display_width(separator); - if max_width <= separator_width + 2 { - return truncate_end( - &format!("{}{}{}", entry.primary_label, separator, tab_label), - max_width, - ); - } - - let available = max_width.saturating_sub(separator_width); - let min_tab = 4.min(available.saturating_sub(1)).max(1); - let preferred_workspace = ((available * 2) / 3).max(1); - let mut workspace_budget = preferred_workspace - .min(available.saturating_sub(min_tab)) - .max(1); - let mut tab_budget = available.saturating_sub(workspace_budget); - - let workspace_len = display_width(&entry.primary_label); - let tab_len = display_width(tab_label); - - if workspace_len < workspace_budget { - let spare = workspace_budget - workspace_len; - workspace_budget = workspace_len; - tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget)); - } - if tab_len < tab_budget { - let spare = tab_budget - tab_len; - tab_budget = tab_len; - workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget)); - } - - format!( - "{}{}{}", - truncate_end(&entry.primary_label, workspace_budget), - separator, - truncate_end(tab_label, tab_budget) + 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(ws: &crate::workspace::Workspace) -> u16 { - if ws.branch().is_some() { - 2 - } else { - 1 - } +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(entries: &[WorkspaceListEntry], entry_idx: usize, indented: bool) -> u16 { + u16::from( + entry_idx + 1 < entries.len() + && !(indented && next_entry_is_indented_workspace(entries, entry_idx)), + ) } fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { @@ -294,11 +297,10 @@ pub(crate) fn normalized_workspace_scroll(app: &AppState, area: Rect, requested: return requested; } - let entry_count = workspace_list_entries(app).len(); - if entry_count == 0 { + if workspace_list_entries(app).is_empty() { 0 } else { - requested.min(entry_count.saturating_sub(1)) + requested.min(workspace_list_bottom_start(app, ws_area)) } } @@ -440,47 +442,62 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us let mut visible = 0usize; let entries = workspace_list_entries(app); for (entry_idx, entry) in entries.iter().enumerate().skip(scroll) { - let needed = match entry { + let (row_height, gap) = match entry { WorkspaceListEntry::Workspace { ws_idx, indented } => { let Some(ws) = app.workspaces.get(*ws_idx) else { continue; }; - let row_height = if *indented { - 1 - } else { - workspace_row_height(ws) - }; - let gap = u16::from( - !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)), - ); - row_height.saturating_add(gap) + ( + workspace_row_height_in_body(app, ws, *indented, body.height), + workspace_entry_gap(&entries, entry_idx, *indented), + ) } }; - if used_rows.saturating_add(needed) > body.height { + if used_rows.saturating_add(row_height) > body.height { break; } - used_rows = used_rows.saturating_add(needed); + used_rows = used_rows.saturating_add(row_height); visible += 1; + if gap > 0 && used_rows < body.height { + used_rows = used_rows.saturating_add(1); + } } 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(&entries, entry_idx, *indented); + let needed = workspace_row_height_in_body(app, workspace, *indented, body.height) + .saturating_add(gap); + if used_rows.saturating_add(needed) > body.height { + 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 entries = workspace_list_entries(app); - let total_rows = entries.len(); - let scroll = app.workspace_scroll.min(total_rows.saturating_sub(1)); + 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); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - let offset_from_bottom = total_rows - .saturating_sub(scroll) - .saturating_sub(viewport_rows); crate::pane::ScrollMetrics { - offset_from_bottom, - max_offset_from_bottom, + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, viewport_rows, } } @@ -507,16 +524,41 @@ pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect { Rect::new(area.x, body_y, body_width, body_height) } -fn agent_panel_visible_count(area: Rect) -> usize { +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) +} + +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 < 2 { + if body.width == 0 || body.height == 0 { return 0; } let mut used_rows = 0u16; let mut visible = 0usize; - while used_rows.saturating_add(2) <= body.height { - used_rows = used_rows.saturating_add(2); + for entry in agent_panel_entries(app).iter().skip(scroll) { + let height = agent_entry_height_in_body(app, entry, body.height); + if used_rows.saturating_add(height) > body.height { + break; + } + used_rows = used_rows.saturating_add(height); visible += 1; if used_rows < body.height { used_rows = used_rows.saturating_add(1); @@ -525,17 +567,52 @@ fn agent_panel_visible_count(area: Rect) -> usize { 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 = u16::from(index + 1 < 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 viewport_rows = agent_panel_visible_count(area); - let total_rows = agent_panel_entries(app).len(); - let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); - let offset_from_bottom = total_rows - .saturating_sub(app.agent_panel_scroll) - .saturating_sub(viewport_rows); + 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_offset_from_bottom, + offset_from_bottom: max_scroll.saturating_sub(scroll), + max_offset_from_bottom: max_scroll, viewport_rows, } } @@ -579,15 +656,9 @@ pub(crate) fn compute_workspace_list_areas( let Some(ws) = app.workspaces.get(*ws_idx) else { continue; }; - let row_height = if *indented { - 1 - } else { - workspace_row_height(ws) - }; - let gap = u16::from( - !(*indented && next_entry_is_indented_workspace(&entries, entry_idx)), - ); - if row_y.saturating_add(row_height).saturating_add(gap) > body_bottom { + let row_height = workspace_row_height_in_body(app, ws, *indented, body.height); + let gap = workspace_entry_gap(&entries, entry_idx, *indented); + if row_y.saturating_add(row_height) > body_bottom { break; } cards.push(crate::app::state::WorkspaceCardArea { @@ -595,7 +666,10 @@ pub(crate) fn compute_workspace_list_areas( rect: Rect::new(body.x, row_y, body.width, row_height), indented: *indented, }); - row_y = row_y.saturating_add(row_height + gap); + row_y = row_y.saturating_add(row_height); + if gap > 0 && row_y < body_bottom { + row_y = row_y.saturating_add(1); + } } } } @@ -797,6 +871,179 @@ pub(super) fn render_sidebar( render_sidebar_toggle(app, frame, area, false, p); } +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, + max_width: usize, +) -> Vec<Span<'static>> { + let fixed_widths = resolved + .iter() + .map(|token| match token { + ResolvedToken::StateIcon => display_width(state_icon.0), + ResolvedToken::GitStatus { ahead, behind } => { + usize::from(*ahead > 0) * display_width(&format!("↑{ahead}")) + + usize::from(*behind > 0) * display_width(&format!("↓{behind}")) + + usize::from(*ahead > 0 && *behind > 0) + } + _ => 0, + }) + .collect::<Vec<_>>(); + let flexible_widths = resolved + .iter() + .map(|token| match token { + ResolvedToken::StateText(text) + | ResolvedToken::Workspace(text) + | ResolvedToken::Tab(text) + | ResolvedToken::Pane(text) + | ResolvedToken::Agent(text) + | ResolvedToken::TerminalTitle(text) + | ResolvedToken::Branch(text) + | ResolvedToken::Custom(text) => display_width(text), + _ => 0, + }) + .collect::<Vec<_>>(); + let minimum_width = |active: &[bool]| { + let indices = active + .iter() + .enumerate() + .filter_map(|(index, active)| active.then_some(index)) + .collect::<Vec<_>>(); + let content = indices + .iter() + .map(|index| fixed_widths[*index] + usize::from(flexible_widths[*index] > 0)) + .sum::<usize>(); + let separators = indices + .windows(2) + .map(|pair| display_width(tokens::separator(&resolved[pair[0]], &resolved[pair[1]]))) + .sum::<usize>(); + content + separators + }; + let mut active = resolved.iter().map(|_| true).collect::<Vec<_>>(); + if minimum_width(&active) > max_width { + for (index, width) in flexible_widths.iter().enumerate() { + if *width > 0 { + active[index] = false; + } + } + for index in (0..resolved.len()).rev() { + if flexible_widths[index] == 0 { + continue; + } + active[index] = true; + if minimum_width(&active) > max_width { + active[index] = false; + } + } + } + let visible_indices = active + .iter() + .enumerate() + .filter_map(|(index, active)| active.then_some(index)) + .collect::<Vec<_>>(); + let separator_width = visible_indices + .windows(2) + .map(|pair| display_width(tokens::separator(&resolved[pair[0]], &resolved[pair[1]]))) + .sum::<usize>(); + let fixed_width = visible_indices + .iter() + .map(|index| fixed_widths[*index]) + .sum::<usize>(); + let mut budgets = flexible_widths + .iter() + .enumerate() + .map(|(index, width)| usize::from(active[index] && *width > 0)) + .collect::<Vec<_>>(); + let minimum = budgets.iter().sum::<usize>(); + let mut remaining = max_width + .saturating_sub(separator_width + fixed_width) + .saturating_sub(minimum); + while remaining > 0 { + let mut grew = false; + for (budget, width) in budgets.iter_mut().zip(&flexible_widths) { + if *budget > 0 && *budget < *width { + *budget += 1; + remaining -= 1; + grew = true; + if remaining == 0 { + break; + } + } + } + if !grew { + break; + } + } + let mut spans = Vec::new(); + for (position, index) in visible_indices.iter().copied().enumerate() { + let token = &resolved[index]; + if position > 0 { + let previous = &resolved[visible_indices[position - 1]]; + spans.push(Span::styled( + tokens::separator(previous, token), + Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), + )); + } + match token { + ResolvedToken::StateIcon => { + spans.push(Span::styled(state_icon.0.to_string(), state_icon.1)); + } + ResolvedToken::StateText(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + state_text_style, + )); + } + ResolvedToken::Workspace(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + workspace_style, + )); + } + ResolvedToken::Tab(text) | ResolvedToken::Pane(text) | ResolvedToken::Agent(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + secondary_style, + )); + } + ResolvedToken::Branch(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + secondary_style, + )); + } + ResolvedToken::GitStatus { ahead, behind } => { + if *ahead > 0 { + spans.push(Span::styled( + format!("↑{ahead}"), + Style::default().fg(p.green), + )); + } + if *ahead > 0 && *behind > 0 { + spans.push(Span::raw(" ")); + } + if *behind > 0 { + spans.push(Span::styled( + format!("↓{behind}"), + Style::default().fg(p.red), + )); + } + } + ResolvedToken::TerminalTitle(text) | ResolvedToken::Custom(text) => { + spans.push(Span::styled( + truncate_end(text, budgets[index]), + custom_style, + )); + } + } + } + spans +} + fn render_workspace_list( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -870,94 +1117,89 @@ fn render_workspace_list( Style::default().fg(p.subtext0) }; - let (icon, icon_style) = state_dot(agg_state, agg_seen, p); let label = ws.display_name_from(&app.terminals, terminal_runtimes); - let mut line1 = Vec::new(); - let mut show_workspace_icon = true; - if card.indented { - line1.push(Span::styled(" ", Style::default())); - } else if let Some((key, collapsed)) = workspace_parent_group_state(app, i) { - let icon = if collapsed { "▸" } else { "▾" }; - let (state_icon, state_style) = if collapsed { - let (state, seen) = space_aggregate_state(app, &key); - state_dot(state, seen, p) - } else { - (icon, Style::default().fg(p.accent)) - }; - line1.push(Span::styled(icon, Style::default().fg(p.accent))); - if collapsed { - line1.push(Span::styled(" ", Style::default())); - line1.push(Span::styled(state_icon, state_style)); - show_workspace_icon = false; - } - line1.push(Span::styled(" ", Style::default())); + let display_label = if card.indented { + grouped_child_display_label(&label, ws.branch().as_deref(), ws.custom_name.is_some()) } else { - line1.push(Span::styled(" ", Style::default())); - } - if show_workspace_icon { - line1.push(Span::styled(icon, icon_style)); - line1.push(Span::styled(" ", Style::default())); - } - if card.indented { - let display_label = grouped_child_display_label( - &label, - ws.branch().as_deref(), - ws.custom_name.is_some(), - ); - line1.push(Span::styled(display_label, name_style)); + label + }; + let parent_group = (!card.indented) + .then(|| workspace_parent_group_state(app, i)) + .flatten(); + 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_dot(display_state, display_seen, 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 { - line1.push(Span::styled(label, name_style)); - } - - frame.render_widget( - Paragraph::new(Line::from(line1)), - Rect::new(card.rect.x, row_y, card.rect.width, 1), + 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, + }, ); - if row_height > 1 && row_y + 1 < list_bottom { - if let Some(branch) = ws.branch() { - let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| { - let mut parts = Vec::new(); - if ahead > 0 { - parts.push((format!("↑{}", ahead), p.green)); - } - if behind > 0 { - parts.push((format!("↓{}", behind), p.red)); - } - (!parts.is_empty()).then_some(parts) - }); - let reserved = upstream_label - .as_ref() - .map(|parts| { - parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len() - }) - .unwrap_or(0); - let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved); - let branch_display = truncate_end(&branch, max_branch_len); - let branch_color = if selected || is_active { - p.mauve + 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(); + if row_index == 0 { + if card.indented { + spans.push(Span::raw(" ")); + } else if let Some((_, collapsed)) = parent_group.as_ref() { + spans.push(Span::styled( + if *collapsed { "▸" } else { "▾" }, + Style::default().fg(p.accent), + )); + spans.push(Span::raw(" ")); } else { - p.overlay0 - }; - let branch_indent = if card.indented { " " } else { " " }; - let mut spans = vec![ - Span::styled(branch_indent, Style::default()), - Span::styled(branch_display, Style::default().fg(branch_color)), - ]; - if let Some(parts) = upstream_label { - spans.push(Span::styled(" ", Style::default())); - for (idx, (label, color)) in parts.into_iter().enumerate() { - if idx > 0 { - spans.push(Span::styled(" ", Style::default())); - } - spans.push(Span::styled(label, Style::default().fg(color))); - } + spans.push(Span::raw(" ")); } - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(card.rect.x, row_y + 1, card.rect.width, 1), - ); + } else { + spans.push(Span::raw(if card.indented { " " } else { " " })); } + let prefix_width = if row_index == 0 { + if card.indented { + 3 + } else if parent_group.is_some() { + 2 + } else { + 1 + } + } else if card.indented { + 5 + } else { + 3 + }; + 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) 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), + ); } } @@ -1050,27 +1292,19 @@ fn render_agent_detail( let mut row_y = body.y; let body_bottom = body.y + body.height; for detail in details.iter().skip(app.agent_panel_scroll) { - if row_y.saturating_add(1) >= body_bottom { + 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; } - // Check if this agent entry corresponds to the active session let is_active = app.is_active_pane(detail.ws_idx, detail.tab_idx, detail.pane_id); - - let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p); - let label_color = state_label_color(detail.state, detail.seen, p); - let label = detail - .state_labels - .get(agent_panel_status_key(detail.state, detail.seen)) - .map(String::as_str) - .unwrap_or_else(|| state_label(detail.state, detail.seen)); - let row_style = if is_active { Style::default().bg(p.surface_dim) } else { Style::default() }; - let name_style = if is_active { Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else { @@ -1082,39 +1316,27 @@ fn render_agent_detail( Style::default().fg(label_color).add_modifier(Modifier::DIM) }; let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - - let primary_label = - format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize); - let name_line = Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(icon, icon_style), - Span::styled(" ", Style::default()), - Span::styled(primary_label, name_style), - ]); - frame.render_widget( - Paragraph::new(name_line).style(row_style), - Rect::new(body.x, row_y, body.width, 1), - ); - row_y += 1; - - let mut status_spans = vec![ - Span::styled(" ", Style::default()), - Span::styled(label, status_style), - ]; - if let Some(agent_label) = &detail.agent_label { - status_spans.push(Span::styled(" · ", agent_style)); - status_spans.push(Span::styled(agent_label, agent_style)); - } - if let Some(custom_status) = &detail.custom_status { - status_spans.push(Span::styled(" · ", agent_style)); - status_spans.push(Span::styled(custom_status.clone(), agent_style)); + let state_icon = agent_icon(detail.state, detail.seen, app.spinner_tick, 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), + ); } - frame.render_widget( - Paragraph::new(Line::from(status_spans)).style(row_style), - Rect::new(body.x, row_y, body.width, 1), - ); - row_y += 1; - + row_y = row_y.saturating_add(height); if row_y < body_bottom { row_y += 1; } @@ -1177,6 +1399,208 @@ mod tests { use crate::{detect::Agent, workspace::Workspace}; use ratatui::{backend::TestBackend, 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() + } + + #[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(); + 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")); + } + + #[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::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(); @@ -1488,27 +1912,6 @@ mod tests { } #[test] - fn all_workspaces_primary_label_truncates_workspace_and_tab() { - let entry = AgentPanelEntry { - ws_idx: 0, - tab_idx: 0, - pane_id: crate::layout::PaneId::from_raw(1), - primary_label: "agent-browser".into(), - primary_tab_label: Some("test-escalation".into()), - agent_label: Some("claude".into()), - state: AgentState::Idle, - seen: true, - last_agent_state_change_seq: None, - custom_status: None, - state_labels: std::collections::HashMap::new(), - }; - - let label = format_agent_panel_primary_label(&entry, 18); - - assert_eq!(label, "agent-bro… · test…"); - } - - #[test] fn expanded_sidebar_sections_handle_tiny_heights() { let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9); @@ -1638,7 +2041,7 @@ mod tests { } #[test] - fn compact_space_group_scroll_offset_can_start_inside_group() { + 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"), @@ -1651,8 +2054,9 @@ mod tests { let (cards, headers) = compute_workspace_list_areas(&app, area); assert!(headers.is_empty()); - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].ws_idx, 2); + assert_eq!(app.workspace_scroll, 0); + assert_eq!(cards.len(), 3); + assert_eq!(cards[2].ws_idx, 2); } #[test] diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs new file mode 100644 index 00000000..43c24d05 --- /dev/null +++ b/src/ui/sidebar/tokens.rs @@ -0,0 +1,285 @@ +use crate::config::{ + AgentSidebarToken, AgentsSidebarConfig, SpaceSidebarToken, SpacesSidebarConfig, +}; + +use super::AgentPanelEntry; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ResolvedToken { + StateIcon, + StateText(String), + Workspace(String), + Tab(String), + Pane(String), + Agent(String), + TerminalTitle(String), + Branch(String), + GitStatus { ahead: usize, behind: usize }, + Custom(String), +} + +pub(super) fn agent_rows( + config: &AgentsSidebarConfig, + entry: &AgentPanelEntry, + state_text: &str, +) -> Vec<Vec<ResolvedToken>> { + config + .rows_for_agent(entry.agent) + .iter() + .filter_map(|row| { + let resolved = row + .iter() + .filter_map(|token| match token { + AgentSidebarToken::StateIcon => Some(ResolvedToken::StateIcon), + AgentSidebarToken::StateText => { + Some(ResolvedToken::StateText(state_text.to_string())) + } + AgentSidebarToken::Workspace => { + Some(ResolvedToken::Workspace(entry.primary_label.clone())) + } + AgentSidebarToken::Tab => { + entry.primary_tab_label.clone().map(ResolvedToken::Tab) + } + AgentSidebarToken::Pane => entry.pane_label.clone().map(ResolvedToken::Pane), + AgentSidebarToken::Agent => entry.agent_label.clone().map(ResolvedToken::Agent), + AgentSidebarToken::TerminalTitle => entry + .terminal_title + .clone() + .map(ResolvedToken::TerminalTitle), + AgentSidebarToken::TerminalTitleStripped => entry + .terminal_title_stripped + .clone() + .map(ResolvedToken::TerminalTitle), + AgentSidebarToken::Custom(name) => { + entry.tokens.get(name).cloned().map(ResolvedToken::Custom) + } + }) + .collect::<Vec<_>>(); + (!resolved.is_empty()).then_some(resolved) + }) + .collect() +} + +pub(super) struct SpaceTokenContext<'a> { + pub workspace: &'a str, + pub branch: Option<&'a str>, + pub state_text: &'a str, + pub ahead_behind: Option<(usize, usize)>, + pub tokens: &'a std::collections::HashMap<String, String>, + pub suppress_git_details: bool, +} + +pub(super) fn space_rows( + config: &SpacesSidebarConfig, + context: SpaceTokenContext<'_>, +) -> Vec<Vec<ResolvedToken>> { + config + .rows + .iter() + .filter_map(|row| { + let resolved = row + .iter() + .filter_map(|token| match token { + SpaceSidebarToken::StateIcon => Some(ResolvedToken::StateIcon), + SpaceSidebarToken::StateText => { + Some(ResolvedToken::StateText(context.state_text.to_string())) + } + SpaceSidebarToken::Workspace => { + Some(ResolvedToken::Workspace(context.workspace.to_string())) + } + SpaceSidebarToken::Branch if !context.suppress_git_details => context + .branch + .map(|branch| ResolvedToken::Branch(branch.to_string())), + SpaceSidebarToken::Branch => None, + SpaceSidebarToken::GitStatus if !context.suppress_git_details => context + .ahead_behind + .filter(|(ahead, behind)| *ahead > 0 || *behind > 0) + .map(|(ahead, behind)| ResolvedToken::GitStatus { ahead, behind }), + SpaceSidebarToken::GitStatus => None, + SpaceSidebarToken::Custom(name) => { + context.tokens.get(name).cloned().map(ResolvedToken::Custom) + } + }) + .collect::<Vec<_>>(); + (!resolved.is_empty()).then_some(resolved) + }) + .collect() +} + +pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { + if matches!(previous, ResolvedToken::StateIcon) + || matches!(current, ResolvedToken::GitStatus { .. }) + { + " " + } else { + " · " + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::AgentState; + + fn entry() -> AgentPanelEntry { + AgentPanelEntry { + ws_idx: 0, + tab_idx: 0, + pane_id: crate::layout::PaneId::from_raw(1), + primary_label: "repo".into(), + primary_tab_label: None, + pane_label: None, + terminal_title: None, + terminal_title_stripped: None, + agent_label: Some("pi".into()), + agent: Some(crate::detect::Agent::Pi), + state: AgentState::Working, + seen: true, + last_agent_state_change_seq: None, + state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), + } + } + + #[test] + fn missing_custom_tokens_elide_rows_and_separators() { + let entry = entry(); + let config = AgentsSidebarConfig { + rows: vec![ + vec![ + AgentSidebarToken::StateIcon, + AgentSidebarToken::Custom("missing".into()), + ], + vec![AgentSidebarToken::Custom("missing".into())], + vec![AgentSidebarToken::Agent], + ], + rows_by_agent: Default::default(), + }; + + let rows = agent_rows(&config, &entry, "working"); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0], vec![ResolvedToken::StateIcon]); + assert_eq!(rows[1], vec![ResolvedToken::Agent("pi".into())]); + } + + #[test] + fn state_text_and_arbitrary_values_are_independent_tokens() { + let mut entry = entry(); + entry + .tokens + .insert("summary".into(), "reviewing auth".into()); + let config = AgentsSidebarConfig { + rows: vec![vec![ + AgentSidebarToken::StateText, + AgentSidebarToken::Custom("summary".into()), + ]], + rows_by_agent: Default::default(), + }; + + assert_eq!( + agent_rows(&config, &entry, "deep in the mines"), + vec![vec![ + ResolvedToken::StateText("deep in the mines".into()), + ResolvedToken::Custom("reviewing auth".into()), + ]] + ); + } + + #[test] + fn terminal_title_builtins_are_distinct_from_custom_tokens() { + let mut entry = entry(); + entry.terminal_title = Some("⠋ raw title".into()); + entry.terminal_title_stripped = Some("raw title".into()); + entry + .tokens + .insert("terminal_title".into(), "custom title".into()); + let config = AgentsSidebarConfig { + rows: vec![vec![ + AgentSidebarToken::TerminalTitle, + AgentSidebarToken::TerminalTitleStripped, + AgentSidebarToken::Custom("terminal_title".into()), + ]], + rows_by_agent: Default::default(), + }; + + assert_eq!( + agent_rows(&config, &entry, "working"), + vec![vec![ + ResolvedToken::TerminalTitle("⠋ raw title".into()), + ResolvedToken::TerminalTitle("raw title".into()), + ResolvedToken::Custom("custom title".into()), + ]] + ); + } + + #[test] + fn known_agent_override_replaces_default_rows() { + let mut config = AgentsSidebarConfig { + rows: vec![vec![AgentSidebarToken::Workspace]], + rows_by_agent: Default::default(), + }; + config + .rows_by_agent + .insert("pi".into(), vec![vec![AgentSidebarToken::Agent]]); + let mut pi = entry(); + pi.agent_label = Some("renamed pi".into()); + + assert_eq!( + agent_rows(&config, &pi, "working"), + vec![vec![ResolvedToken::Agent("renamed pi".into())]] + ); + + pi.agent = None; + assert_eq!( + agent_rows(&config, &pi, "working"), + vec![vec![ResolvedToken::Workspace("repo".into())]] + ); + } + + #[test] + fn grouped_children_suppress_all_builtin_git_details() { + let config = SpacesSidebarConfig::default(); + + assert_eq!( + space_rows( + &config, + SpaceTokenContext { + workspace: "feature", + branch: Some("worktree/feature"), + state_text: "idle", + ahead_behind: Some((2, 1)), + tokens: &std::collections::HashMap::new(), + suppress_git_details: true, + }, + ), + vec![vec![ + ResolvedToken::StateIcon, + ResolvedToken::Workspace("feature".into()), + ]] + ); + } + + #[test] + fn workspace_custom_token_can_replace_git_specific_details() { + let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]); + let config = SpacesSidebarConfig { + rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]], + }; + + assert_eq!( + space_rows( + &config, + SpaceTokenContext { + workspace: "repo", + branch: None, + state_text: "idle", + ahead_behind: None, + tokens: &tokens, + suppress_git_details: false, + }, + ), + vec![vec![ResolvedToken::Custom("2 changes".into())]] + ); + } +} |
