aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/dialogs.rs1158
-rw-r--r--src/ui/keybind_help.rs426
-rw-r--r--src/ui/menus.rs315
-rw-r--r--src/ui/mobile.rs1626
-rw-r--r--src/ui/navigator.rs643
-rw-r--r--src/ui/onboarding.rs127
-rw-r--r--src/ui/panes.rs306
-rw-r--r--src/ui/release_notes.rs289
-rw-r--r--src/ui/scrollbar.rs50
-rw-r--r--src/ui/settings.rs424
-rw-r--r--src/ui/sidebar.rs3042
-rw-r--r--src/ui/sidebar/tokens.rs131
-rw-r--r--src/ui/status.rs294
-rw-r--r--src/ui/tab_surface.rs108
-rw-r--r--src/ui/tabs.rs788
-rw-r--r--src/ui/text.rs42
-rw-r--r--src/ui/widgets.rs247
17 files changed, 374 insertions, 9642 deletions
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs
deleted file mode 100644
index ff6ced3d..00000000
--- a/src/ui/dialogs.rs
+++ /dev/null
@@ -1,1158 +0,0 @@
-use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Clear, Paragraph, Wrap},
- Frame,
-};
-
-use super::text::{display_width_u16, truncate_end};
-use super::widgets::{
- action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button,
- render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec,
-};
-use crate::app::{state::WorktreeOpenState, AppState, Mode};
-use crate::terminal::TerminalRuntimeRegistry;
-
-const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68;
-const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12;
-
-pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "save",
- },
- ActionButtonSpec {
- hint: Some("^c"),
- label: "clear",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- 3,
- );
- (rects[0], rects[1], rects[2])
-}
-
-/// Draws the shared `name_input` field and puts the host cursor on its caret.
-///
-/// IMEs draw their composition preview at the host terminal cursor. Without an
-/// explicit cursor the frame carries none, the client keeps the position last
-/// reported by the focused pane, and composition lands behind the dialog.
-fn render_name_input_field(app: &AppState, frame: &mut Frame, input_rect: Rect) {
- frame.render_widget(Clear, input_rect);
-
- // The text stops one column short of the field so the clamped caret always
- // lands on a blank cell: a host terminal inverts the cell under its cursor,
- // and an IME composes there.
- let text_rect = Rect {
- width: input_rect.width.saturating_sub(1),
- ..input_rect
- };
- frame.render_widget(
- Paragraph::new(format!(" {}", app.name_input)).style(
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0),
- ),
- text_rect,
- );
-
- if input_rect.width == 0 {
- return;
- }
- let caret_x = input_rect
- .x
- .saturating_add(1)
- .saturating_add(display_width_u16(&app.name_input))
- .min(input_rect.right().saturating_sub(1));
- frame.set_cursor_position((caret_x, input_rect.y));
-}
-
-pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- super::dim_background(frame, area);
-
- let title = match app.mode {
- Mode::RenameWorkspace if app.pending_workspace_create_cwd.is_some() => "new workspace",
- Mode::RenameWorkspace => "rename workspace",
- Mode::RenameTab if app.creating_new_tab => "new tab",
- Mode::RenameTab => "rename tab",
- Mode::RenamePane => "rename pane",
- _ => return,
- };
-
- let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else {
- return;
- };
- if inner.height < 4 {
- return;
- }
-
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<5>(inner);
-
- render_modal_header(frame, rows[0], title, &app.palette);
-
- let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
- render_name_input_field(app, frame, input_rect);
-
- let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
-
- render_action_button(
- frame,
- save_rect,
- Some("↵"),
- "save",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- clear_rect,
- Some("^c"),
- "clear",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option<Rect> {
- centered_popup_rect(
- area,
- NEW_LINKED_WORKTREE_POPUP_WIDTH,
- NEW_LINKED_WORKTREE_POPUP_HEIGHT,
- )
- .map(|popup| {
- Rect::new(
- popup.x + 1,
- popup.y + 1,
- popup.width.saturating_sub(2),
- popup.height.saturating_sub(2),
- )
- })
-}
-
-pub(crate) fn new_linked_worktree_button_rects(inner: Rect) -> (Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "create and open",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- inner.height.saturating_sub(1),
- );
- (rects[0], rects[1])
-}
-
-pub(crate) fn remove_worktree_popup_rect(area: Rect) -> Option<Rect> {
- centered_popup_rect(area, 72, 10)
-}
-
-pub(crate) fn remove_worktree_button_rects(inner: Rect, force_confirmation: bool) -> (Rect, Rect) {
- let primary_label = if force_confirmation {
- "delete anyway"
- } else {
- "remove"
- };
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: primary_label,
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- inner.height.saturating_sub(1),
- );
- (rects[0], rects[1])
-}
-
-pub(crate) fn open_existing_worktree_inner_rect(area: Rect, entry_count: usize) -> Option<Rect> {
- let height = (entry_count as u16)
- .saturating_mul(2)
- .saturating_add(7)
- .clamp(12, 26);
- centered_popup_rect(area, 96, height).map(|popup| {
- Rect::new(
- popup.x + 1,
- popup.y + 1,
- popup.width.saturating_sub(2),
- popup.height.saturating_sub(2),
- )
- })
-}
-
-pub(crate) fn open_existing_worktree_max_visible_rows(inner: Rect) -> usize {
- usize::from(inner.height.saturating_sub(5) / 2)
-}
-
-pub(crate) fn open_existing_worktree_visible_start(
- open: &WorktreeOpenState,
- max_rows: usize,
-) -> usize {
- let filtered = open.filtered_indices();
- let selected = open.selected_entry_index().unwrap_or(open.selected);
- let selected_pos = filtered
- .iter()
- .position(|idx| *idx == selected)
- .unwrap_or(0);
- selected_pos.saturating_sub(max_rows.saturating_sub(1))
-}
-
-pub(crate) fn open_existing_worktree_button_rects(inner: Rect) -> (Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "open",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- inner.height.saturating_sub(1),
- );
- (rects[0], rects[1])
-}
-
-pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(create) = app.worktree_create.as_ref() else {
- return;
- };
-
- super::dim_background(frame, area);
- let Some(inner) = render_modal_shell(
- frame,
- area,
- NEW_LINKED_WORKTREE_POPUP_WIDTH,
- NEW_LINKED_WORKTREE_POPUP_HEIGHT,
- &app.palette,
- ) else {
- return;
- };
- if inner.height < 9 {
- return;
- }
-
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(3),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<8>(inner);
-
- render_modal_header(frame, rows[0], "new worktree", &app.palette);
-
- frame.render_widget(
- Paragraph::new(" branch").style(Style::default().fg(app.palette.overlay0)),
- rows[1],
- );
- let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
- render_name_input_field(app, frame, input_rect);
-
- let checkout = create.checkout_path.display().to_string();
- frame.render_widget(
- Paragraph::new(" checkout").style(Style::default().fg(app.palette.overlay0)),
- rows[3],
- );
- frame.render_widget(
- Paragraph::new(format!(" {checkout}")).style(Style::default().fg(app.palette.subtext0)),
- rows[4],
- );
-
- if create.creating {
- frame.render_widget(
- Paragraph::new(" creating…").style(Style::default().fg(app.palette.overlay0)),
- rows[5],
- );
- } else if let Some(error) = &create.error {
- frame.render_widget(
- Paragraph::new(format!(" {error}"))
- .style(Style::default().fg(app.palette.red))
- .wrap(Wrap { trim: false }),
- rows[5],
- );
- }
-
- let (create_rect, cancel_rect) = new_linked_worktree_button_rects(inner);
- render_action_button(
- frame,
- create_rect,
- Some("↵"),
- "create and open",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-pub(super) fn render_remove_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(remove) = app.worktree_remove.as_ref() else {
- return;
- };
-
- super::dim_background(frame, area);
- let Some(popup) = remove_worktree_popup_rect(area) else {
- return;
- };
- let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
- else {
- return;
- };
-
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<8>(inner);
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " delete worktree checkout?",
- Style::default()
- .fg(app.palette.red)
- .add_modifier(Modifier::BOLD),
- )])),
- rows[0],
- );
- frame.render_widget(
- Paragraph::new(" This removes the checkout folder:")
- .style(Style::default().fg(app.palette.overlay0)),
- rows[1],
- );
- frame.render_widget(
- Paragraph::new(format!(" {}", remove.path.display()))
- .style(Style::default().fg(app.palette.text)),
- rows[2],
- );
- frame.render_widget(
- Paragraph::new(" The branch is not deleted. The Herdr workspace will close.")
- .style(Style::default().fg(app.palette.overlay0)),
- rows[3],
- );
- if remove.force_confirmation {
- frame.render_widget(
- Paragraph::new(" Dirty or untracked files will be permanently deleted.")
- .style(Style::default().fg(app.palette.red)),
- rows[4],
- );
- }
- if remove.removing {
- frame.render_widget(
- Paragraph::new(" removing…").style(Style::default().fg(app.palette.overlay0)),
- rows[5],
- );
- } else if let Some(error) = &remove.error {
- frame.render_widget(
- Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)),
- rows[5],
- );
- }
-
- let (remove_rect, cancel_rect) = remove_worktree_button_rects(inner, remove.force_confirmation);
- let remove_label = if remove.force_confirmation {
- "delete anyway"
- } else {
- "remove"
- };
- render_action_button(
- frame,
- remove_rect,
- Some("↵"),
- remove_label,
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.red)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(open) = app.worktree_open.as_ref() else {
- return;
- };
-
- super::dim_background(frame, area);
- let height = (open.entries.len() as u16)
- .saturating_mul(2)
- .saturating_add(7)
- .clamp(12, 26);
- let Some(inner) = render_modal_shell(frame, area, 96, height, &app.palette) else {
- return;
- };
- if inner.height < 8 {
- return;
- }
-
- render_modal_header(
- frame,
- Rect::new(inner.x, inner.y, inner.width, 1),
- "open worktree",
- &app.palette,
- );
- render_open_worktree_search(
- app,
- frame,
- Rect::new(inner.x, inner.y + 1, inner.width, 1),
- open,
- );
- frame.render_widget(
- Paragraph::new("─".repeat(inner.width as usize))
- .style(Style::default().fg(app.palette.surface1)),
- Rect::new(inner.x, inner.y.saturating_add(2), inner.width, 1),
- );
-
- let filtered = open.filtered_indices();
- let max_rows = open_existing_worktree_max_visible_rows(inner);
- let start = open_existing_worktree_visible_start(open, max_rows);
- for (visible_idx, entry_idx) in filtered.iter().skip(start).take(max_rows).enumerate() {
- let Some(entry) = open.entries.get(*entry_idx) else {
- continue;
- };
- let selected = Some(*entry_idx) == open.selected_entry_index();
- let y = inner.y.saturating_add(3 + (visible_idx as u16 * 2));
- let marker = if selected { "›" } else { " " };
- let row_style = if selected {
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(app.palette.subtext0)
- };
- let path_style = if selected {
- Style::default()
- .fg(app.palette.subtext0)
- .bg(app.palette.surface0)
- } else {
- Style::default().fg(app.palette.overlay0)
- };
- let status = entry.status_label();
- let title_width = inner
- .width
- .saturating_sub(display_width_u16(status))
- .saturating_sub(4) as usize;
- let mut title = format!(
- "{marker} {}",
- truncate_end(&entry.display_name(), title_width)
- );
- if !status.is_empty() {
- let pad = inner
- .width
- .saturating_sub(display_width_u16(&title))
- .saturating_sub(display_width_u16(status))
- .max(1);
- title.push_str(&" ".repeat(pad as usize));
- title.push_str(status);
- }
- frame.render_widget(
- Paragraph::new(truncate_end(&title, inner.width as usize)).style(row_style),
- Rect::new(inner.x, y, inner.width, 1),
- );
- frame.render_widget(
- Paragraph::new(truncate_end(
- &format!(" {}", entry.path.display()),
- inner.width as usize,
- ))
- .style(path_style),
- Rect::new(inner.x, y.saturating_add(1), inner.width, 1),
- );
- }
-
- if filtered.is_empty() {
- frame.render_widget(
- Paragraph::new(" no matching worktrees")
- .style(Style::default().fg(app.palette.overlay0)),
- Rect::new(inner.x, inner.y.saturating_add(3), inner.width, 1),
- );
- }
-
- if let Some(error) = &open.error {
- frame.render_widget(
- Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)),
- Rect::new(
- inner.x,
- inner.y + inner.height.saturating_sub(2),
- inner.width,
- 1,
- ),
- );
- }
-
- let (open_rect, cancel_rect) = open_existing_worktree_button_rects(inner);
- render_action_button(
- frame,
- open_rect,
- Some("↵"),
- "open",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-fn render_open_worktree_search(
- app: &AppState,
- frame: &mut Frame,
- area: Rect,
- open: &WorktreeOpenState,
-) {
- let focus_style = if open.search_focused {
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(app.palette.overlay0)
- };
- let filtered_count = open.filtered_indices().len();
- let count = if open.query.trim().is_empty() {
- format!("{} checkouts", open.entries.len())
- } else {
- format!("{filtered_count}/{} checkouts", open.entries.len())
- };
- let mut spans = vec![Span::styled(" / ", focus_style)];
- if open.query.trim().is_empty() {
- spans.push(Span::styled(
- "filter worktrees",
- Style::default().fg(app.palette.overlay0),
- ));
- } else {
- spans.push(Span::styled(
- open.query.clone(),
- Style::default().fg(app.palette.text),
- ));
- }
- spans.push(Span::styled(
- format!(
- "{count:>width$}",
- width = area.width.saturating_sub(18) as usize
- ),
- Style::default().fg(app.palette.overlay0),
- ));
- frame.render_widget(Paragraph::new(Line::from(spans)), area);
-}
-
-fn confirm_close_overlay_text(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
-) -> (String, String) {
- let ws_name = app
- .workspaces
- .get(app.selected)
- .map(|ws| ws.display_name_from(&app.terminals, terminal_runtimes))
- .unwrap_or_else(|| "?".to_string());
- let selected_space = app
- .workspaces
- .get(app.selected)
- .and_then(|ws| ws.worktree_space());
- let group_member_indices = selected_space
- .filter(|space| !space.is_linked_worktree)
- .map(|space| {
- app.workspaces
- .iter()
- .enumerate()
- .filter_map(|(idx, ws)| {
- ws.worktree_space()
- .is_some_and(|member| member.key == space.key)
- .then_some(idx)
- })
- .collect::<Vec<_>>()
- })
- .unwrap_or_default();
- let closes_group = group_member_indices.len() > 1;
- let pane_count = if closes_group {
- group_member_indices
- .iter()
- .filter_map(|idx| app.workspaces.get(*idx))
- .map(|ws| ws.layout.pane_count())
- .sum()
- } else {
- app.workspaces
- .get(app.selected)
- .map(|ws| ws.layout.pane_count())
- .unwrap_or(0)
- };
-
- let pane_text = if pane_count == 1 {
- "1 pane".to_string()
- } else {
- format!("{pane_count} panes")
- };
- let workspace_text = if closes_group {
- let count = group_member_indices.len();
- if count == 1 {
- "1 workspace, ".to_string()
- } else {
- format!("{count} workspaces, ")
- }
- } else {
- String::new()
- };
-
- let title = if closes_group {
- "Close worktree group?"
- } else {
- "Close workspace?"
- };
- let detail = format!("{ws_name} — {workspace_text}{pane_text}");
- (title.to_string(), detail)
-}
-
-pub(super) fn render_confirm_close_overlay(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- let (title, detail) = confirm_close_overlay_text(app, terminal_runtimes);
-
- super::dim_background(frame, area);
-
- let Some(popup) = confirm_close_popup_rect(area) else {
- return;
- };
-
- let warn = Style::default()
- .fg(app.palette.red)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
-
- let title_line = Line::from(vec![Span::styled(format!(" {title}"), warn)]);
-
- let detail_line = Line::from(vec![
- Span::styled(
- format!(" {}", detail.split(" — ").next().unwrap_or(&detail)),
- Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- detail
- .split_once(" — ")
- .map(|(_, rest)| format!(" — {rest}"))
- .unwrap_or_default(),
- dim,
- ),
- ]);
-
- let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
- else {
- return;
- };
-
- if inner.height >= 3 {
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<4>(inner);
-
- frame.render_widget(Paragraph::new(title_line), rows[0]);
- frame.render_widget(Paragraph::new(detail_line), rows[1]);
-
- let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner);
- render_action_button(
- frame,
- confirm_rect,
- Some("↵"),
- "confirm",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.red)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
- }
-}
-
-pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option<Rect> {
- centered_popup_rect(area, 64, 6)
-}
-
-pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "confirm",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- 3,
- );
- (rects[0], rects[1])
-}
-
-#[cfg(test)]
-mod tests {
- use crate::{
- app::{state::WorktreeCreateState, AppState, Mode},
- workspace::Workspace,
- };
- use ratatui::{
- backend::TestBackend,
- buffer::Buffer,
- layout::{Position, Rect},
- Terminal,
- };
-
- use super::{
- confirm_close_overlay_text, render_new_linked_worktree_overlay, render_rename_overlay,
- };
-
- #[test]
- fn confirm_close_text_uses_live_workspace_cwd_label() {
- let mut app = AppState::test_new();
- let mut workspace = Workspace::test_new("initial");
- workspace.custom_name = None;
- workspace.identity_cwd = "/projects/original".into();
- let root_pane = workspace.tabs[0].root_pane;
- let terminal_id = workspace.tabs[0].panes[&root_pane]
- .attached_terminal_id
- .clone();
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.terminals.get_mut(&terminal_id).unwrap().cwd = "/projects/current".into();
- app.selected = 0;
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(title, "Close workspace?");
- assert_eq!(detail, "current — 1 pane");
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn confirm_close_text_prefers_live_runtime_cwd_over_stale_terminal_cwd() {
- let root = std::env::temp_dir().join(format!(
- "herdr-confirm-close-runtime-cwd-{}",
- std::process::id()
- ));
- let stale_cwd = root.join("original");
- let live_cwd = root.join("current");
- std::fs::create_dir_all(&live_cwd).unwrap();
-
- let mut app = AppState::test_new();
- let mut workspace = Workspace::test_new("initial");
- workspace.custom_name = None;
- workspace.identity_cwd = stale_cwd.clone();
- let root_pane = workspace.tabs[0].root_pane;
- let terminal_id = workspace.tabs[0].panes[&root_pane]
- .attached_terminal_id
- .clone();
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.selected = 0;
-
- let (events, _) = tokio::sync::mpsc::channel(4);
- let runtime = crate::terminal::TerminalRuntime::spawn(
- root_pane,
- 24,
- 80,
- live_cwd,
- 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 mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- terminal_runtimes.insert(terminal_id, runtime);
-
- let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(detail, "current — 1 pane");
-
- drop(terminal_runtimes);
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn confirm_close_text_uses_selected_custom_name_instead_of_active_workspace_cwd() {
- let mut app = AppState::test_new();
- let active = Workspace::test_new("active");
- let selected = Workspace::test_new("selected");
- let selected_root = selected.tabs[0].root_pane;
- let selected_terminal_id = selected.tabs[0].panes[&selected_root]
- .attached_terminal_id
- .clone();
- app.workspaces = vec![active, selected];
- app.ensure_test_terminals();
- app.terminals.get_mut(&selected_terminal_id).unwrap().cwd = "/projects/current".into();
- app.active = Some(0);
- app.selected = 1;
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (_, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(detail, "selected — 1 pane");
- }
-
- #[test]
- fn confirm_close_text_reports_parent_group_scope() {
- let mut app = AppState::test_new();
- let mut parent = Workspace::test_new("main");
- parent.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr".into(),
- is_linked_worktree: false,
- });
- let mut child = Workspace::test_new("issue");
- child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-issue".into(),
- is_linked_worktree: true,
- });
- app.workspaces = vec![parent, child];
- app.selected = 0;
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (title, detail) = confirm_close_overlay_text(&app, &terminal_runtimes);
-
- assert_eq!(title, "Close worktree group?");
- assert_eq!(detail, "main — 2 workspaces, 2 panes");
- }
-
- #[test]
- fn new_worktree_error_renders_fatal_stderr_line() {
- let mut app = AppState::test_new();
- app.name_input = "foo".into();
- app.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "foo".into(),
- checkout_path: "/repo/.worktrees/herdr/foo".into(),
- error: Some(
- "Preparing worktree (new branch 'foo')\nfatal: a branch named 'foo' already exists"
- .into(),
- ),
- creating: false,
- });
-
- let mut terminal =
- Terminal::new(TestBackend::new(100, 30)).expect("test terminal should initialize");
- terminal
- .draw(|frame| render_new_linked_worktree_overlay(&app, frame, Rect::new(0, 0, 100, 30)))
- .expect("new worktree overlay should render");
- let rendered = terminal
- .backend()
- .buffer()
- .content()
- .iter()
- .map(|cell| cell.symbol())
- .collect::<String>();
-
- assert!(rendered.contains("fatal: a branch named 'foo' already exists"));
- }
-
- #[test]
- fn new_worktree_hit_test_geometry_matches_modal_size() {
- let area = Rect::new(0, 0, 100, 30);
- let inner = super::new_linked_worktree_inner_rect(area).unwrap();
- let (create, cancel) = super::new_linked_worktree_button_rects(inner);
-
- assert_eq!(inner.width, super::NEW_LINKED_WORKTREE_POPUP_WIDTH - 2);
- assert_eq!(inner.height, super::NEW_LINKED_WORKTREE_POPUP_HEIGHT - 2);
- assert_eq!(create.y, inner.y + inner.height - 1);
- assert_eq!(cancel.y, inner.y + inner.height - 1);
- }
-
- const RENAME_AREA: Rect = Rect {
- x: 0,
- y: 0,
- width: 80,
- height: 20,
- };
- const WORKTREE_AREA: Rect = Rect {
- x: 0,
- y: 0,
- width: 100,
- height: 30,
- };
-
- /// Reproduces the input row that `render_rename_overlay` lays out: the
- /// centred popup, the border inset, then the third row of the vertical
- /// split.
- fn rename_input_rect(area: Rect) -> Rect {
- let popup = super::centered_popup_rect(area, 56, 7).expect("popup fits");
- let inner = Rect::new(popup.x + 1, popup.y + 1, popup.width - 2, popup.height - 2);
- Rect::new(inner.x, inner.y + 2, inner.width, 1)
- }
-
- fn rename_overlay_caret_in(mode: Mode, name: &str) -> (Position, Buffer) {
- let mut app = AppState::test_new();
- app.mode = mode;
- app.name_input = name.into();
-
- let mut terminal = Terminal::new(TestBackend::new(RENAME_AREA.width, RENAME_AREA.height))
- .expect("test terminal");
- terminal
- .draw(|frame| render_rename_overlay(&app, frame, RENAME_AREA))
- .expect("rename overlay should render");
- let caret = terminal.get_cursor_position().expect("cursor position");
- (caret, terminal.backend().buffer().clone())
- }
-
- fn rename_overlay_caret(name: &str) -> Position {
- rename_overlay_caret_in(Mode::RenameWorkspace, name).0
- }
-
- fn worktree_overlay_caret(branch: &str) -> Position {
- let mut app = AppState::test_new();
- app.name_input = branch.into();
- app.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path: "/repo/.worktrees/herdr/foo".into(),
- error: None,
- creating: false,
- });
-
- let mut terminal =
- Terminal::new(TestBackend::new(WORKTREE_AREA.width, WORKTREE_AREA.height))
- .expect("test terminal");
- terminal
- .draw(|frame| render_new_linked_worktree_overlay(&app, frame, WORKTREE_AREA))
- .expect("new worktree overlay should render");
- terminal.get_cursor_position().expect("cursor position")
- }
-
- #[test]
- fn rename_overlay_anchors_the_host_cursor_to_the_input_caret() {
- let input = rename_input_rect(RENAME_AREA);
-
- // Without an explicit cursor the frame carries none, the client parks the
- // host cursor where the focused pane last reported it, and the IME
- // composes there instead of in the dialog.
- assert_eq!(
- rename_overlay_caret(""),
- Position::new(input.x + 1, input.y),
- "empty input should put the caret past the one-column left padding"
- );
- assert_eq!(
- rename_overlay_caret("abcd"),
- Position::new(input.x + 5, input.y)
- );
-
- // The cell under the caret has to be blank: a host terminal draws its
- // cursor by inverting that cell, so a glyph there would swallow it.
- let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, "ab");
- assert_eq!(caret, Position::new(input.x + 3, input.y));
- assert_eq!(buffer[(caret.x, caret.y)].symbol(), " ");
- assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "b");
- }
-
- #[test]
- fn rename_overlay_anchors_the_cursor_in_every_rename_mode() {
- let input = rename_input_rect(RENAME_AREA);
- let expected = Position::new(input.x + 3, input.y);
-
- for mode in [Mode::RenameWorkspace, Mode::RenameTab, Mode::RenamePane] {
- assert_eq!(
- rename_overlay_caret_in(mode, "ab").0,
- expected,
- "{mode:?} should anchor the caret like the other rename modes"
- );
- }
- }
-
- #[test]
- fn rename_overlay_caret_counts_wide_characters_as_two_columns() {
- let input = rename_input_rect(RENAME_AREA);
-
- // "あい" is two columns per character, so the caret sits two cells further
- // right than the two-column "ab".
- assert_eq!(
- rename_overlay_caret("あい"),
- Position::new(input.x + 5, input.y)
- );
- assert_eq!(
- rename_overlay_caret("aあ"),
- Position::new(input.x + 4, input.y)
- );
- }
-
- #[test]
- fn rename_overlay_caret_stays_inside_the_input_when_the_name_overflows() {
- let input = rename_input_rect(RENAME_AREA);
- let last_column = input.right() - 1;
-
- // The field is 54 columns wide. 51 characters is the last name whose
- // caret still lands strictly inside it; from 52 on the unclamped column
- // would leave the field and gets pinned to the final cell.
- assert_eq!(
- rename_overlay_caret(&"a".repeat(51)),
- Position::new(input.x + 52, input.y)
- );
- assert_eq!(
- rename_overlay_caret(&"a".repeat(53)),
- Position::new(last_column, input.y)
- );
- assert_eq!(
- rename_overlay_caret(&"a".repeat(200)),
- Position::new(last_column, input.y)
- );
-
- // The clamped cell has to stay blank as well, or the host cursor would
- // sit on a glyph and the IME would compose over it.
- let (caret, buffer) = rename_overlay_caret_in(Mode::RenameWorkspace, &"a".repeat(200));
- assert_eq!(caret, Position::new(last_column, input.y));
- assert_eq!(buffer[(caret.x, caret.y)].symbol(), " ");
- assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "a");
- }
-
- #[test]
- fn rename_overlay_caret_reaches_the_frame_the_server_sends() {
- let input = rename_input_rect(RENAME_AREA);
- let mut app = AppState::test_new();
- app.mode = Mode::RenameWorkspace;
- app.name_input = "ab".into();
-
- // The widget tests above stop at the ratatui frame. This one goes through
- // the server's cursor resolution, which is where the bug lived: the frame
- // used to leave here with `cursor: None`.
- let (_, cursor) =
- crate::server::render_stream::render_virtual(&mut app, RENAME_AREA, false);
- let cursor = cursor.expect("the modal caret should survive cursor resolution");
-
- assert_eq!((cursor.x, cursor.y), (input.x + 3, input.y));
- assert!(cursor.visible);
- }
-
- #[test]
- fn new_worktree_overlay_anchors_the_host_cursor_to_the_input_caret() {
- let popup = super::new_linked_worktree_inner_rect(WORKTREE_AREA).expect("popup fits");
- let input = Rect::new(popup.x, popup.y + 2, popup.width, 1);
-
- assert_eq!(
- worktree_overlay_caret(""),
- Position::new(input.x + 1, input.y)
- );
- assert_eq!(
- worktree_overlay_caret("ab"),
- Position::new(input.x + 3, input.y)
- );
- assert_eq!(
- worktree_overlay_caret("あい"),
- Position::new(input.x + 5, input.y)
- );
- }
-}
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
deleted file mode 100644
index 1f01c60c..00000000
--- a/src/ui/keybind_help.rs
+++ /dev/null
@@ -1,426 +0,0 @@
-use std::borrow::Cow;
-
-use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Paragraph, Wrap},
- Frame,
-};
-
-use super::release_notes::release_notes_close_button_rect;
-use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
-use super::widgets::{
- modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header,
- render_modal_shell,
-};
-use crate::app::AppState;
-
-pub(super) type HelpEntry = (String, Cow<'static, str>);
-pub(super) type HelpGroup = (&'static str, Vec<HelpEntry>);
-
-fn help_entry(key: impl Into<String>, label: &'static str) -> HelpEntry {
- (key.into(), Cow::Borrowed(label))
-}
-
-fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String {
- bindings.label().unwrap_or_else(|| "unset".to_string())
-}
-
-fn indexed_label(bindings: &[crate::config::IndexedKeybind]) -> String {
- if bindings.is_empty() {
- return "unset".to_string();
- }
-
- let mut parts = Vec::new();
- let mut index = 0;
- while index < bindings.len() {
- if let Some(prefix) = indexed_range_prefix(&bindings[index..]) {
- parts.push(format!("{prefix}1..9"));
- index += 9;
- } else {
- parts.push(bindings[index].label.clone());
- index += 1;
- }
- }
-
- parts.join(" / ")
-}
-
-fn indexed_range_prefix(bindings: &[crate::config::IndexedKeybind]) -> Option<&str> {
- let run = bindings.get(..9)?;
- let prefix = run[0].label.strip_suffix('1')?;
- for (offset, binding) in run.iter().enumerate() {
- let digit = char::from(b'1' + offset as u8);
- if binding.label.strip_suffix(digit) != Some(prefix) {
- return None;
- }
- }
- Some(prefix)
-}
-
-pub(super) fn keybind_help_groups(app: &AppState) -> Vec<HelpGroup> {
- let kb = &app.keybinds;
- let mut groups = Vec::new();
-
- groups.push((
- "global",
- vec![
- help_entry(
- crate::config::format_key_combo((app.prefix_code, app.prefix_mods)),
- "prefix mode",
- ),
- help_entry(keybind_label(&kb.help), "keybinds"),
- help_entry(keybind_label(&kb.settings), "settings"),
- help_entry(keybind_label(&kb.detach), "detach"),
- help_entry(keybind_label(&kb.reload_config), "reload config"),
- help_entry(
- keybind_label(&kb.open_notification_target),
- "open notification target",
- ),
- ],
- ));
-
- groups.push((
- "navigation",
- vec![
- help_entry("esc", "back"),
- help_entry(
- format!(
- "{} / {}",
- keybind_label(&kb.navigate.workspace_up),
- keybind_label(&kb.navigate.workspace_down)
- ),
- "workspace list",
- ),
- help_entry(
- format!(
- "{} / {} / {} / {} / left / right",
- keybind_label(&kb.navigate.pane_left),
- keybind_label(&kb.navigate.pane_down),
- keybind_label(&kb.navigate.pane_up),
- keybind_label(&kb.navigate.pane_right)
- ),
- "move focus",
- ),
- help_entry("tab / shift+tab", "cycle pane"),
- help_entry("enter", "open workspace"),
- help_entry("1..9", "switch workspace"),
- ],
- ));
-
- let workspace_tab = vec![
- help_entry(keybind_label(&kb.workspace_picker), "workspace navigation"),
- help_entry(keybind_label(&kb.goto), "session navigator"),
- help_entry(keybind_label(&kb.new_workspace), "new workspace"),
- help_entry(keybind_label(&kb.new_worktree), "new worktree"),
- help_entry(keybind_label(&kb.open_worktree), "open worktree"),
- help_entry(
- keybind_label(&kb.remove_worktree),
- "delete worktree checkout",
- ),
- help_entry(keybind_label(&kb.rename_workspace), "rename workspace"),
- help_entry(keybind_label(&kb.close_workspace), "close workspace"),
- help_entry(keybind_label(&kb.previous_workspace), "previous workspace"),
- help_entry(keybind_label(&kb.next_workspace), "next workspace"),
- help_entry(indexed_label(&kb.switch_workspace), "switch workspace 1-9"),
- help_entry(keybind_label(&kb.previous_agent), "previous agent"),
- help_entry(keybind_label(&kb.next_agent), "next agent"),
- help_entry(indexed_label(&kb.focus_agent), "focus agent 1-9"),
- help_entry(keybind_label(&kb.new_tab), "new tab"),
- help_entry(keybind_label(&kb.rename_tab), "rename tab"),
- help_entry(keybind_label(&kb.previous_tab), "previous tab"),
- help_entry(keybind_label(&kb.next_tab), "next tab"),
- help_entry(keybind_label(&kb.move_tab_previous), "move tab left"),
- help_entry(keybind_label(&kb.move_tab_next), "move tab right"),
- help_entry(indexed_label(&kb.switch_tab), "switch tab 1-9"),
- help_entry(keybind_label(&kb.close_tab), "close tab"),
- ];
- groups.push(("workspaces / tabs", workspace_tab));
-
- let panes = vec![
- help_entry(keybind_label(&kb.split_vertical), "split vertical"),
- help_entry(keybind_label(&kb.split_horizontal), "split horizontal"),
- help_entry(keybind_label(&kb.close_pane), "close pane"),
- help_entry(keybind_label(&kb.rename_pane), "rename pane"),
- help_entry(keybind_label(&kb.edit_scrollback), "edit scrollback"),
- help_entry(keybind_label(&kb.copy_mode), "copy mode"),
- help_entry(keybind_label(&kb.zoom), "zoom pane"),
- help_entry(keybind_label(&kb.resize_mode), "resize mode"),
- help_entry(keybind_label(&kb.resize_pane_left), "resize pane left"),
- help_entry(keybind_label(&kb.resize_pane_down), "resize pane down"),
- help_entry(keybind_label(&kb.resize_pane_up), "resize pane up"),
- help_entry(keybind_label(&kb.resize_pane_right), "resize pane right"),
- help_entry(keybind_label(&kb.toggle_sidebar), "toggle sidebar"),
- help_entry(keybind_label(&kb.focus_pane_left), "focus pane left"),
- help_entry(keybind_label(&kb.focus_pane_down), "focus pane down"),
- help_entry(keybind_label(&kb.focus_pane_up), "focus pane up"),
- help_entry(keybind_label(&kb.focus_pane_right), "focus pane right"),
- help_entry(keybind_label(&kb.cycle_pane_next), "cycle pane next"),
- help_entry(
- keybind_label(&kb.cycle_pane_previous),
- "cycle pane previous",
- ),
- help_entry(keybind_label(&kb.last_pane), "last pane"),
- ];
- groups.push(("panes", panes));
-
- if !kb.custom_commands.is_empty() {
- groups.push((
- "custom",
- kb.custom_commands
- .iter()
- .map(|binding| {
- (
- binding.label.clone(),
- binding
- .description
- .clone()
- .map(Cow::Owned)
- .unwrap_or(Cow::Borrowed("custom command")),
- )
- })
- .collect(),
- ));
- }
-
- groups
-}
-
-fn filter_keybind_help_groups(groups: Vec<HelpGroup>, query: &str) -> Vec<HelpGroup> {
- if query.is_empty() {
- return groups;
- }
-
- let query = query.to_lowercase();
- groups
- .into_iter()
- .filter_map(|(group, entries)| {
- let entries = entries
- .into_iter()
- .filter(|(key, label)| {
- key.to_lowercase().contains(&query) || label.to_lowercase().contains(&query)
- })
- .collect::<Vec<_>>();
- (!entries.is_empty()).then_some((group, entries))
- })
- .collect()
-}
-
-pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> {
- let heading_style = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let key_style = Style::default()
- .fg(app.palette.mauve)
- .add_modifier(Modifier::BOLD);
- let label_style = Style::default().fg(app.palette.text);
-
- let groups = filter_keybind_help_groups(keybind_help_groups(app), &app.keybind_help.query);
- let key_width = groups
- .iter()
- .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
- .max()
- .unwrap_or(8);
-
- let mut lines = Vec::new();
-
- if groups.is_empty() {
- let message = " no matching keybinds";
- return vec![(
- message.chars().count(),
- Line::from(Span::styled(
- message,
- Style::default().fg(app.palette.overlay1),
- )),
- )];
- }
-
- for (group, entries) in groups {
- lines.push((
- group.len() + 1,
- Line::from(vec![Span::styled(format!(" {group}"), heading_style)]),
- ));
- for (key, label) in entries {
- let padded_key = format!(" {:<width$} ", key, width = key_width);
- let width = padded_key.chars().count() + label.chars().count();
- lines.push((
- width,
- Line::from(vec![
- Span::styled(padded_key, key_style),
- Span::styled(label.into_owned(), label_style),
- ]),
- ));
- }
- lines.push((0, Line::raw("")));
- }
-
- lines
-}
-
-pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
- super::dim_background(frame, frame.area());
-
- let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
- return;
- };
- if inner.height < 6 || inner.width < 20 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- render_modal_header(frame, header_rows[0], "keybinds", &app.palette);
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- if app.keybind_help.search_focused {
- "back"
- } else {
- "close"
- },
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
- let search_line = if app.keybind_help.search_focused {
- Line::from(vec![
- Span::styled(
- " / ",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- app.keybind_help.query.as_str(),
- Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD),
- ),
- ])
- } else {
- Line::from(Span::styled(
- " press / to filter by command or shortcut",
- Style::default().fg(app.palette.overlay0),
- ))
- };
- frame.render_widget(Paragraph::new(search_line), header_rows[1]);
-
- let body_area = stack.content;
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: app
- .keybind_help_max_scroll()
- .saturating_sub(app.keybind_help.scroll) as usize,
- max_offset_from_bottom: app.keybind_help_max_scroll() as usize,
- viewport_rows: body_area.height.max(1) as usize,
- };
- let track = release_notes_scrollbar_rect(body_area, metrics);
- let text_area = track
- .map(|_| {
- Rect::new(
- body_area.x,
- body_area.y,
- body_area.width.saturating_sub(1),
- body_area.height,
- )
- })
- .unwrap_or(body_area);
-
- let body = Paragraph::new(
- keybind_help_lines(app)
- .into_iter()
- .map(|(_, line)| line)
- .collect::<Vec<_>>(),
- )
- .wrap(Wrap { trim: false })
- .scroll((app.keybind_help.scroll, 0));
- frame.render_widget(body, text_area);
- if let Some(track) = track {
- render_scrollbar(
- frame,
- metrics,
- track,
- app.palette.overlay0,
- app.palette.overlay1,
- "▐",
- );
- }
-
- let footer = if app.keybind_help.search_focused {
- Line::from(vec![
- Span::styled(" filter ", Style::default().fg(app.palette.overlay0)),
- Span::styled("type/backspace", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("clear ", Style::default().fg(app.palette.overlay0)),
- Span::styled("ctrl+u", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("scroll ", Style::default().fg(app.palette.overlay0)),
- Span::styled("↑↓/pgup/pgdn", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("back ", Style::default().fg(app.palette.overlay0)),
- Span::styled("esc", Style::default().fg(app.palette.text)),
- ])
- } else {
- Line::from(vec![
- Span::styled(" search ", Style::default().fg(app.palette.overlay0)),
- Span::styled("/", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("scroll ", Style::default().fg(app.palette.overlay0)),
- Span::styled("j/k/↑↓/pgup/pgdn", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("close ", Style::default().fg(app.palette.overlay0)),
- Span::styled("esc/enter", Style::default().fg(app.palette.text)),
- ])
- };
- frame.render_widget(Paragraph::new(footer), stack.footer.unwrap_or_default());
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn groups() -> Vec<HelpGroup> {
- vec![
- (
- "workspaces / tabs",
- vec![
- help_entry("w", "workspace navigation"),
- help_entry("c", "new tab"),
- ],
- ),
- (
- "panes",
- vec![
- help_entry("v", "split vertical"),
- help_entry("x", "close pane"),
- ],
- ),
- ]
- }
-
- #[test]
- fn keybind_help_filter_matches_labels_case_insensitively() {
- let filtered = filter_keybind_help_groups(groups(), "WoRk");
-
- assert_eq!(filtered.len(), 1);
- assert_eq!(filtered[0].0, "workspaces / tabs");
- assert_eq!(filtered[0].1.len(), 1);
- assert_eq!(filtered[0].1[0].1, "workspace navigation");
- }
-
- #[test]
- fn keybind_help_filter_matches_shortcuts_without_matching_group_headings() {
- let filtered = filter_keybind_help_groups(groups(), "x");
-
- assert_eq!(filtered.len(), 1);
- assert_eq!(filtered[0].0, "panes");
- assert_eq!(filtered[0].1.len(), 1);
- assert_eq!(filtered[0].1[0].1, "close pane");
-
- assert!(filter_keybind_help_groups(groups(), "panes").is_empty());
- }
-}
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
deleted file mode 100644
index 763c0ea8..00000000
--- a/src/ui/menus.rs
+++ /dev/null
@@ -1,315 +0,0 @@
-use ratatui::{
- layout::{Alignment, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Clear, List, ListItem, ListState, Paragraph},
- Frame,
-};
-
-use super::widgets::{panel_contrast_fg, render_panel_shell};
-use crate::app::AppState;
-
-fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String {
- bindings
- .prefix_rhs_label()
- .unwrap_or_else(|| "unset".to_string())
-}
-
-fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String {
- bindings.label().unwrap_or_else(|| "unset".to_string())
-}
-
-fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) {
- frame.render_widget(Clear, area);
- let buf = frame.buffer_mut();
- for x in area.x..area.x + area.width {
- buf[(x, area.y)].set_style(Style::default().bg(bg));
- }
- frame.render_widget(Paragraph::new(line), area);
-}
-
-pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let key = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
- let mode_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
-
- let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker);
- let help = prefix_rhs_label(&app.keybinds.help);
- let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods));
-
- let line = Line::from(vec![
- Span::styled(" PREFIX ", mode_style),
- Span::raw(" "),
- Span::styled("esc", key),
- Span::styled(" cancel ", dim),
- Span::styled(prefix, key),
- Span::styled(" send prefix ", dim),
- Span::styled(workspace_picker, key),
- Span::styled(" workspace nav ", dim),
- Span::styled(help, key),
- Span::styled(" keybinds", dim),
- ]);
-
- let overlay_y = area.y + area.height.saturating_sub(1);
- let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
- render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
-}
-
-pub(super) fn render_copy_mode_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let key = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
- let mode_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
-
- let Some(copy_mode) = app.copy_mode.as_ref() else {
- return;
- };
- let line = if let Some(prompt) = copy_mode.search.prompt.as_ref() {
- let marker = match prompt.direction {
- crate::app::state::CopyModeSearchDirection::Forward => "/",
- crate::app::state::CopyModeSearchDirection::Backward => "?",
- };
- Line::from(vec![
- Span::styled(" COPY ", mode_style),
- Span::raw(" "),
- Span::styled(marker, key),
- Span::styled(prompt.query.clone(), Style::default().fg(app.palette.text)),
- Span::styled("█", key),
- Span::styled(" enter search esc cancel", dim),
- ])
- } else {
- let select = if copy_mode.selection.is_some() {
- "selecting"
- } else {
- "select"
- };
- let match_status = copy_mode
- .search
- .current
- .map(|current| format!(" {}/{}", current + 1, copy_mode.search.matches.len()))
- .or_else(|| (!copy_mode.search.query.is_empty()).then(|| " 0/0".to_string()))
- .unwrap_or_default();
- let (exit_keys, exit_label) =
- if copy_mode.search.query.is_empty() && copy_mode.selection.is_none() {
- ("q/esc", " exit")
- } else {
- ("esc", " clear q exit")
- };
- Line::from(vec![
- Span::styled(" COPY ", mode_style),
- Span::raw(" "),
- Span::styled("h/j/k/l w/b/e { }", key),
- Span::styled(" move ", dim),
- Span::styled("/ ?", key),
- Span::styled(" search ", dim),
- Span::styled("n/N", key),
- Span::styled(format!(" repeat{match_status} "), dim),
- Span::styled("v/space", key),
- Span::styled(format!(" {select} "), dim),
- Span::styled("y/enter", key),
- Span::styled(" copy ", dim),
- Span::styled(exit_keys, key),
- Span::styled(exit_label, dim),
- ])
- };
-
- let overlay_y = area.y + area.height.saturating_sub(1);
- let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
- render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
-}
-
-pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let key = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
-
- let mode_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
-
- let kb = &app.keybinds;
- let new_tab = prefix_rhs_label(&kb.new_tab);
- let split_vertical = prefix_rhs_label(&kb.split_vertical);
- let split_horizontal = prefix_rhs_label(&kb.split_horizontal);
- let close_pane = prefix_rhs_label(&kb.close_pane);
- let zoom = prefix_rhs_label(&kb.zoom);
- let resize = prefix_rhs_label(&kb.resize_mode);
- let help = prefix_rhs_label(&kb.help);
- let settings = prefix_rhs_label(&kb.settings);
- let goto = prefix_rhs_label(&kb.goto);
- let detach = prefix_rhs_label(&kb.detach);
- let workspace_nav = format!(
- "{} / {}",
- keybind_label(&kb.navigate.workspace_up),
- keybind_label(&kb.navigate.workspace_down)
- );
- let line = Line::from(vec![
- Span::styled(" NAVIGATE ", mode_style),
- Span::raw(" "),
- Span::styled("esc", key),
- Span::styled(" back ", dim),
- Span::styled(workspace_nav, key),
- Span::styled(" ws ", dim),
- Span::styled("⇥", key),
- Span::styled(" pane ", dim),
- Span::styled(goto, key),
- Span::styled(" navigator ", dim),
- Span::styled(new_tab, key),
- Span::styled(" new tab ", dim),
- Span::styled(split_vertical, key),
- Span::styled(" split│ ", dim),
- Span::styled(split_horizontal, key),
- Span::styled(" split─ ", dim),
- Span::styled(close_pane, key),
- Span::styled(" close ", dim),
- Span::styled(zoom, key),
- Span::styled(" zoom ", dim),
- Span::styled(resize, key),
- Span::styled(" resize ", dim),
- Span::styled(help, key),
- Span::styled(" keybinds ", dim),
- Span::styled(settings, key),
- Span::styled(" settings ", dim),
- Span::styled(detach, key),
- Span::styled(" detach", dim),
- ]);
-
- let overlay_y = area.y + area.height.saturating_sub(1);
- let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
- render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
-
- if app.update_available.is_some() {
- let status = Line::from(vec![Span::styled(
- " update ready",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- )]);
- let width = 13u16.min(overlay_area.width);
- let status_area = Rect::new(
- overlay_area.x + overlay_area.width.saturating_sub(width),
- overlay_area.y,
- width,
- overlay_area.height,
- );
- frame.render_widget(Clear, status_area);
- frame.render_widget(
- Paragraph::new(status).alignment(Alignment::Right),
- status_area,
- );
- }
-}
-
-pub(super) fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
- let rect = app.global_menu_rect();
- let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg)
- else {
- return;
- };
-
- let items = app.global_menu_labels();
- for (idx, item) in items.iter().enumerate() {
- let y = inner.y + idx as u16;
- if y >= inner.y + inner.height {
- break;
- }
- let selected = idx == app.global_menu.highlighted;
- let rect = Rect::new(inner.x, y, inner.width, 1);
-
- let selected_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let item_style = if selected {
- selected_style
- } else {
- Style::default().fg(app.palette.text)
- };
- let badge_style = if selected {
- selected_style
- } else {
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD)
- };
-
- let line = if app.global_menu_item_has_badge(item) {
- Line::from(vec![
- Span::styled(" ●", badge_style),
- Span::styled(format!(" {item} "), item_style),
- ])
- } else {
- Line::from(Span::styled(format!(" {item} "), item_style))
- };
- frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect);
- }
-}
-
-pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let key = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
-
- let mode_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.mauve)
- .add_modifier(Modifier::BOLD);
-
- let line = Line::from(vec![
- Span::styled(" RESIZE ", mode_style),
- Span::raw(" "),
- Span::styled("h/l", key),
- Span::styled(" width ", dim),
- Span::styled("j/k", key),
- Span::styled(" height ", dim),
- Span::styled("esc", key),
- Span::styled(" done", dim),
- ]);
-
- let overlay_y = area.y + area.height.saturating_sub(1);
- let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
- render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
-}
-
-pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) {
- let Some(menu) = &app.context_menu else {
- return;
- };
-
- let p = &app.palette;
- let Some(menu_rect) = app.context_menu_rect() else {
- return;
- };
- let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else {
- return;
- };
-
- let items: Vec<ListItem> = menu
- .items()
- .iter()
- .map(|item| ListItem::new(Line::from(*item)))
- .collect();
- let list = List::new(items)
- .style(Style::default().fg(p.text))
- .highlight_style(
- Style::default()
- .bg(p.accent)
- .fg(panel_contrast_fg(p))
- .add_modifier(Modifier::BOLD),
- )
- .highlight_symbol(" ");
- let mut state = ListState::default().with_selected(Some(menu.list.highlighted));
- frame.render_stateful_widget(list, inner, &mut state);
-}
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
deleted file mode 100644
index bdec6551..00000000
--- a/src/ui/mobile.rs
+++ /dev/null
@@ -1,1626 +0,0 @@
-use ratatui::{
- layout::{Alignment, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Clear, Paragraph},
- Frame,
-};
-
-use super::sidebar::{
- agent_panel_entries, agent_panel_entries_from, grouped_child_display_label,
- next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry,
- WorkspaceListEntry,
-};
-use super::status::{state_icon, state_icon_symbol};
-use super::text::{display_width_u16, truncate_end};
-use crate::app::state::{Palette, ToastKind, ToastNotification};
-use crate::app::AppState;
-use crate::config::StatusIndicatorStyle;
-use crate::detect::AgentState;
-use crate::layout::PaneId;
-use crate::terminal::TerminalRuntimeRegistry;
-
-const SWITCH_BUTTON_WIDTH: u16 = 10;
-
-#[derive(Debug, Clone, Copy, Default)]
-pub(crate) struct MobileHeaderHitAreas {
- pub menu: Rect,
-}
-
-#[derive(Debug, Clone, Copy, Default)]
-pub(crate) struct MobileSwitcherAreas {
- pub close: Rect,
- pub viewport: Rect,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum MobileSwitcherTarget {
- NewWorkspace,
- Workspace(usize),
- NewTab,
- Tab(usize),
- Agent {
- ws_idx: usize,
- tab_idx: usize,
- pane_id: PaneId,
- },
- Menu(usize),
-}
-
-pub(crate) fn is_mobile_width(area: Rect, threshold: u16) -> bool {
- area.width > 0 && area.width <= threshold
-}
-
-pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas {
- if area.width == 0 || area.height == 0 {
- return MobileHeaderHitAreas::default();
- }
-
- let width = SWITCH_BUTTON_WIDTH.min(area.width);
- let switch = Rect::new(
- area.x + area.width.saturating_sub(width),
- area.y,
- width,
- area.height,
- );
-
- MobileHeaderHitAreas { menu: switch }
-}
-
-pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas {
- let screen = mobile_screen_rect(app);
- if screen.width == 0 || screen.height <= 2 {
- return MobileSwitcherAreas::default();
- }
-
- let header_h = screen.height.min(2);
- let close_w = 10u16.min(screen.width);
- let close = Rect::new(
- screen.x + screen.width.saturating_sub(close_w),
- screen.y,
- close_w,
- header_h,
- );
- let viewport = Rect::new(
- screen.x,
- screen.y + header_h + 1,
- screen.width,
- screen.height.saturating_sub(header_h + 1),
- );
-
- MobileSwitcherAreas { close, viewport }
-}
-
-pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize {
- mobile_switcher_content_height(app).saturating_sub(viewport_height as usize)
-}
-
-/// Doc-row height of the agents section. An active query keeps its title and an
-/// empty-state row visible even when no agents match.
-fn mobile_agents_block_height(app: &AppState) -> usize {
- let count = agent_panel_entries(app).len();
- if count == 0 {
- usize::from(app.agent_view_override.is_some()) * 2
- } else {
- 1 + count * 2
- }
-}
-
-pub(crate) fn mobile_switcher_workspace_doc_range(
- app: &AppState,
- idx: usize,
-) -> std::ops::Range<usize> {
- // Spaces render in grouped order, so a workspace's row position is its index
- // in the entry list, not its raw array index.
- let pos = workspace_list_entries_expanded(app)
- .iter()
- .position(|WorkspaceListEntry::Workspace { ws_idx, .. }| *ws_idx == idx)
- .unwrap_or(idx);
- // spaces sit after the agents block, then a title + "new workspace" row.
- let start = mobile_agents_block_height(app) + 2 + pos * 2;
- start..start + 2
-}
-
-pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize {
- mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height)
-}
-
-pub(crate) fn mobile_switcher_target_at(
- app: &AppState,
- col: u16,
- row: u16,
-) -> Option<MobileSwitcherTarget> {
- let areas = mobile_switcher_areas(app);
- let content = inset_for_left_scrollbar(areas.viewport);
- if !rect_contains(content, col, row) {
- return None;
- }
-
- let scroll = app
- .mobile_switcher_scroll
- .min(mobile_switcher_max_scroll_for_height(
- app,
- areas.viewport.height,
- ));
- let doc_row = scroll.saturating_add(row.saturating_sub(areas.viewport.y) as usize);
- let mut cursor = 0usize;
-
- // Agents lead the switcher: the primary job is switching between running
- // agents. Spaces/tabs/create actions follow for navigation and management.
- let agents = agent_panel_entries(app);
- if !agents.is_empty() || app.agent_view_override.is_some() {
- cursor += 1; // agents title
- if agents.is_empty() {
- cursor += 1; // active-query empty state
- } else {
- let agents_end = cursor + agents.len() * 2;
- if doc_row >= cursor && doc_row < agents_end {
- let idx = (doc_row - cursor) / 2;
- return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent {
- ws_idx: entry.ws_idx,
- tab_idx: entry.tab_idx,
- pane_id: entry.pane_id,
- });
- }
- cursor = agents_end;
- }
- }
-
- cursor += 1; // spaces title
- if doc_row == cursor {
- return Some(MobileSwitcherTarget::NewWorkspace);
- }
- cursor += 1;
- // Spaces render in grouped (worktree-tree) order, which differs from raw
- // array order, so map the clicked row to the entry's real workspace index.
- let space_entries = workspace_list_entries_expanded(app);
- let spaces_end = cursor + space_entries.len() * 2;
- if doc_row >= cursor && doc_row < spaces_end {
- let entry_idx = (doc_row - cursor) / 2;
- return space_entries.get(entry_idx).map(
- |WorkspaceListEntry::Workspace { ws_idx, .. }| MobileSwitcherTarget::Workspace(*ws_idx),
- );
- }
- cursor = spaces_end;
-
- if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) {
- cursor += 1; // tabs title
- if doc_row == cursor {
- return Some(MobileSwitcherTarget::NewTab);
- }
- cursor += 1;
- let tabs_end = cursor + ws.tabs.len();
- if doc_row >= cursor && doc_row < tabs_end {
- return Some(MobileSwitcherTarget::Tab(doc_row - cursor));
- }
- cursor = tabs_end;
- }
-
- cursor += 1; // menu title
- let menu_idx = doc_row.checked_sub(cursor)?;
- (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx))
-}
-
-pub(crate) fn render_mobile_header(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- if area.width == 0 || area.height == 0 {
- return;
- }
-
- let p = &app.palette;
- fill_rect(frame, area, Style::default().bg(p.panel_bg));
-
- let switch = app.view.mobile_menu_hit_area;
- let status_w = switch.x.saturating_sub(area.x).saturating_sub(1);
- let status = Rect::new(area.x, area.y, status_w, area.height);
-
- render_header_status(app, terminal_runtimes, frame, status);
- render_switch_button(app, frame, switch);
-}
-
-pub(crate) fn mobile_toast_banner_rect(area: Rect, offset_for_warning: bool) -> Rect {
- if area.width == 0 || area.height == 0 {
- return Rect::default();
- }
-
- let y = area.y
- + area
- .height
- .saturating_sub(1 + if offset_for_warning { 1 } else { 0 });
- Rect::new(area.x, y, area.width, 1)
-}
-
-pub(crate) fn render_mobile_toast_banner(
- frame: &mut Frame,
- area: Rect,
- toast: &ToastNotification,
- offset_for_warning: bool,
- p: &Palette,
-) {
- if area.width == 0 || area.height == 0 {
- return;
- }
-
- let dot_color = match toast.kind {
- ToastKind::NeedsAttention => p.red,
- ToastKind::Finished => p.blue,
- ToastKind::UpdateInstalled => p.accent,
- };
- let banner = mobile_toast_banner_rect(area, offset_for_warning);
- let bg = p.surface0;
-
- frame.render_widget(Clear, banner);
- fill_rect(frame, banner, Style::default().bg(bg));
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" ", Style::default().bg(bg)),
- Span::styled("●", Style::default().fg(dot_color).bg(bg)),
- Span::styled(" ", Style::default().bg(bg)),
- Span::styled(
- mobile_toast_title(toast),
- Style::default()
- .fg(p.text)
- .bg(bg)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)),
- Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)),
- ])),
- banner,
- );
-}
-
-pub(crate) fn render_mobile_panel(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- if area.width == 0 || area.height == 0 {
- return;
- }
-
- let p = &app.palette;
- frame.render_widget(Clear, area);
- fill_rect(frame, area, Style::default().bg(p.panel_bg));
-
- let areas = mobile_switcher_areas(app);
- frame.render_widget(
- Paragraph::new(" switch").style(
- Style::default()
- .fg(p.text)
- .bg(p.panel_bg)
- .add_modifier(Modifier::BOLD),
- ),
- Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1),
- );
- render_close_button(app, frame, areas.close);
-
- if area.height > areas.close.height {
- draw_horizontal_rule(
- frame,
- Rect::new(area.x, area.y + areas.close.height, area.width, 1),
- p,
- );
- }
-
- render_mobile_switcher_content(app, terminal_runtimes, frame, areas.viewport);
-}
-
-fn render_header_status(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let p = &app.palette;
- let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else {
- frame.render_widget(Paragraph::new(" no workspace"), area);
- return;
- };
-
- let (state, seen) = ws.aggregate_state(&app.terminals);
- let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p);
- let tab_label = mobile_tab_status(ws);
- let row1 = Rect::new(area.x, area.y, area.width, 1);
- let tab_w = display_width_u16(&tab_label)
- .saturating_add(1)
- .min(area.width);
- let name_w = area.width.saturating_sub(tab_w);
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::raw(" "),
- Span::styled(dot, dot_style.bg(p.panel_bg)),
- Span::raw(" "),
- Span::styled(
- truncate_end(
- &ws.display_name_from(&app.terminals, terminal_runtimes),
- name_w.saturating_sub(4) as usize,
- ),
- Style::default()
- .fg(p.text)
- .bg(p.panel_bg)
- .add_modifier(Modifier::BOLD),
- ),
- ])),
- Rect::new(row1.x, row1.y, name_w, 1),
- );
- frame.render_widget(
- Paragraph::new(tab_label)
- .style(Style::default().fg(p.overlay1).bg(p.panel_bg))
- .alignment(Alignment::Right),
- Rect::new(row1.x + name_w, row1.y, tab_w, 1),
- );
-
- if area.height > 1 {
- frame.render_widget(
- Paragraph::new(agent_summary_line(app, p, area.width)),
- Rect::new(area.x, area.y + 1, area.width, 1),
- );
- }
-}
-
-fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String {
- let tab_label = ws
- .tab_display_name(ws.active_tab)
- .unwrap_or_else(|| (ws.active_tab + 1).to_string());
- if ws.tabs.len() <= 1 {
- format!("tab {tab_label}")
- } else {
- format!("tab {tab_label} · {}/{}", ws.active_tab + 1, ws.tabs.len())
- }
-}
-
-fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let p = &app.palette;
- fill_rect(frame, area, Style::default().bg(p.surface0));
- for y in area.y..area.y + area.height {
- frame.buffer_mut()[(area.x, y)]
- .set_symbol("│")
- .set_style(Style::default().fg(p.surface_dim).bg(p.surface0));
- }
- let label_y = if area.height > 1 { area.y + 1 } else { area.y };
- frame.render_widget(
- Paragraph::new("switch")
- .style(
- Style::default()
- .fg(p.text)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD),
- )
- .alignment(Alignment::Center),
- Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1),
- );
-
- // Attention badge: a blocked agent anywhere makes the button itself read as
- // "tap me" without the user reading the summary row.
- if global_agent_counts(app).blocked > 0 {
- let bx = area.x + area.width.saturating_sub(1);
- let (symbol, style) = state_icon(AgentState::Blocked, true, app.status_indicators, p);
- frame.buffer_mut()[(bx, area.y)]
- .set_symbol(symbol)
- .set_style(style.bg(p.surface0));
- }
-}
-
-fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let p = &app.palette;
- fill_rect(frame, area, Style::default().bg(p.surface0));
- for y in area.y..area.y + area.height {
- frame.buffer_mut()[(area.x, y)]
- .set_symbol("│")
- .set_style(Style::default().fg(p.surface_dim).bg(p.surface0));
- }
- frame.render_widget(
- Paragraph::new("close")
- .style(
- Style::default()
- .fg(p.overlay1)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD),
- )
- .alignment(Alignment::Center),
- Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1),
- );
- if area.height > 1 {
- frame.render_widget(
- Paragraph::new("×")
- .style(
- Style::default()
- .fg(p.text)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD),
- )
- .alignment(Alignment::Center),
- Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1),
- );
- }
-}
-
-fn mobile_switcher_content_height(app: &AppState) -> usize {
- // Derive spaces height from the same entry list the render/hit-test use so
- // the three never disagree.
- let spaces_h = 2 + workspace_list_entries_expanded(app).len() * 2;
- let tabs_h = app
- .active
- .and_then(|idx| app.workspaces.get(idx))
- .map(|ws| 2 + ws.tabs.len())
- .unwrap_or(0);
- let agents_h = mobile_agents_block_height(app);
- let menu_h = 1 + app.global_menu_labels().len();
- spaces_h + tabs_h + agents_h + menu_h
-}
-
-fn render_mobile_switcher_content(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- viewport: Rect,
-) {
- if viewport.width == 0 || viewport.height == 0 {
- return;
- }
-
- let p = &app.palette;
- let total_height = mobile_switcher_content_height(app);
- render_left_scrollbar(
- frame,
- viewport,
- total_height,
- viewport.height as usize,
- app.mobile_switcher_scroll,
- p,
- );
- let content = inset_for_left_scrollbar(viewport);
- if content == Rect::default() {
- return;
- }
-
- let mut doc_y = 0usize;
-
- let entries = agent_panel_entries_from(app, terminal_runtimes);
- if !entries.is_empty() || app.agent_view_override.is_some() {
- let focused_agent = app.active.and_then(|ws_idx| {
- let ws = app.workspaces.get(ws_idx)?;
- ws.focused_pane_id()
- .map(|pane_id| (ws_idx, ws.active_tab, pane_id))
- });
- let title = app
- .agent_view_override
- .as_ref()
- .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered")))
- .unwrap_or_else(|| "agents".to_string());
- render_section_title_at(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- &title,
- p,
- );
- doc_y += 1;
- if entries.is_empty() {
- render_one_line_item(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- ratatui::style::Color::Reset,
- Line::from(Span::styled(
- " no matching agents",
- Style::default().fg(p.overlay0).add_modifier(Modifier::DIM),
- )),
- );
- doc_y += 1;
- }
- for entry in &entries {
- let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| {
- entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id
- });
- let bg = mobile_item_bg(false, active, p);
- let (icon, icon_style) = state_icon(entry.state, entry.seen, app.status_indicators, p);
- let title = Line::from(vec![
- Span::styled(" ", Style::default().bg(bg)),
- Span::styled(icon, icon_style.bg(bg)),
- Span::styled(" ", Style::default().bg(bg)),
- Span::styled(
- truncate_end(
- &entry.primary_label,
- content.width.saturating_sub(5) as usize,
- ),
- Style::default()
- .fg(p.text)
- .bg(bg)
- .add_modifier(Modifier::BOLD),
- ),
- ]);
- let detail = mobile_agent_detail(entry);
- render_two_line_item(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- bg,
- title,
- truncate_end(&detail, content.width as usize),
- p.overlay0,
- );
- doc_y += 2;
- }
- }
-
- render_section_title_at(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- "spaces",
- p,
- );
- doc_y += 1;
- render_action_row_at(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- "+ new workspace",
- p,
- );
- doc_y += 1;
- let space_entries = workspace_list_entries_expanded(app);
- for (entry_idx, WorkspaceListEntry::Workspace { ws_idx, indented }) in
- space_entries.iter().enumerate()
- {
- let Some(ws) = app.workspaces.get(*ws_idx) else {
- continue;
- };
- let active = Some(*ws_idx) == app.active;
- let selected = *ws_idx == app.selected;
- let bg = mobile_item_bg(selected, active, p);
- let (state, seen) = ws.aggregate_state(&app.terminals);
- let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p);
-
- let mut title_spans = vec![Span::styled(" ", Style::default().bg(bg))];
- // Worktrees of the same space render as branches off their parent, so a
- // child gets an L/T connector on its name row and a matching vertical
- // continuation on its detail row.
- let detail_prefix = if *indented {
- let last_child = !next_entry_is_indented_workspace(&space_entries, entry_idx);
- title_spans.push(Span::styled(
- if last_child { "└─ " } else { "├─ " },
- Style::default().fg(p.overlay0).bg(bg),
- ));
- if last_child {
- " "
- } else {
- " │ "
- }
- } else {
- " "
- };
-
- title_spans.push(Span::styled(dot, dot_style.bg(bg)));
- title_spans.push(Span::styled(" ", Style::default().bg(bg)));
- let raw_label = ws.display_name_from(&app.terminals, terminal_runtimes);
- let name = if *indented {
- grouped_child_display_label(
- &raw_label,
- ws.branch().as_deref(),
- ws.custom_name.is_some(),
- )
- } else {
- raw_label
- };
- let name_budget = content.width.saturating_sub(if *indented { 8 } else { 5 }) as usize;
- title_spans.push(Span::styled(
- truncate_end(&name, name_budget),
- Style::default()
- .fg(p.text)
- .bg(bg)
- .add_modifier(Modifier::BOLD),
- ));
-
- let detail = format!(
- "{detail_prefix}{} · {}",
- ws.branch().unwrap_or_else(|| "shell".into()),
- mobile_tab_status(ws)
- );
- render_two_line_item(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- bg,
- Line::from(title_spans),
- truncate_end(&detail, content.width as usize),
- p.overlay0,
- );
- doc_y += 2;
- }
-
- if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) {
- render_section_title_at(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- "tabs",
- p,
- );
- doc_y += 1;
- render_action_row_at(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- "+ new tab",
- p,
- );
- doc_y += 1;
- for (idx, tab) in ws.tabs.iter().enumerate() {
- let active = idx == ws.active_tab;
- let bg = mobile_item_bg(false, active, p);
- let display_name = ws
- .tab_display_name(idx)
- .unwrap_or_else(|| (idx + 1).to_string());
- let label = if tab.is_auto_named() {
- format!("tab {display_name}")
- } else {
- format!("{} · {display_name}", idx + 1)
- };
- let title = Line::from(vec![
- Span::styled(" ", Style::default().bg(bg)),
- Span::styled(
- truncate_end(&label, content.width.saturating_sub(3) as usize),
- Style::default()
- .fg(p.text)
- .bg(bg)
- .add_modifier(Modifier::BOLD),
- ),
- ]);
- render_one_line_item(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- bg,
- title,
- );
- doc_y += 1;
- }
- }
-
- render_section_title_at(
- frame,
- viewport,
- content,
- doc_y,
- app.mobile_switcher_scroll,
- "menu",
- p,
- );
- doc_y += 1;
- for label in app.global_menu_labels() {
- if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) {
- frame.render_widget(
- Paragraph::new(format!(" {label}"))
- .style(Style::default().fg(p.overlay1).bg(p.panel_bg)),
- Rect::new(content.x, y, content.width, 1),
- );
- }
- doc_y += 1;
- }
-}
-
-fn mobile_agent_detail(entry: &AgentPanelEntry) -> String {
- let mut parts = Vec::new();
- if let Some(tab_label) = entry.primary_tab_label.as_deref() {
- parts.push(tab_label.to_string());
- }
- let status = entry
- .state_labels
- .get(super::sidebar::agent_panel_status_key(
- entry.state,
- entry.seen,
- ))
- .cloned()
- .unwrap_or_else(|| super::status::state_label(entry.state, entry.seen).to_string());
- parts.push(status);
- if let Some(agent_label) = entry.agent_label.as_deref() {
- parts.push(agent_label.to_string());
- }
- format!(" {}", parts.join(" · "))
-}
-
-fn render_section_title_at(
- frame: &mut Frame,
- viewport: Rect,
- content: Rect,
- doc_y: usize,
- scroll: usize,
- title: &str,
- p: &Palette,
-) {
- let Some(y) = visible_y(viewport, scroll, doc_y) else {
- return;
- };
- render_section_title(
- frame,
- Rect::new(content.x, y, content.width.saturating_sub(1), 1),
- title,
- p,
- );
-}
-
-fn render_action_row_at(
- frame: &mut Frame,
- viewport: Rect,
- content: Rect,
- doc_y: usize,
- scroll: usize,
- label: &str,
- p: &Palette,
-) {
- let Some(y) = visible_y(viewport, scroll, doc_y) else {
- return;
- };
- render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p);
-}
-
-fn render_one_line_item(
- frame: &mut Frame,
- viewport: Rect,
- content: Rect,
- doc_y: usize,
- scroll: usize,
- bg: ratatui::style::Color,
- title: Line<'_>,
-) {
- fill_visible_doc_rect(
- frame,
- viewport,
- content,
- doc_y,
- 1,
- Style::default().bg(bg),
- scroll,
- );
- if let Some(y) = visible_y(viewport, scroll, doc_y) {
- frame.render_widget(
- Paragraph::new(title),
- Rect::new(content.x, y, content.width, 1),
- );
- }
-}
-
-fn render_two_line_item(
- frame: &mut Frame,
- viewport: Rect,
- content: Rect,
- doc_y: usize,
- scroll: usize,
- bg: ratatui::style::Color,
- title: Line<'_>,
- detail: String,
- detail_fg: ratatui::style::Color,
-) {
- fill_visible_doc_rect(
- frame,
- viewport,
- content,
- doc_y,
- 2,
- Style::default().bg(bg),
- scroll,
- );
- if let Some(y) = visible_y(viewport, scroll, doc_y) {
- frame.render_widget(
- Paragraph::new(title),
- Rect::new(content.x, y, content.width, 1),
- );
- }
- if let Some(y) = visible_y(viewport, scroll, doc_y + 1) {
- frame.render_widget(
- Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)),
- Rect::new(content.x, y, content.width, 1),
- );
- }
-}
-
-fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option<u16> {
- let offset = doc_y.checked_sub(scroll)?;
- (offset < viewport.height as usize).then_some(viewport.y + offset as u16)
-}
-
-fn fill_visible_doc_rect(
- frame: &mut Frame,
- viewport: Rect,
- content: Rect,
- doc_y: usize,
- height: usize,
- style: Style,
- scroll: usize,
-) {
- for offset in 0..height {
- if let Some(y) = visible_y(viewport, scroll, doc_y + offset) {
- fill_rect(frame, Rect::new(content.x, y, content.width, 1), style);
- }
- }
-}
-
-fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color {
- if selected {
- p.surface0
- } else if active {
- p.surface_dim
- } else {
- p.panel_bg
- }
-}
-
-fn inset_for_left_scrollbar(area: Rect) -> Rect {
- if area.width <= 1 {
- return Rect::default();
- }
- Rect::new(area.x + 1, area.y, area.width - 1, area.height)
-}
-
-fn render_left_scrollbar(
- frame: &mut Frame,
- area: Rect,
- total_rows: usize,
- visible_rows: usize,
- scroll: usize,
- p: &Palette,
-) {
- if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows {
- return;
- }
-
- let track = Rect::new(area.x, area.y, 1, area.height);
- let max_scroll = total_rows.saturating_sub(visible_rows);
- let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows))
- .max(1)
- .min(track.height as usize) as u16;
- let travel = track.height.saturating_sub(thumb_len);
- let thumb_top = track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16;
-
- for y in track.y..track.y + track.height {
- let is_thumb = y >= thumb_top && y < thumb_top + thumb_len;
- frame.buffer_mut()[(track.x, y)]
- .set_symbol(if is_thumb { "▌" } else { "│" })
- .set_style(
- Style::default()
- .fg(if is_thumb { p.accent } else { p.surface_dim })
- .bg(p.panel_bg),
- );
- }
-}
-
-fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
- frame.render_widget(
- Paragraph::new(format!(" {title} ")).style(
- Style::default()
- .fg(p.overlay1)
- .bg(p.panel_bg)
- .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
- ),
- Rect::new(area.x, area.y, area.width, 1),
- );
-}
-
-fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- frame.render_widget(
- Paragraph::new(format!(" {label}")).style(
- Style::default()
- .fg(p.accent)
- .bg(p.panel_bg)
- .add_modifier(Modifier::BOLD),
- ),
- area,
- );
-}
-
-fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
- rect.width > 0
- && rect.height > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
-}
-
-fn mobile_screen_rect(app: &AppState) -> Rect {
- let header = app.view.mobile_header_rect;
- let terminal = app.view.terminal_area;
- let x = header.x.min(terminal.x);
- let y = header.y.min(terminal.y);
- let right = (header.x + header.width).max(terminal.x + terminal.width);
- let bottom = (header.y + header.height).max(terminal.y + terminal.height);
- Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
-}
-
-/// Agent state counts across every workspace. The mobile header is global on
-/// purpose: while you stare at one terminal, a blocked agent anywhere should
-/// still surface.
-#[derive(Debug, Default, Clone, Copy)]
-struct GlobalAgentCounts {
- blocked: usize,
- done: usize,
- working: usize,
- idle: usize,
-}
-
-impl GlobalAgentCounts {
- fn total(&self) -> usize {
- self.blocked + self.done + self.working + self.idle
- }
-
- fn any_pending(&self) -> bool {
- self.blocked > 0 || self.done > 0 || self.working > 0
- }
-}
-
-fn global_agent_counts(app: &AppState) -> GlobalAgentCounts {
- let mut counts = GlobalAgentCounts::default();
- for entry in crate::ui::all_agent_panel_entries(app) {
- match (entry.state, entry.seen) {
- (AgentState::Blocked, _) => counts.blocked += 1,
- (AgentState::Idle, false) => counts.done += 1,
- (AgentState::Working, _) => counts.working += 1,
- (AgentState::Idle, true) => counts.idle += 1,
- (AgentState::Unknown, _) => {}
- }
- }
- counts
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum SummaryTone {
- Blocked,
- Done,
- Working,
- Idle,
- Muted,
-}
-
-/// Ordered, non-zero breakdown for the header roll-up: attention states lead
-/// (blocked → done → working → idle). Pure so it can be unit-tested.
-fn agent_summary_segments(
- counts: GlobalAgentCounts,
- indicator_style: StatusIndicatorStyle,
-) -> Vec<(String, SummaryTone)> {
- if counts.total() == 0 {
- return vec![("no agents".to_string(), SummaryTone::Muted)];
- }
- if !counts.any_pending() {
- return vec![("all idle".to_string(), SummaryTone::Muted)];
- }
- let mut segments = Vec::new();
- if counts.blocked > 0 {
- segments.push((
- agent_summary_text(
- indicator_style,
- AgentState::Blocked,
- true,
- Some("◉"),
- counts.blocked,
- "blocked",
- ),
- SummaryTone::Blocked,
- ));
- }
- if counts.done > 0 {
- segments.push((
- agent_summary_text(
- indicator_style,
- AgentState::Idle,
- false,
- Some("●"),
- counts.done,
- "done",
- ),
- SummaryTone::Done,
- ));
- }
- if counts.working > 0 {
- segments.push((
- agent_summary_text(
- indicator_style,
- AgentState::Working,
- true,
- None,
- counts.working,
- "working",
- ),
- SummaryTone::Working,
- ));
- }
- if counts.idle > 0 {
- segments.push((
- agent_summary_text(
- indicator_style,
- AgentState::Idle,
- true,
- None,
- counts.idle,
- "idle",
- ),
- SummaryTone::Idle,
- ));
- }
- segments
-}
-
-fn agent_summary_text(
- indicator_style: StatusIndicatorStyle,
- state: AgentState,
- seen: bool,
- dot_style_symbol: Option<&str>,
- count: usize,
- label: &str,
-) -> String {
- let symbol = match indicator_style {
- StatusIndicatorStyle::Dots => dot_style_symbol,
- StatusIndicatorStyle::Symbols => Some(state_icon_symbol(state, seen, indicator_style)),
- };
- match symbol {
- Some(symbol) => format!("{symbol} {count} {label}"),
- None => format!("{count} {label}"),
- }
-}
-
-/// Greedily keep the most-urgent segments that fit `max_width` (counting the
-/// leading space and " · " separators) and report whether any were dropped.
-/// Segments are ordered by urgency, so the dropped tail is always the least
-/// important state.
-fn fit_summary_segments(
- segments: Vec<(String, SummaryTone)>,
- max_width: usize,
-) -> (Vec<(String, SummaryTone)>, bool) {
- let mut shown = Vec::new();
- let mut used = 1usize; // leading space
- for (idx, segment) in segments.iter().enumerate() {
- let sep = if idx > 0 { 3 } else { 0 }; // " · "
- let seg_w = segment.0.chars().count();
- if used + sep + seg_w > max_width {
- break;
- }
- used += sep + seg_w;
- shown.push(segment.clone());
- }
- let truncated = shown.len() < segments.len();
- (shown, truncated)
-}
-
-fn agent_summary_line(app: &AppState, p: &Palette, max_width: u16) -> Line<'static> {
- let segments = agent_summary_segments(global_agent_counts(app), app.status_indicators);
- let (shown, truncated) = fit_summary_segments(segments, max_width as usize);
-
- let mut spans = vec![Span::styled(" ", Style::default().bg(p.panel_bg))];
- let mut used = 1usize;
- for (idx, (text, tone)) in shown.into_iter().enumerate() {
- if idx > 0 {
- spans.push(Span::styled(
- " · ",
- Style::default().fg(p.overlay0).bg(p.panel_bg),
- ));
- used += 3;
- }
- // Only the leading (most urgent) segment keeps its state color; the
- // rest stay dim so the urgent count is the loud thing.
- let style = if idx == 0 {
- let color = match tone {
- SummaryTone::Blocked => p.red,
- SummaryTone::Done => p.blue,
- SummaryTone::Working => p.yellow,
- SummaryTone::Idle | SummaryTone::Muted => p.overlay1,
- };
- let style = Style::default().fg(color).bg(p.panel_bg);
- if tone == SummaryTone::Muted {
- style
- } else {
- style.add_modifier(Modifier::BOLD)
- }
- } else {
- Style::default().fg(p.overlay1).bg(p.panel_bg)
- };
- used += text.chars().count();
- spans.push(Span::styled(text, style));
- }
- if truncated && used + 2 <= max_width as usize {
- spans.push(Span::styled(
- " …",
- Style::default().fg(p.overlay0).bg(p.panel_bg),
- ));
- }
- Line::from(spans)
-}
-
-fn mobile_toast_title(toast: &ToastNotification) -> String {
- match toast.kind {
- ToastKind::NeedsAttention => toast
- .title
- .strip_suffix(" needs attention")
- .map(|agent| format!("{agent} waiting"))
- .unwrap_or_else(|| toast.title.clone()),
- ToastKind::Finished => toast
- .title
- .strip_suffix(" finished")
- .map(|agent| format!("{agent} done"))
- .unwrap_or_else(|| toast.title.clone()),
- ToastKind::UpdateInstalled => "update ready".to_string(),
- }
-}
-
-fn fill_rect(frame: &mut Frame, area: Rect, style: Style) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let buf = frame.buffer_mut();
- for y in area.y..area.y + area.height {
- for x in area.x..area.x + area.width {
- buf[(x, y)].set_symbol(" ");
- buf[(x, y)].set_style(style);
- }
- }
-}
-
-fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let buf = frame.buffer_mut();
- for x in area.x..area.x + area.width {
- buf[(x, area.y)]
- .set_symbol("─")
- .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg));
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn agent_entry(primary_tab_label: Option<&str>, agent_label: Option<&str>) -> AgentPanelEntry {
- AgentPanelEntry {
- ws_idx: 0,
- tab_idx: 0,
- 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_kind_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,
- state_labels: std::collections::HashMap::new(),
- tokens: std::collections::HashMap::new(),
- }
- }
-
- #[test]
- fn global_agent_counts_ignore_active_agent_view_filter() {
- let mut app = AppState::test_new();
- app.workspaces = vec![
- crate::workspace::Workspace::test_new("blocked"),
- crate::workspace::Workspace::test_new("working"),
- ];
- app.ensure_test_terminals();
- for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] {
- let pane_id = app.workspaces[ws_idx].tabs[0].root_pane;
- 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(crate::detect::Agent::Claude);
- terminal.state = state;
- }
- app.agent_view_override = Some(crate::api::schema::AgentViewSetParams {
- source: "example.views".to_string(),
- label: None,
- filter: Some(crate::api::schema::AgentViewFilter::Eq {
- field: crate::api::schema::AgentViewField::Builtin(
- crate::api::schema::AgentViewBuiltinField::Status,
- ),
- value: crate::api::schema::AgentViewValue::String("working".to_string()),
- }),
- sort: Vec::new(),
- });
-
- let counts = global_agent_counts(&app);
- assert_eq!(counts.blocked, 1);
- assert_eq!(counts.working, 1);
- }
-
- #[test]
- fn agent_summary_leads_with_attention_states_in_priority_order() {
- let counts = GlobalAgentCounts {
- blocked: 2,
- done: 1,
- working: 2,
- idle: 1,
- };
- let segments = agent_summary_segments(counts, StatusIndicatorStyle::Dots);
- let labels: Vec<&str> = segments.iter().map(|(text, _)| text.as_str()).collect();
- assert_eq!(
- labels,
- vec!["◉ 2 blocked", "● 1 done", "2 working", "1 idle"]
- );
- assert_eq!(segments[0].1, SummaryTone::Blocked);
- }
-
- #[test]
- fn distinct_agent_summary_uses_configured_symbols_for_every_state() {
- let counts = GlobalAgentCounts {
- blocked: 2,
- done: 1,
- working: 2,
- idle: 1,
- };
- let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Symbols)
- .into_iter()
- .map(|(text, _)| text)
- .collect();
- assert_eq!(
- labels,
- ["× 2 blocked", "✓ 1 done", "◐ 2 working", "○ 1 idle"]
- );
- }
-
- #[test]
- fn distinct_status_style_updates_mobile_blocked_badge() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![crate::workspace::Workspace::test_new("blocked")];
- app.ensure_test_terminals();
- app.status_indicators = StatusIndicatorStyle::Symbols;
- let pane_id = app.workspaces[0].tabs[0].root_pane;
- 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(crate::detect::Agent::Claude);
- terminal_state.state = AgentState::Blocked;
-
- let area = Rect::new(0, 0, 12, 2);
- let mut terminal =
- ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
- .unwrap();
- terminal
- .draw(|frame| render_switch_button(&app, frame, area))
- .unwrap();
-
- assert_eq!(
- terminal.backend().buffer()[(area.width - 1, 0)].symbol(),
- "×"
- );
- }
-
- #[test]
- fn agent_summary_hides_empty_categories() {
- let counts = GlobalAgentCounts {
- done: 1,
- working: 2,
- ..Default::default()
- };
- let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Dots)
- .into_iter()
- .map(|(text, _)| text)
- .collect();
- assert_eq!(
- labels,
- vec!["● 1 done".to_string(), "2 working".to_string()]
- );
- }
-
- #[test]
- fn agent_summary_collapses_to_all_idle_without_attention() {
- let counts = GlobalAgentCounts {
- idle: 3,
- ..Default::default()
- };
- assert_eq!(
- agent_summary_segments(counts, StatusIndicatorStyle::Dots),
- vec![("all idle".to_string(), SummaryTone::Muted)]
- );
- }
-
- #[test]
- fn agent_summary_drops_least_urgent_segments_when_narrow() {
- let counts = GlobalAgentCounts {
- blocked: 2,
- done: 1,
- working: 2,
- idle: 1,
- };
- let (shown, truncated) = fit_summary_segments(
- agent_summary_segments(counts, StatusIndicatorStyle::Dots),
- 24,
- );
- let labels: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
- assert_eq!(labels, vec!["◉ 2 blocked", "● 1 done"]);
- assert!(truncated);
- }
-
- #[test]
- fn agent_summary_keeps_all_segments_when_wide_enough() {
- let counts = GlobalAgentCounts {
- blocked: 2,
- done: 1,
- working: 2,
- idle: 1,
- };
- let (shown, truncated) = fit_summary_segments(
- agent_summary_segments(counts, StatusIndicatorStyle::Dots),
- 60,
- );
- assert_eq!(shown.len(), 4);
- assert!(!truncated);
- }
-
- #[test]
- fn agent_summary_reports_no_agents_when_empty() {
- assert_eq!(
- agent_summary_segments(GlobalAgentCounts::default(), StatusIndicatorStyle::Dots,),
- vec![("no agents".to_string(), SummaryTone::Muted)]
- );
- }
-
- #[test]
- fn switcher_leads_with_agents_and_shifts_spaces_below() {
- let mut app = crate::app::state::AppState::test_new();
- let mut workspace = crate::workspace::Workspace::test_new("agents-first");
- workspace.test_add_tab(None); // two tabs -> two agent panes
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- for terminal in app.terminals.values_mut() {
- terminal.agent_name = Some("pi".to_string());
- terminal.state = AgentState::Working;
- }
- app.active = Some(0);
- app.selected = 0;
- app.view.mobile_header_rect = Rect::new(0, 0, 40, 2);
- app.view.terminal_area = Rect::new(0, 2, 40, 18);
-
- assert_eq!(agent_panel_entries(&app).len(), 2);
- // agents title (1) + 2 agents * 2 rows = 5, then spaces title + "new
- // workspace" (2) before the first workspace ribbon at doc row 7.
- assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7);
-
- let viewport = mobile_switcher_areas(&app).viewport;
- app.mobile_switcher_scroll = 100;
- let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1);
- assert!(matches!(
- agent_hit,
- Some(MobileSwitcherTarget::Agent { .. })
- ));
- let workspace_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 7);
- assert_eq!(workspace_hit, Some(MobileSwitcherTarget::Workspace(0)));
- }
-
- fn worktree_workspace(name: &str, key: &str, linked: bool) -> crate::workspace::Workspace {
- let mut ws = crate::workspace::Workspace::test_new(name);
- 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(format!("/repo/{name}")),
- is_linked_worktree: linked,
- });
- ws
- }
-
- #[test]
- fn switcher_spaces_follow_grouped_worktree_order() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![
- worktree_workspace("main", "repo-key", false),
- crate::workspace::Workspace::test_new("other"),
- worktree_workspace("feature", "repo-key", true),
- ];
- app.active = Some(0);
- app.selected = 0;
- app.view.mobile_header_rect = Rect::new(0, 0, 40, 2);
- app.view.terminal_area = Rect::new(0, 2, 40, 18);
-
- // Grouped order pulls the worktree (idx 2) up under its parent (idx 0),
- // ahead of the unrelated "other" workspace (idx 1): rows are main,
- // feature, other.
- assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4);
- assert_eq!(mobile_switcher_workspace_doc_range(&app, 1).start, 6);
-
- let viewport = mobile_switcher_areas(&app).viewport;
- // The second space row on screen is the worktree, not workspaces[1].
- let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4);
- assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2)));
-
- // Mobile ignores collapse: even with the space folded on desktop, the
- // worktree child still renders in the same position.
- app.collapsed_space_keys.insert("repo-key".to_string());
- assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4);
- let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4);
- assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2)));
- }
-
- #[test]
- fn switcher_without_agents_keeps_spaces_first() {
- let mut app = crate::app::state::AppState::test_new();
- app.workspaces = vec![crate::workspace::Workspace::test_new("shell-only")];
- app.active = Some(0);
- app.selected = 0;
-
- // No attached terminals -> no agents -> no agents header, spaces lead.
- assert_eq!(agent_panel_entries(&app).len(), 0);
- assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 2);
- }
-
- #[test]
- fn mobile_agent_detail_includes_tab_context_when_available() {
- let entry = agent_entry(Some("mobile-state"), Some("pi"));
-
- assert_eq!(mobile_agent_detail(&entry), " mobile-state · idle · pi");
- }
-
- #[test]
- fn mobile_agent_detail_keeps_existing_compact_detail_without_tab_context() {
- let entry = agent_entry(None, Some("pi"));
-
- assert_eq!(mobile_agent_detail(&entry), " idle · pi");
- }
-
- #[test]
- fn mobile_tab_status_uses_compact_tab_label_and_position() {
- let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs");
- let removed_tab = workspace.test_add_tab(None);
- workspace.test_add_tab(None);
- assert!(workspace.close_tab(removed_tab));
- workspace.active_tab = 1;
-
- assert_eq!(mobile_tab_status(&workspace), "tab 2 · 2/2");
- }
-
- #[test]
- fn mobile_switcher_uses_compact_tab_label_for_auto_tab_labels() {
- let mut app = crate::app::state::AppState::test_new();
- let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs");
- let removed_tab = workspace.test_add_tab(None);
- workspace.test_add_tab(None);
- assert!(workspace.close_tab(removed_tab));
- app.workspaces = vec![workspace];
- app.ensure_test_terminals();
- app.active = Some(0);
- app.selected = 0;
- app.view.mobile_header_rect = Rect::new(0, 0, 40, 2);
- app.view.terminal_area = Rect::new(0, 2, 40, 18);
-
- let backend = ratatui::backend::TestBackend::new(40, 20);
- let mut terminal = ratatui::Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| {
- render_mobile_panel(
- &app,
- &TerminalRuntimeRegistry::new(),
- frame,
- Rect::new(0, 0, 40, 20),
- )
- })
- .unwrap();
-
- let row = (0..40)
- .map(|x| terminal.backend().buffer()[(x, 10)].symbol())
- .collect::<String>();
-
- assert!(row.contains("tab 2"), "mobile tab row: {row:?}");
- assert!(!row.contains("tab 3"), "mobile tab row: {row:?}");
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() {
- let unique = format!(
- "herdr-mobile-header-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 = crate::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();
- app.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd;
- app.active = Some(0);
- app.selected = 0;
- app.view.mobile_menu_hit_area = Rect::new(30, 0, 10, 2);
-
- 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 backend = ratatui::backend::TestBackend::new(40, 2);
- let mut terminal = ratatui::Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| {
- render_mobile_header(&app, &runtime_registry, frame, Rect::new(0, 0, 40, 2))
- })
- .unwrap();
- let row = (0..40)
- .map(|x| terminal.backend().buffer()[(x, 0)].symbol())
- .collect::<String>();
-
- for (_, runtime) in runtime_registry.drain() {
- runtime.shutdown();
- }
- let _ = std::fs::remove_dir_all(root);
-
- assert!(row.contains("herdr"), "header row: {row:?}");
- assert!(
- !row.contains("issue-264-nix-support"),
- "header row: {row:?}"
- );
- }
-}
diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs
deleted file mode 100644
index cb2f235e..00000000
--- a/src/ui/navigator.rs
+++ /dev/null
@@ -1,643 +0,0 @@
-use ratatui::{
- layout::Rect,
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Clear, Paragraph},
- Frame,
-};
-
-use super::{
- scrollbar::{render_scrollbar, should_show_scrollbar},
- status::{state_icon, state_label_color},
- text::{display_width_u16, middle_elide, truncate_end},
- widgets::{panel_contrast_fg, render_panel_shell},
-};
-use crate::app::state::{
- navigator_display_lines, AppState, NavigatorDisplayLine, NavigatorRow, NavigatorStateFilter,
- NavigatorTarget,
-};
-use crate::terminal::TerminalRuntimeRegistry;
-
-pub(super) fn render_navigator_overlay(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
-) {
- let popup = app.navigator_popup_rect();
- let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg)
- else {
- return;
- };
-
- let search = app.navigator_search_rect();
- let body = app.navigator_body_rect();
- let detail = app.navigator_detail_rect();
- let footer = app.navigator_footer_rect();
- render_search(app, frame, search);
-
- if body.height > 0 {
- let rows = app.navigator_rows_from(terminal_runtimes);
- let lines = navigator_display_lines(&rows);
- render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app);
- render_rows(app, &rows, &lines, frame, body);
- render_navigator_scrollbar(app, lines.len(), frame, body);
- }
- render_detail(app, terminal_runtimes, frame, detail);
- render_footer(app, frame, footer);
-}
-
-fn render_search(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
- let focus_style = if app.navigator.search_focused {
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.overlay0)
- };
- let count = app
- .workspaces
- .iter()
- .flat_map(|workspace| workspace.tabs.iter())
- .map(|tab| tab.panes.len())
- .sum::<usize>();
- let mut spans = vec![Span::styled(" / ", focus_style)];
- let query = app.navigator.query.trim();
- match app.navigator.state_filter {
- Some(NavigatorStateFilter::Blocked) => push_state_chip(
- &mut spans,
- crate::detect::AgentState::Blocked,
- true,
- "blocked",
- app,
- ),
- Some(NavigatorStateFilter::Working) => push_state_chip(
- &mut spans,
- crate::detect::AgentState::Working,
- true,
- "working",
- app,
- ),
- Some(NavigatorStateFilter::Idle) => push_state_chip(
- &mut spans,
- crate::detect::AgentState::Idle,
- true,
- "idle",
- app,
- ),
- Some(NavigatorStateFilter::Done) => push_state_chip(
- &mut spans,
- crate::detect::AgentState::Idle,
- false,
- "done",
- app,
- ),
- None if query.is_empty() => spans.push(Span::styled(
- "search panes",
- Style::default().fg(p.overlay0),
- )),
- None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))),
- }
- spans.push(Span::styled(
- format!(
- "{count:>width$} panes",
- width = area.width.saturating_sub(16) as usize
- ),
- Style::default().fg(p.overlay0),
- ));
- frame.render_widget(Paragraph::new(Line::from(spans)), area);
-}
-
-fn push_state_chip(
- spans: &mut Vec<Span<'static>>,
- state: crate::detect::AgentState,
- seen: bool,
- label: &'static str,
- app: &AppState,
-) {
- let (icon, icon_style) = state_icon(state, seen, app.status_indicators, &app.palette);
- spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD)));
- spans.push(Span::raw(" "));
- spans.push(Span::styled(
- label,
- Style::default()
- .fg(state_label_color(state, seen, &app.palette))
- .add_modifier(Modifier::BOLD),
- ));
-}
-
-fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) {
- if area.height == 0 || area.width == 0 {
- return;
- }
- let line = "─".repeat(area.width as usize);
- frame.render_widget(
- Paragraph::new(line).style(Style::default().fg(app.palette.surface1)),
- area,
- );
-}
-
-fn render_rows(
- app: &AppState,
- rows: &[NavigatorRow],
- lines: &[NavigatorDisplayLine],
- frame: &mut Frame,
- body: Rect,
-) {
- let start = app.navigator.scroll.min(lines.len());
- let end = lines.len().min(start.saturating_add(body.height as usize));
- for (visible_idx, line) in lines[start..end].iter().enumerate() {
- let NavigatorDisplayLine::Row(idx) = *line else {
- continue;
- };
- let y = body.y + visible_idx as u16;
- let rect = Rect::new(body.x, y, body.width, 1);
- let selected = idx == app.navigator.selected;
- render_row(app, frame, rect, rows, idx, selected);
- }
-}
-
-fn render_row(
- app: &AppState,
- frame: &mut Frame,
- rect: Rect,
- rows: &[NavigatorRow],
- idx: usize,
- selected: bool,
-) {
- let row = &rows[idx];
- let p = &app.palette;
- frame.render_widget(Clear, rect);
- let base_style = if selected {
- Style::default().bg(p.accent).fg(panel_contrast_fg(p))
- } else {
- Style::default().bg(p.panel_bg).fg(p.text)
- };
- let dim_style = if selected {
- base_style
- } else {
- Style::default().fg(p.overlay0).bg(p.panel_bg)
- };
- let filter_active =
- app.navigator.state_filter.is_some() || !app.navigator.query.trim().is_empty();
- let context_only = filter_active && !row.matched;
- let text_style = if selected {
- base_style.add_modifier(Modifier::BOLD)
- } else if context_only {
- let dimmed = Style::default().fg(p.overlay0).bg(p.panel_bg);
- if row.is_workspace {
- dimmed.add_modifier(Modifier::BOLD)
- } else {
- dimmed
- }
- } else if row.is_workspace {
- Style::default()
- .fg(p.accent)
- .bg(p.panel_bg)
- .add_modifier(Modifier::BOLD)
- } else if row.is_current {
- Style::default()
- .fg(p.text)
- .bg(p.panel_bg)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.subtext0).bg(p.panel_bg)
- };
- let (status_icon, status_style) = state_icon(row.status, row.seen, app.status_indicators, p);
- let status_style = if selected {
- base_style.add_modifier(Modifier::BOLD)
- } else if context_only {
- Style::default().fg(p.overlay0).bg(p.panel_bg)
- } else {
- status_style.bg(p.panel_bg)
- };
-
- let prefix = tree_prefix(rows, idx);
- let current = if row.is_current { "◆" } else { " " };
- let gutter = format!(" {current} ");
- let gutter_style = if selected {
- base_style
- } else if row.is_current {
- Style::default().fg(p.accent).bg(p.panel_bg)
- } else {
- dim_style
- };
- // Branch glyphs recede one shade below the workspace caret so the
- // structure stays behind the labels.
- let tree_style = if selected {
- base_style
- } else if row.is_workspace {
- dim_style
- } else {
- Style::default().fg(p.surface1).bg(p.panel_bg)
- };
- let meta_width = metadata_width(rect.width);
- let left_budget = rect
- .width
- .saturating_sub(meta_width)
- .saturating_sub(display_width_u16(&format!("{gutter}{prefix} ")))
- .saturating_sub(3) as usize;
- let title = truncate_end(&row.label, left_budget);
-
- let spans = vec![
- Span::styled(gutter, gutter_style),
- Span::styled(prefix, tree_style),
- Span::styled(" ", base_style),
- Span::styled(status_icon, status_style),
- Span::raw(" "),
- Span::styled(title, text_style),
- ];
- frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect);
-
- if meta_width > 0 {
- let meta_rect = Rect::new(
- rect.x + rect.width.saturating_sub(meta_width),
- rect.y,
- meta_width,
- 1,
- );
- let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize);
- let meta_style = if selected {
- base_style
- } else if context_only || row.is_workspace || row.is_tab {
- Style::default().fg(p.overlay0).bg(p.panel_bg)
- } else {
- Style::default()
- .fg(state_label_color(row.status, row.seen, p))
- .bg(p.panel_bg)
- };
- frame.render_widget(
- Paragraph::new(format!(" {meta}")).style(meta_style),
- meta_rect,
- );
- }
-}
-
-/// Tree prefix for a navigator row: expand caret for workspaces, connected
-/// branch glyphs for children (`├──`, `└──` for the last sibling, with `│`
-/// continuation lines under ancestors that have more siblings below).
-fn tree_prefix(rows: &[NavigatorRow], idx: usize) -> String {
- let row = &rows[idx];
- if row.is_workspace {
- return if row.expanded { "▾" } else { "▸" }.to_string();
- }
- if row.depth == 0 {
- return " ".to_string();
- }
- let mut prefix = String::new();
- for level in 1..row.depth {
- prefix.push_str(if has_following_sibling_at_depth(rows, idx, level) {
- "│ "
- } else {
- " "
- });
- }
- prefix.push_str(if has_following_sibling_at_depth(rows, idx, row.depth) {
- "├──"
- } else {
- "└──"
- });
- prefix
-}
-
-/// Whether another row at `depth` follows `idx` before the subtree at that
-/// depth ends (a row shallower than `depth` closes the subtree).
-fn has_following_sibling_at_depth(rows: &[NavigatorRow], idx: usize, depth: u8) -> bool {
- rows[idx + 1..]
- .iter()
- .take_while(|row| row.depth >= depth)
- .any(|row| row.depth == depth)
-}
-
-fn render_navigator_scrollbar(app: &AppState, line_count: usize, frame: &mut Frame, body: Rect) {
- if body.width <= 1 || body.height == 0 {
- return;
- }
- let viewport = body.height as usize;
- if line_count <= viewport {
- return;
- }
- let metrics = crate::pane::ScrollMetrics {
- viewport_rows: viewport,
- offset_from_bottom: line_count
- .saturating_sub(viewport)
- .saturating_sub(app.navigator.scroll),
- max_offset_from_bottom: line_count.saturating_sub(viewport),
- };
- if !should_show_scrollbar(metrics) {
- return;
- }
- let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height);
- render_scrollbar(
- frame,
- metrics,
- track,
- app.palette.surface_dim,
- app.palette.overlay0,
- "▕",
- );
-}
-
-fn metadata_width(width: u16) -> u16 {
- if width >= 90 {
- 28
- } else if width >= 68 {
- 20
- } else if width >= 52 {
- 14
- } else {
- 0
- }
-}
-
-fn render_detail(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- if area.height == 0 || area.width == 0 {
- return;
- }
- render_separator(frame, area, app);
- let detail = selected_detail(app, terminal_runtimes);
- if detail.is_empty() {
- return;
- }
- let text = middle_elide(&detail, area.width.saturating_sub(2) as usize);
- frame.render_widget(
- Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)),
- area,
- );
-}
-
-fn selected_detail(app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry) -> String {
- let rows = app.navigator_rows_from(terminal_runtimes);
- let Some(row) = rows.get(app.navigator.selected) else {
- return String::new();
- };
- match row.target {
- NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, terminal_runtimes, ws_idx),
- NavigatorTarget::Tab { ws_idx, tab_idx } => {
- tab_detail(app, terminal_runtimes, ws_idx, tab_idx)
- }
- NavigatorTarget::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- } => pane_detail(app, terminal_runtimes, ws_idx, tab_idx, pane_id),
- }
-}
-
-fn workspace_detail(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- ws_idx: usize,
-) -> String {
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return String::new();
- };
- let label = ws.display_name_from(&app.terminals, terminal_runtimes);
- let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>();
- let mut parts = vec![label, format!("{pane_count} panes")];
- if !rowless_workspace_activity(app, terminal_runtimes, ws_idx).is_empty() {
- parts.push(rowless_workspace_activity(app, terminal_runtimes, ws_idx));
- }
- parts.join(" · ")
-}
-
-fn tab_detail(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- ws_idx: usize,
- tab_idx: usize,
-) -> String {
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return String::new();
- };
- let Some(tab) = ws.tabs.get(tab_idx) else {
- return String::new();
- };
- let mut parts = vec![
- ws.display_name_from(&app.terminals, terminal_runtimes),
- format!(
- "tab: {}",
- ws.tab_display_name(tab_idx)
- .unwrap_or_else(|| (tab_idx + 1).to_string())
- ),
- format!("{} panes", tab.panes.len()),
- ];
- let rows = app.navigator_rows_from(terminal_runtimes);
- if let Some(meta) = rows
- .into_iter()
- .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx))
- .map(|row| row.meta)
- .filter(|meta| !meta.is_empty())
- {
- parts.push(meta);
- }
- parts.join(" · ")
-}
-
-fn pane_detail(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- ws_idx: usize,
- tab_idx: usize,
- pane_id: crate::layout::PaneId,
-) -> String {
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return String::new();
- };
- let Some(tab) = ws.tabs.get(tab_idx) else {
- return String::new();
- };
- let mut parts = vec![ws.display_name_from(&app.terminals, terminal_runtimes)];
- if ws.tabs.len() > 1 {
- parts.push(format!(
- "tab: {}",
- ws.tab_display_name(tab_idx)
- .unwrap_or_else(|| (tab_idx + 1).to_string())
- ));
- }
- if let Some(pane_number) = ws.public_pane_number(pane_id) {
- parts.push(format!("pane {pane_number}"));
- }
- if let Some(terminal_id) = tab.terminal_id(pane_id) {
- if let Some(terminal) = app.terminals.get(terminal_id) {
- let presentation = terminal.effective_presentation();
- if let Some(title) = presentation.title {
- parts.push(title);
- }
- let display_agent = terminal.effective_display_agent();
- if let Some(agent) = display_agent.as_deref().or_else(|| {
- terminal
- .agent_name
- .as_deref()
- .or_else(|| terminal.effective_agent_label())
- }) {
- parts.push(agent.to_string());
- let seen = tab
- .panes
- .get(&pane_id)
- .map(|pane| pane.seen)
- .unwrap_or(true);
- let state = row_state(app, ws_idx, tab_idx, pane_id);
- let status = presentation
- .state_labels
- .get(display_state(state, seen))
- .cloned()
- .unwrap_or_else(|| display_state(state, seen).to_string());
- parts.push(status);
- } else {
- parts.push("shell".to_string());
- }
- }
- }
- parts.join(" · ")
-}
-
-fn rowless_workspace_activity(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- ws_idx: usize,
-) -> String {
- app.navigator_rows_from(terminal_runtimes)
- .into_iter()
- .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx))
- .map(|row| row.meta)
- .unwrap_or_default()
-}
-
-fn row_state(
- app: &AppState,
- ws_idx: usize,
- tab_idx: usize,
- pane_id: crate::layout::PaneId,
-) -> crate::detect::AgentState {
- app.workspaces
- .get(ws_idx)
- .and_then(|ws| ws.tabs.get(tab_idx))
- .and_then(|tab| tab.terminal_id(pane_id))
- .and_then(|terminal_id| app.terminals.get(terminal_id))
- .map(|terminal| terminal.state)
- .unwrap_or(crate::detect::AgentState::Unknown)
-}
-
-fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str {
- match (state, seen) {
- (crate::detect::AgentState::Blocked, _) => "blocked",
- (crate::detect::AgentState::Working, _) => "working",
- (crate::detect::AgentState::Idle, false) => "done",
- (crate::detect::AgentState::Idle, true) => "idle",
- (crate::detect::AgentState::Unknown, _) => "unknown",
- }
-}
-
-fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.height == 0 {
- return;
- }
- let p = &app.palette;
- let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(p.overlay0);
- let line = if app.navigator.search_focused {
- Line::from(vec![
- Span::styled(" enter", key),
- Span::styled(" switch ", dim),
- Span::styled("↑↓", key),
- Span::styled(" move ", dim),
- Span::styled("ctrl+u", key),
- Span::styled(" clear ", dim),
- Span::styled("esc", key),
- Span::styled(" back", dim),
- ])
- } else {
- Line::from(vec![
- Span::styled(" enter", key),
- Span::styled(" switch ", dim),
- Span::styled("/", key),
- Span::styled(" search ", dim),
- Span::styled("b/w/i/d/a", key),
- Span::styled(" states ", dim),
- Span::styled("j/k/↑↓", key),
- Span::styled(" move ", dim),
- Span::styled("esc", key),
- Span::styled(" close", dim),
- ])
- };
- frame.render_widget(Paragraph::new(line), area);
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::detect::AgentState;
-
- fn row(depth: u8, is_workspace: bool) -> NavigatorRow {
- NavigatorRow {
- target: NavigatorTarget::Workspace { ws_idx: 0 },
- depth,
- label: String::new(),
- meta: String::new(),
- status: AgentState::Idle,
- seen: true,
- is_current: false,
- is_workspace,
- is_tab: false,
- expanded: true,
- search_text: String::new(),
- matched: true,
- }
- }
-
- fn multi_tab_rows() -> Vec<NavigatorRow> {
- vec![
- row(0, true), // workspace
- row(1, false), // tab a
- row(2, false), // pane
- row(2, false), // pane (last in tab a)
- row(1, false), // tab b (last tab)
- row(2, false), // pane (last in tab b)
- row(0, true), // workspace
- row(1, false), // pane (single child)
- ]
- }
-
- #[test]
- fn workspace_rows_use_expand_caret() {
- let rows = multi_tab_rows();
- assert_eq!(tree_prefix(&rows, 0), "▾");
- let mut collapsed = rows.clone();
- collapsed[0].expanded = false;
- assert_eq!(tree_prefix(&collapsed, 0), "▸");
- }
-
- #[test]
- fn middle_children_get_branch_glyph() {
- let rows = multi_tab_rows();
- assert_eq!(tree_prefix(&rows, 1), "├──");
- assert_eq!(tree_prefix(&rows, 2), "│ ├──");
- }
-
- #[test]
- fn last_children_get_terminator_glyph() {
- let rows = multi_tab_rows();
- assert_eq!(tree_prefix(&rows, 3), "│ └──");
- assert_eq!(tree_prefix(&rows, 4), "└──");
- assert_eq!(tree_prefix(&rows, 7), "└──");
- }
-
- #[test]
- fn spine_stops_after_last_ancestor_sibling() {
- let rows = multi_tab_rows();
- assert_eq!(tree_prefix(&rows, 5), " └──");
- }
-
- #[test]
- fn next_workspace_does_not_extend_previous_subtree() {
- // The pane at idx 5 is last in its workspace even though another
- // workspace with children follows.
- let rows = multi_tab_rows();
- assert!(!has_following_sibling_at_depth(&rows, 5, 1));
- assert!(!has_following_sibling_at_depth(&rows, 5, 2));
- }
-}
diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs
index 8829acc7..94c9b2a0 100644
--- a/src/ui/onboarding.rs
+++ b/src/ui/onboarding.rs
@@ -1,114 +1,19 @@
-use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::Paragraph,
- Frame,
-};
-
-use super::widgets::{
- action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button,
- render_modal_shell,
-};
-use crate::app::AppState;
-
-const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
-
-pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- super::dim_background(frame, area);
- render_onboarding_welcome(app, frame, area);
-}
+use ratatui::layout::Rect;
+
+pub(crate) const ONBOARDING_TITLE: &str = " herdr";
+pub(crate) const ONBOARDING_SUBTITLE: &str = " terminal workspace manager for coding agents";
+pub(crate) const ONBOARDING_DESCRIPTION: [&str; 3] = [
+ " this is a mouse-first terminal.",
+ " click the sidebar to switch workspaces, drag pane",
+ " borders to resize, right-click for context menus.",
+];
+pub(crate) const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
+pub(crate) const ONBOARDING_PREFIX_SUFFIX: &str = " enters prefix mode · ";
+pub(crate) const ONBOARDING_HELP_LABEL: &str = "?";
+pub(crate) const ONBOARDING_HELP_SUFFIX: &str = " shows keybinds and settings";
+pub(crate) const ONBOARDING_NEXT: &str =
+ " next: install optional agent integrations for more reliable state";
pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect {
- Rect::new(
- area.x,
- area.y,
- action_button_width(Some("↵"), "continue"),
- 1,
- )
-}
-
-fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else {
- return;
- };
- if inner.height < 11 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 0, 1, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
- let content_rows = Layout::vertical([
- Constraint::Length(3),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<4>(stack.content);
-
- frame.render_widget(
- Paragraph::new(" herdr").style(
- Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD),
- ),
- header_rows[0],
- );
- frame.render_widget(
- Paragraph::new(" terminal workspace manager for coding agents")
- .style(Style::default().fg(app.palette.overlay0)),
- header_rows[1],
- );
-
- frame.render_widget(
- Paragraph::new(
- " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.",
- )
- .style(Style::default().fg(app.palette.overlay1)),
- content_rows[0],
- );
-
- let key_line = Line::from(vec![
- Span::styled(" ", Style::default()),
- Span::styled(
- ONBOARDING_PREFIX_LABEL,
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " enters prefix mode · ",
- Style::default().fg(app.palette.overlay1),
- ),
- Span::styled(
- "?",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " shows keybinds and settings",
- Style::default().fg(app.palette.overlay1),
- ),
- ]);
- frame.render_widget(Paragraph::new(key_line), content_rows[2]);
-
- frame.render_widget(
- Paragraph::new(" next: install optional agent integrations for more reliable state")
- .style(Style::default().fg(app.palette.overlay1)),
- content_rows[3],
- );
-
- let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
- render_action_button(
- frame,
- continue_rect,
- Some("↵"),
- "continue",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
+ super::widgets::continue_button_rect(area)
}
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index f20b55b5..cd5ea34c 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -1,8 +1,8 @@
use ratatui::{
+ buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
- text::{Line, Span},
- widgets::{Block, Borders, Clear, Paragraph},
+ widgets::{Block, Borders},
Frame,
};
@@ -12,7 +12,7 @@ use super::text::display_width;
use super::text::truncate_end;
use super::widgets::panel_contrast_fg;
use crate::app::state::Palette;
-use crate::app::{AppState, Mode};
+use crate::app::AppState;
use crate::layout::PaneInfo;
use crate::popup_size::resolve_popup_geometry;
use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry};
@@ -352,60 +352,13 @@ pub(super) fn render_panes(
return;
};
- let multi_pane = ws.layout.pane_count() > 1;
- let terminal_active = app.mode == Mode::Terminal;
-
for info in pane_infos {
if let Some(rt) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) {
let show_cursor = info.is_focused
- && terminal_active
&& !pane_is_scrolled_back(rt)
&& app.pane_exposes_host_cursor(ws_idx, info.id);
rt.render(frame, info.inner_rect, show_cursor);
render_pane_scrollbar(app, frame, info, rt);
-
- let should_dim = !info.is_focused && multi_pane && !terminal_active;
- if should_dim {
- let inner = info.inner_rect;
- let buf = frame.buffer_mut();
- for y in inner.y..inner.y + inner.height {
- for x in inner.x..inner.x + inner.width {
- let cell = &mut buf[(x, y)];
- cell.set_style(cell.style().add_modifier(Modifier::DIM));
- }
- }
- }
-
- let (copy_search_top, copy_search_bottom, copy_search_matches) =
- validated_copy_mode_search_matches(app, info, rt);
- render_copy_mode_search_highlights(
- app,
- frame,
- info,
- copy_search_top,
- copy_search_bottom,
- &copy_search_matches,
- false,
- );
- render_selection_highlight(
- &app.selection,
- frame,
- info.id,
- info.inner_rect,
- rt.scroll_metrics(),
- &app.palette,
- app.host_terminal_theme,
- );
- render_copy_mode_search_highlights(
- app,
- frame,
- info,
- copy_search_top,
- copy_search_bottom,
- &copy_search_matches,
- true,
- );
- render_copy_mode_cursor(app, frame, info);
}
}
@@ -443,36 +396,6 @@ pub(super) fn resize_popup_pane(
}
}
-pub(super) fn render_popup_pane(
- app: &AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- frame: &mut Frame,
- area: Rect,
-) {
- let Some(popup) = app.popup_pane.as_ref() else {
- return;
- };
- let Some((outer, inner)) = popup_pane_rects(app, area) else {
- return;
- };
- let Some(rt) = terminal_runtimes.get(&popup.terminal_id) else {
- return;
- };
- let title = app
- .terminals
- .get(&popup.terminal_id)
- .and_then(|terminal| terminal.manual_label.as_deref())
- .unwrap_or("popup");
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(app.palette.accent))
- .title(pane_border_title(title, outer.width, true).unwrap_or_default())
- .style(Style::default().bg(app.palette.panel_bg));
- frame.render_widget(Clear, outer);
- frame.render_widget(block, outer);
- rt.render(frame, inner, !pane_is_scrolled_back(rt));
-}
-
#[derive(Clone, Copy, Default)]
struct LineCell {
up: bool,
@@ -724,141 +647,25 @@ fn line_cell_symbol(line: LineCell) -> &'static str {
}
}
-fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) {
- if app.mode != Mode::Copy {
- return;
- }
- let Some(copy_mode) = app.copy_mode.as_ref() else {
- return;
- };
- if copy_mode.pane_id != info.id
- || copy_mode.cursor_row >= info.inner_rect.height
- || copy_mode.cursor_col >= info.inner_rect.width
- {
- return;
- }
-
- let x = info.inner_rect.x + copy_mode.cursor_col;
- let y = info.inner_rect.y + copy_mode.cursor_row;
- let cell = &mut frame.buffer_mut()[(x, y)];
- cell.set_style(
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-fn validated_copy_mode_search_matches(
- app: &AppState,
- info: &PaneInfo,
- rt: &crate::terminal::TerminalRuntime,
-) -> (u32, u32, Vec<(usize, crate::pane::TerminalTextMatch)>) {
- let Some(copy_mode) = app.copy_mode.as_ref() else {
- return (0, 0, Vec::new());
- };
- if copy_mode.pane_id != info.id {
- return (0, 0, Vec::new());
- }
- let Some(metrics) = rt.scroll_metrics() else {
- return (0, 0, Vec::new());
- };
- let top = metrics
- .max_offset_from_bottom
- .saturating_sub(metrics.offset_from_bottom)
- .min(u32::MAX as usize) as u32;
- let bottom = top.saturating_add(u32::from(info.inner_rect.height.saturating_sub(1)));
- let first_visible = copy_mode
- .search
- .matches
- .partition_point(|text_match| text_match.end.row < top);
- let visible = &copy_mode.search.matches[first_visible..];
- let visible_len = visible.partition_point(|text_match| text_match.start.row <= bottom);
- let candidates = visible[..visible_len].to_vec();
- let validity = rt.text_matches_are_current(&candidates);
-
- let matches = candidates
- .into_iter()
- .zip(validity)
- .enumerate()
- .filter_map(|(offset, (text_match, is_current))| {
- is_current.then_some((first_visible + offset, text_match))
- })
- .collect();
- (top, bottom, matches)
-}
-
-fn render_copy_mode_search_highlights(
- app: &AppState,
- frame: &mut Frame,
- info: &PaneInfo,
- top: u32,
- bottom: u32,
- matches: &[(usize, crate::pane::TerminalTextMatch)],
- current_only: bool,
-) {
- let Some(copy_mode) = app.copy_mode.as_ref() else {
- return;
- };
- let current = copy_mode.search.current;
- let style = if current_only {
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface1)
- };
-
- for &(index, text_match) in matches {
- if (current == Some(index)) != current_only {
- continue;
- }
- let start_row = text_match.start.row.max(top);
- let end_row = text_match.end.row.min(bottom);
- for absolute_row in start_row..=end_row {
- let viewport_row = absolute_row.saturating_sub(top) as u16;
- let start_col = if absolute_row == text_match.start.row {
- text_match.start.col
- } else {
- 0
- };
- let end_col = if absolute_row == text_match.end.row {
- text_match.end.col
- } else {
- info.inner_rect.width.saturating_sub(1)
- };
- for col in start_col..=end_col.min(info.inner_rect.width.saturating_sub(1)) {
- let x = info.inner_rect.x.saturating_add(col);
- let y = info.inner_rect.y.saturating_add(viewport_row);
- frame.buffer_mut()[(x, y)].set_style(style);
- }
- }
- }
-}
-
-fn render_selection_highlight(
- selection: &Option<crate::selection::Selection>,
- frame: &mut Frame,
- pane_id: crate::layout::PaneId,
+pub(crate) fn render_selection_highlight<P: PartialEq>(
+ selection: Option<&crate::selection::Selection<P>>,
+ buffer: &mut Buffer,
+ pane_id: &P,
inner: Rect,
scroll_metrics: Option<crate::pane::ScrollMetrics>,
p: &Palette,
host_theme: crate::terminal_theme::TerminalTheme,
) {
- if let Some(sel) = selection {
- if sel.is_visible() && sel.pane_id == pane_id {
- let buf = frame.buffer_mut();
- let style = automatic_selection_style(p, host_theme);
- for y in 0..inner.height {
- for x in 0..inner.width {
- if sel.contains(y, x, scroll_metrics) {
- let cell = &mut buf[(inner.x + x, inner.y + y)];
- cell.set_style(style);
- }
- }
+ let Some(selection) =
+ selection.filter(|selection| selection.is_visible() && &selection.pane_id == pane_id)
+ else {
+ return;
+ };
+ let style = automatic_selection_style(p, host_theme);
+ for y in 0..inner.height {
+ for x in 0..inner.width {
+ if selection.contains(y, x, scroll_metrics) {
+ buffer[(inner.x + x, inner.y + y)].set_style(style);
}
}
}
@@ -959,47 +766,6 @@ fn color_to_rgb(color: Color) -> Option<Rgb> {
}
}
-pub(super) fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
- let lines = vec![
- Line::from(""),
- Line::from(""),
- Line::from(Span::styled(
- " No workspaces yet",
- Style::default().fg(p.overlay0),
- )),
- Line::from(""),
- Line::from(Span::styled(
- " A workspace is one project context.",
- Style::default().fg(p.overlay1),
- )),
- Line::from(Span::styled(
- " Its root pane (top-left) sets the default repo or folder name.",
- Style::default().fg(p.overlay1),
- )),
- Line::from(""),
- Line::from(vec![
- Span::styled(" Press ", Style::default().fg(p.overlay0)),
- Span::styled(
- app.keybinds
- .new_workspace
- .label()
- .unwrap_or_else(|| "unset".to_string()),
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- Span::styled(" to create one", Style::default().fg(p.overlay0)),
- ]),
- ];
- frame.render_widget(
- Paragraph::new(lines).block(
- Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(p.surface_dim)),
- ),
- area,
- );
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -1009,14 +775,13 @@ mod tests {
use crate::terminal::TerminalState;
use crate::workspace::Workspace;
- fn render_view_pane_borders(app: &AppState, ws: &Workspace, frame: &mut Frame) {
- render_pane_borders(
- app,
- ws,
- &app.view.pane_infos,
- &app.view.split_borders,
- frame,
- );
+ fn render_view_pane_borders(
+ app: &AppState,
+ ws: &Workspace,
+ split_borders: &[crate::layout::SplitBorder],
+ frame: &mut Frame,
+ ) {
+ render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame);
}
#[test]
@@ -1052,7 +817,6 @@ mod tests {
#[test]
fn pane_border_renderer_places_adjacent_cjk_by_display_width() {
let mut app = AppState::test_new();
- app.mode = Mode::Terminal;
app.view.terminal_area = Rect::new(0, 0, 12, 3);
let ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
@@ -1073,7 +837,7 @@ mod tests {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(12, 3)).unwrap();
terminal
- .draw(|frame| render_view_pane_borders(&app, &ws, frame))
+ .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame))
.unwrap();
let buffer = terminal.backend().buffer();
@@ -1208,7 +972,6 @@ mod tests {
#[test]
fn global_pane_border_renderer_composes_junctions_and_focus_style() {
let mut app = AppState::test_new();
- app.mode = Mode::Terminal;
app.view.terminal_area = Rect::new(0, 0, 4, 4);
app.view.pane_infos = vec![
PaneInfo {
@@ -1244,7 +1007,7 @@ mod tests {
is_focused: false,
},
];
- app.view.split_borders = vec![
+ let split_borders = vec![
crate::layout::SplitBorder {
pos: 2,
direction: ratatui::layout::Direction::Horizontal,
@@ -1265,7 +1028,7 @@ mod tests {
ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 4)).unwrap();
terminal
- .draw(|frame| render_view_pane_borders(&app, &ws, frame))
+ .draw(|frame| render_view_pane_borders(&app, &ws, &split_borders, frame))
.unwrap();
let buffer = terminal.backend().buffer();
@@ -1278,7 +1041,6 @@ mod tests {
#[test]
fn gapped_pane_focus_does_not_color_neighbor_border() {
let mut app = AppState::test_new();
- app.mode = Mode::Terminal;
app.pane_gaps = true;
app.view.terminal_area = Rect::new(0, 0, 4, 3);
app.view.pane_infos = vec![
@@ -1304,7 +1066,7 @@ mod tests {
ratatui::Terminal::new(ratatui::backend::TestBackend::new(4, 3)).unwrap();
terminal
- .draw(|frame| render_view_pane_borders(&app, &ws, frame))
+ .draw(|frame| render_view_pane_borders(&app, &ws, &[], frame))
.unwrap();
let buffer = terminal.backend().buffer();
@@ -1532,7 +1294,11 @@ mod tests {
..Default::default()
};
let expected_style = automatic_selection_style(&palette, host_theme);
- let selection = Some(Selection::range(PaneId::from_raw(1), 0, 0, 2, None));
+ let selection = Some(Selection::absolute_range(
+ PaneId::from_raw(1),
+ (0, 0),
+ (0, 2),
+ ));
let backend = ratatui::backend::TestBackend::new(4, 1);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
@@ -1552,9 +1318,9 @@ mod tests {
);
buf[(2, 0)].set_style(Style::default().fg(Color::Blue).bg(Color::Reset));
render_selection_highlight(
- &selection,
- frame,
- PaneId::from_raw(1),
+ selection.as_ref(),
+ frame.buffer_mut(),
+ &PaneId::from_raw(1),
Rect::new(0, 0, 4, 1),
None,
&palette,
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
index 5274739c..5f6dc078 100644
--- a/src/ui/release_notes.rs
+++ b/src/ui/release_notes.rs
@@ -1,250 +1,15 @@
use ratatui::{
- layout::{Constraint, Layout, Rect},
+ layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Wrap},
- Frame,
};
-use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
-use super::widgets::{
- action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button,
- render_modal_header, render_modal_shell,
-};
-use crate::app::{
- state::{Palette, ProductAnnouncementState, ReleaseNotesState},
- AppState,
-};
+use crate::app::state::{Palette, ProductAnnouncementState, ReleaseNotesState};
pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
pub(crate) const PRODUCT_ANNOUNCEMENT_MODAL_SIZE: (u16, u16) = (88, 24);
-pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(notes) = &app.release_notes else {
- return;
- };
-
- super::dim_background(frame, area);
-
- let Some(inner) = render_modal_shell(
- frame,
- area,
- RELEASE_NOTES_MODAL_SIZE.0,
- RELEASE_NOTES_MODAL_SIZE.1,
- &app.palette,
- ) else {
- return;
- };
- if inner.height < 8 || inner.width < 20 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- let header_title_area = Rect::new(
- header_rows[0].x + 1,
- header_rows[0].y,
- header_rows[0].width.saturating_sub(2),
- header_rows[0].height,
- );
- let header_subtitle_area = Rect::new(
- header_rows[1].x + 1,
- header_rows[1].y,
- header_rows[1].width.saturating_sub(2),
- header_rows[1].height,
- );
-
- render_modal_header(
- frame,
- header_title_area,
- &format!("v{}", notes.version),
- &app.palette,
- );
- let subtitle = if notes.preview {
- "update ready"
- } else {
- "what's new in this release"
- };
- frame.render_widget(
- Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)),
- header_subtitle_area,
- );
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- "close",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-
- let notes_body = stack.content;
- let display_lines =
- release_notes_display_lines(notes, &app.update_install_command, &app.palette);
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
- max_offset_from_bottom: app.release_notes_max_scroll() as usize,
- viewport_rows: notes_body.height.max(1) as usize,
- };
- let track = release_notes_scrollbar_rect(notes_body, metrics);
- let notes_text_area = track
- .map(|_| {
- Rect::new(
- notes_body.x,
- notes_body.y,
- notes_body.width.saturating_sub(1),
- notes_body.height,
- )
- })
- .unwrap_or(notes_body);
-
- let body = Paragraph::new(
- display_lines
- .into_iter()
- .map(|(_, line)| line)
- .collect::<Vec<_>>(),
- )
- .wrap(Wrap { trim: false })
- .scroll((notes.scroll, 0));
- frame.render_widget(body, notes_text_area);
- if let Some(track) = track {
- render_scrollbar(
- frame,
- metrics,
- track,
- app.palette.overlay0,
- app.palette.overlay1,
- "▐",
- );
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
- Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("close", Style::default().fg(app.palette.overlay0)),
- Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
- ])),
- stack.footer.unwrap_or_default(),
- );
-}
-
-pub(super) fn render_product_announcement_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(announcement) = &app.product_announcement else {
- return;
- };
-
- super::dim_background(frame, area);
-
- let Some(inner) = render_modal_shell(
- frame,
- area,
- PRODUCT_ANNOUNCEMENT_MODAL_SIZE.0,
- PRODUCT_ANNOUNCEMENT_MODAL_SIZE.1,
- &app.palette,
- ) else {
- return;
- };
- if inner.height < 8 || inner.width < 20 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- let header_title_area = Rect::new(
- header_rows[0].x + 1,
- header_rows[0].y,
- header_rows[0].width.saturating_sub(2),
- header_rows[0].height,
- );
- let header_subtitle_area = Rect::new(
- header_rows[1].x + 1,
- header_rows[1].y,
- header_rows[1].width.saturating_sub(2),
- header_rows[1].height,
- );
-
- render_modal_header(frame, header_title_area, &announcement.title, &app.palette);
- let subtitle = if announcement.preview {
- "product announcement preview"
- } else {
- "product announcement"
- };
- frame.render_widget(
- Paragraph::new(format!("{subtitle} · v{}", announcement.version))
- .style(Style::default().fg(app.palette.overlay1)),
- header_subtitle_area,
- );
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- "close",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-
- let body_rect = stack.content;
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: app
- .product_announcement_max_scroll()
- .saturating_sub(announcement.scroll) as usize,
- max_offset_from_bottom: app.product_announcement_max_scroll() as usize,
- viewport_rows: body_rect.height.max(1) as usize,
- };
- let track = release_notes_scrollbar_rect(body_rect, metrics);
- let text_area = track
- .map(|_| {
- Rect::new(
- body_rect.x,
- body_rect.y,
- body_rect.width.saturating_sub(1),
- body_rect.height,
- )
- })
- .unwrap_or(body_rect);
-
- let body = Paragraph::new(
- product_announcement_display_lines(announcement, &app.palette)
- .into_iter()
- .map(|(_, line)| line)
- .collect::<Vec<_>>(),
- )
- .wrap(Wrap { trim: false })
- .scroll((announcement.scroll, 0));
- frame.render_widget(body, text_area);
- if let Some(track) = track {
- render_scrollbar(
- frame,
- metrics,
- track,
- app.palette.overlay0,
- app.palette.overlay1,
- "▐",
- );
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
- Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("close", Style::default().fg(app.palette.overlay0)),
- Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
- ])),
- stack.footer.unwrap_or_default(),
- );
-}
-
fn release_notes_inline_spans<'a>(
text: &str,
base_style: Style,
@@ -428,6 +193,53 @@ pub(crate) fn product_announcement_display_lines<'a>(
release_notes_lines(announcement.body.as_str(), p)
}
+fn display_lines_scroll_metrics(
+ lines: &[(usize, Line<'_>)],
+ scroll: u16,
+ body: Rect,
+) -> crate::pane::ScrollMetrics {
+ let viewport_rows = body.height.max(1) as usize;
+ let rows_for_width = |width: u16| release_notes_wrapped_line_count(lines, width.max(1));
+ let full_width = body.width.max(1);
+ let initial_rows = rows_for_width(full_width);
+ let wrap_width = if initial_rows > viewport_rows && full_width > 1 {
+ full_width.saturating_sub(1)
+ } else {
+ full_width
+ };
+ let max_offset_from_bottom = rows_for_width(wrap_width).saturating_sub(viewport_rows);
+ crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom.saturating_sub(usize::from(scroll)),
+ max_offset_from_bottom,
+ viewport_rows,
+ }
+}
+
+pub(crate) fn release_notes_scroll_metrics(
+ notes: &ReleaseNotesState,
+ install_command: &str,
+ body: Rect,
+ p: &Palette,
+) -> crate::pane::ScrollMetrics {
+ display_lines_scroll_metrics(
+ &release_notes_display_lines(notes, install_command, p),
+ notes.scroll,
+ body,
+ )
+}
+
+pub(crate) fn product_announcement_scroll_metrics(
+ announcement: &ProductAnnouncementState,
+ body: Rect,
+ p: &Palette,
+) -> crate::pane::ScrollMetrics {
+ display_lines_scroll_metrics(
+ &product_announcement_display_lines(announcement, p),
+ announcement.scroll,
+ body,
+ )
+}
+
pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], width: u16) -> usize {
Paragraph::new(
lines
@@ -440,8 +252,7 @@ pub(crate) fn release_notes_wrapped_line_count(lines: &[(usize, Line<'_>)], widt
}
pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect {
- let width = action_button_width(Some("esc"), "close");
- Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)
+ super::widgets::close_button_rect(area)
}
#[cfg(test)]
diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs
index b73b2c50..cf700825 100644
--- a/src/ui/scrollbar.rs
+++ b/src/ui/scrollbar.rs
@@ -1,4 +1,5 @@
use ratatui::{
+ buffer::Buffer,
layout::Rect,
style::{Color, Style},
Frame,
@@ -75,7 +76,7 @@ pub(crate) fn scrollbar_thumb_grab_offset(
row: u16,
) -> Option<u16> {
let thumb = scrollbar_thumb(metrics, track)?;
- (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top)
+ (row >= thumb.top && row < thumb.top + thumb.len).then(|| row - thumb.top)
}
fn scrollbar_offset_from_thumb_top(
@@ -132,8 +133,8 @@ pub(crate) fn scrollbar_offset_from_drag_row(
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
-pub(super) fn render_scrollbar(
- frame: &mut Frame,
+pub(crate) fn render_scrollbar_buffer(
+ buffer: &mut Buffer,
metrics: crate::pane::ScrollMetrics,
track: Rect,
track_color: Color,
@@ -148,19 +149,40 @@ pub(super) fn render_scrollbar(
return;
};
- let buf = frame.buffer_mut();
for y in track.y..track.y + track.height {
- let cell = &mut buf[(track.x, y)];
+ let cell = &mut buffer[(track.x, y)];
cell.set_symbol("▕");
cell.set_style(Style::default().fg(track_color));
}
for y in thumb.top..thumb.top + thumb.len {
- let cell = &mut buf[(track.x, y)];
+ let cell = &mut buffer[(track.x, y)];
cell.set_symbol(thumb_symbol);
cell.set_style(Style::default().fg(thumb_color));
}
}
+pub(crate) fn render_pane_scrollbar_buffer(
+ buffer: &mut Buffer,
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ palette: &crate::app::state::Palette,
+ focused: bool,
+) {
+ let (track_color, thumb_color, thumb_symbol) = if focused {
+ (palette.overlay0, palette.overlay1, "▐")
+ } else {
+ (palette.surface_dim, palette.overlay0, "▕")
+ };
+ render_scrollbar_buffer(
+ buffer,
+ metrics,
+ track,
+ track_color,
+ thumb_color,
+ thumb_symbol,
+ );
+}
+
pub(super) fn render_pane_scrollbar(
app: &AppState,
frame: &mut Frame,
@@ -173,19 +195,11 @@ pub(super) fn render_pane_scrollbar(
let Some(track) = pane_scrollbar_rect(info) else {
return;
};
-
- let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
- (app.palette.overlay0, app.palette.overlay1, "▐")
- } else {
- (app.palette.surface_dim, app.palette.overlay0, "▕")
- };
-
- render_scrollbar(
- frame,
+ render_pane_scrollbar_buffer(
+ frame.buffer_mut(),
metrics,
track,
- track_color,
- thumb_color,
- thumb_symbol,
+ &app.palette,
+ info.is_focused,
);
}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
deleted file mode 100644
index 3cea6da3..00000000
--- a/src/ui/settings.rs
+++ /dev/null
@@ -1,424 +0,0 @@
-use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{List, ListItem, ListState, Paragraph, Tabs},
- Frame,
-};
-
-use super::widgets::{
- action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg,
- render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec,
-};
-use crate::{
- app::{state::Palette, AppState},
- config::{StatusIndicatorStyle, ToastDelivery},
-};
-
-pub(crate) const SETTINGS_POPUP_WIDTH: u16 = 76;
-pub(crate) const SETTINGS_POPUP_BASE_HEIGHT: u16 = 22;
-
-pub(crate) fn settings_popup_height(app: &AppState) -> u16 {
- if app.settings.section != crate::app::state::SettingsSection::Integrations {
- return SETTINGS_POPUP_BASE_HEIGHT;
- }
- let list_rows = app.integration_recommendations.len().max(1) as u16;
- let footer_rows = integrations_footer_height(app, SETTINGS_POPUP_WIDTH - 2);
- // borders 2 + header 3 + stack gaps 2 + modal footer 2
- // + section title 1 + description 2 + spacers 2
- (14 + list_rows + footer_rows).max(SETTINGS_POPUP_BASE_HEIGHT)
-}
-
-pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- use crate::app::state::SettingsSection;
-
- let p = &app.palette;
- let Some(popup) = centered_popup_rect(area, SETTINGS_POPUP_WIDTH, settings_popup_height(app))
- else {
- return;
- };
-
- super::dim_background(frame, area);
-
- let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else {
- return;
- };
- if inner.height < 4 || inner.width < 10 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 3, 2, 0, 1);
- let header_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<3>(stack.header);
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " settings",
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- )])),
- header_rows[0],
- );
-
- let tab_labels = SettingsSection::ALL.iter().map(|section| {
- if app.settings_section_has_badge(*section) {
- Line::from(vec![
- Span::styled(
- "● ",
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- Span::raw(section.label()),
- ])
- } else {
- Line::from(section.label())
- }
- });
- let tabs = Tabs::new(tab_labels)
- .select(
- SettingsSection::ALL
- .iter()
- .position(|section| *section == app.settings.section)
- .unwrap_or(0),
- )
- .style(Style::default().fg(p.overlay1))
- .highlight_style(
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.accent)
- .add_modifier(Modifier::BOLD),
- )
- .divider(" ")
- .padding(" ", " ");
- frame.render_widget(tabs, header_rows[1]);
-
- let sep = "─".repeat(inner.width as usize);
- frame.render_widget(
- Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
- header_rows[2],
- );
-
- let content_area = stack.content;
-
- match app.settings.section {
- SettingsSection::Theme => {
- render_settings_theme(app, frame, content_area);
- }
- SettingsSection::Indicators => {
- render_modal_choice_list(
- frame,
- content_area,
- "agent status indicators",
- "choose color dots or distinct symbols for each state",
- &[
- ("color dots ● ● ● ○ ·", StatusIndicatorStyle::Dots),
- ("distinct symbols × ◐ ✓ ○ ·", StatusIndicatorStyle::Symbols),
- ],
- app.status_indicators,
- app.settings.list.selected,
- p,
- 1,
- );
- }
- SettingsSection::Sound => {
- render_settings_toggle(
- frame,
- content_area,
- p,
- "sound alerts",
- "play sounds when agents change state in background",
- app.sound_enabled(),
- app.settings.list.selected,
- );
- }
- SettingsSection::Toast => {
- render_modal_choice_list(
- frame,
- content_area,
- "notification popups",
- "choose where background popup notifications should appear",
- &[
- ("off", ToastDelivery::Off),
- ("inside herdr", ToastDelivery::Herdr),
- ("via terminal", ToastDelivery::Terminal),
- ("via system", ToastDelivery::System),
- ],
- app.toast_delivery(),
- app.settings.list.selected,
- p,
- 2,
- );
- }
- SettingsSection::PaneLabels => {
- render_settings_toggle(
- frame,
- content_area,
- p,
- "agent border labels",
- "show detected agent names in split pane borders",
- app.agent_border_labels_enabled(),
- app.settings.list.selected,
- );
- }
- SettingsSection::Integrations => {
- render_settings_integrations(app, frame, content_area);
- }
- }
-
- if let Some(footer_area) = stack.footer {
- let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
- .areas::<2>(footer_area);
- let primary_label = settings_primary_button_label(app.settings.section);
- let show_primary = settings_show_primary_action(app);
- let (apply_rect, close_rect) =
- settings_button_rects(inner, app.settings.section, show_primary);
- if let Some(apply_rect) = apply_rect {
- render_action_button(
- frame,
- apply_rect,
- Some("↵"),
- primary_label,
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.accent)
- .add_modifier(Modifier::BOLD),
- );
- }
- render_action_button(
- frame,
- close_rect,
- Some("esc"),
- "close",
- Style::default()
- .fg(p.text)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD),
- );
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" ↑↓", Style::default().fg(p.overlay0)),
- Span::styled(" select ", Style::default().fg(p.overlay1)),
- Span::styled("tab", Style::default().fg(p.overlay0)),
- Span::styled(" section", Style::default().fg(p.overlay1)),
- ])),
- footer_rows[0],
- );
- }
-}
-
-pub(crate) fn settings_primary_button_label(
- section: crate::app::state::SettingsSection,
-) -> &'static str {
- match section {
- crate::app::state::SettingsSection::Integrations => "install",
- _ => "apply",
- }
-}
-
-pub(crate) fn settings_show_primary_action(app: &AppState) -> bool {
- match app.settings.section {
- crate::app::state::SettingsSection::Integrations => app
- .integration_recommendations
- .iter()
- .any(crate::integration::IntegrationRecommendation::needs_install),
- _ => true,
- }
-}
-
-pub(crate) fn settings_button_rects(
- inner: Rect,
- section: crate::app::state::SettingsSection,
- show_primary: bool,
-) -> (Option<Rect>, Rect) {
- if !show_primary {
- let rects = action_button_row_rects(
- inner,
- &[ActionButtonSpec {
- hint: Some("esc"),
- label: "close",
- }],
- 2,
- inner.height.saturating_sub(1),
- );
- return (None, rects[0]);
- }
-
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: settings_primary_button_label(section),
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "close",
- },
- ],
- 2,
- inner.height.saturating_sub(1),
- );
- (Some(rects[0]), rects[1])
-}
-
-fn integrations_footer_paragraph(app: &AppState) -> Paragraph<'static> {
- let p = &app.palette;
- let mut footer_lines = Vec::new();
- if !app.integration_install_messages.is_empty() {
- for message in &app.integration_install_messages {
- footer_lines.push(Line::from(Span::styled(
- format!(" {message}"),
- Style::default().fg(p.overlay1),
- )));
- }
- } else {
- let found_any = app.integration_recommendations.iter().any(|item| {
- item.available || item.state != crate::integration::IntegrationStatusKind::NotInstalled
- });
- let hint = if app
- .integration_recommendations
- .iter()
- .any(crate::integration::IntegrationRecommendation::needs_install)
- {
- " press install to add available or outdated integrations"
- } else if found_any {
- " all detected integrations are installed"
- } else {
- " no supported agent CLIs found on PATH"
- };
- footer_lines.push(Line::from(Span::styled(
- hint.to_string(),
- Style::default().fg(p.overlay1),
- )));
- }
- Paragraph::new(footer_lines).wrap(ratatui::widgets::Wrap { trim: false })
-}
-
-fn integrations_footer_height(app: &AppState, width: u16) -> u16 {
- (integrations_footer_paragraph(app).line_count(width) as u16).min(6)
-}
-
-fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
-
- let footer = integrations_footer_paragraph(app);
- let footer_height = integrations_footer_height(app, area.width);
-
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(2),
- Constraint::Length(1),
- Constraint::Min(0),
- Constraint::Length(1),
- Constraint::Length(footer_height),
- ])
- .areas::<6>(area);
-
- frame.render_widget(
- Paragraph::new("agent integrations")
- .style(Style::default().fg(p.text).add_modifier(Modifier::BOLD)),
- rows[0],
- );
- frame.render_widget(
- Paragraph::new(
- "let agents report state directly instead of relying only on process detection",
- )
- .style(Style::default().fg(p.overlay1))
- .wrap(ratatui::widgets::Wrap { trim: false }),
- rows[1],
- );
-
- let mut lines = Vec::new();
- for item in &app.integration_recommendations {
- let marker = match item.state {
- crate::integration::IntegrationStatusKind::Current => "✓",
- crate::integration::IntegrationStatusKind::Outdated => "↻",
- crate::integration::IntegrationStatusKind::NotInstalled if item.available => "+",
- crate::integration::IntegrationStatusKind::NotInstalled => "–",
- };
- let marker_style = match item.state {
- crate::integration::IntegrationStatusKind::Current => Style::default().fg(p.green),
- crate::integration::IntegrationStatusKind::Outdated => Style::default().fg(p.yellow),
- crate::integration::IntegrationStatusKind::NotInstalled if item.available => {
- Style::default().fg(p.accent)
- }
- crate::integration::IntegrationStatusKind::NotInstalled => {
- Style::default().fg(p.overlay0)
- }
- };
- lines.push(Line::from(vec![
- Span::styled(format!(" {marker} "), marker_style),
- Span::styled(
- format!("{:<9}", item.label),
- Style::default().fg(p.subtext0),
- ),
- Span::styled(item.status_label(), Style::default().fg(p.overlay1)),
- ]));
- }
-
- if lines.is_empty() {
- lines.push(Line::from(Span::styled(
- " no integration targets available",
- Style::default().fg(p.overlay1),
- )));
- }
-
- frame.render_widget(Paragraph::new(lines), rows[3]);
- frame.render_widget(footer, rows[5]);
-}
-
-fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
- use crate::app::state::THEME_NAMES;
-
- let p = &app.palette;
- let items: Vec<ListItem> = THEME_NAMES
- .iter()
- .map(|name| {
- let is_current = name.to_lowercase().replace([' ', '_'], "-")
- == app.theme_name.to_lowercase().replace([' ', '_'], "-");
- let marker = if is_current { " ✓" } else { "" };
- ListItem::new(Line::from(vec![
- Span::styled(*name, Style::default().fg(p.subtext0)),
- Span::styled(marker, Style::default().fg(p.green)),
- ]))
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(
- Style::default()
- .bg(p.surface0)
- .fg(p.text)
- .add_modifier(Modifier::BOLD),
- )
- .highlight_symbol(" ▸ ")
- .style(Style::default().fg(p.subtext0));
-
- let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
- frame.render_stateful_widget(list, area, &mut state);
-}
-
-fn render_settings_toggle(
- frame: &mut Frame,
- area: Rect,
- p: &Palette,
- title: &str,
- description: &str,
- current_value: bool,
- selected_idx: usize,
-) {
- render_modal_choice_list(
- frame,
- area,
- title,
- description,
- &[("on", true), ("off", false)],
- current_value,
- selected_idx,
- p,
- 1,
- );
-}
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,
- },
- ]
- );
- }
-}
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
index c578e13f..eaec154b 100644
--- a/src/ui/sidebar/tokens.rs
+++ b/src/ui/sidebar/tokens.rs
@@ -1,17 +1,16 @@
-use super::AgentPanelEntry;
use crate::config::{
AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken,
SpacesSidebarConfig,
};
#[derive(Debug, Clone, PartialEq, Eq)]
-pub(super) struct ResolvedToken {
+pub(crate) struct ResolvedToken {
pub kind: ResolvedTokenKind,
pub style: SidebarTokenStyle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
-pub(super) enum ResolvedTokenKind {
+pub(crate) enum ResolvedTokenKind {
StateIcon,
StateText(String),
Workspace(String),
@@ -35,13 +34,24 @@ impl ResolvedToken {
}
}
-pub(super) fn agent_rows(
+pub(crate) struct AgentTokenContext<'a> {
+ pub(crate) workspace: &'a str,
+ pub(crate) tab: Option<&'a str>,
+ pub(crate) pane: Option<&'a str>,
+ pub(crate) agent_label: Option<&'a str>,
+ pub(crate) terminal_title: Option<&'a str>,
+ pub(crate) terminal_title_stripped: Option<&'a str>,
+ pub(crate) canonical_agent: Option<crate::detect::Agent>,
+ pub(crate) tokens: &'a std::collections::HashMap<String, String>,
+}
+
+pub(crate) fn agent_rows(
config: &AgentsSidebarConfig,
- entry: &AgentPanelEntry,
+ context: AgentTokenContext<'_>,
state_text: &str,
) -> Vec<Vec<ResolvedToken>> {
config
- .rows_for_agent(entry.agent)
+ .rows_for_agent(context.canonical_agent)
.iter()
.filter_map(|row| {
let resolved = row
@@ -54,26 +64,24 @@ pub(super) fn agent_rows(
Some(ResolvedTokenKind::StateText(state_text.to_string()))
}
AgentSidebarToken::Workspace => {
- Some(ResolvedTokenKind::Workspace(entry.primary_label.clone()))
- }
- AgentSidebarToken::Tab => {
- entry.primary_tab_label.clone().map(ResolvedTokenKind::Tab)
- }
- AgentSidebarToken::Pane => {
- entry.pane_label.clone().map(ResolvedTokenKind::Pane)
- }
- AgentSidebarToken::Agent => {
- entry.agent_label.clone().map(ResolvedTokenKind::Agent)
+ Some(ResolvedTokenKind::Workspace(context.workspace.to_string()))
}
- AgentSidebarToken::TerminalTitle => entry
+ AgentSidebarToken::Tab => context
+ .tab
+ .map(|value| ResolvedTokenKind::Tab(value.to_string())),
+ AgentSidebarToken::Pane => context
+ .pane
+ .map(|value| ResolvedTokenKind::Pane(value.to_string())),
+ AgentSidebarToken::Agent => context
+ .agent_label
+ .map(|value| ResolvedTokenKind::Agent(value.to_string())),
+ AgentSidebarToken::TerminalTitle => context
.terminal_title
- .clone()
- .map(ResolvedTokenKind::TerminalTitle),
- AgentSidebarToken::TerminalTitleStripped => entry
+ .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())),
+ AgentSidebarToken::TerminalTitleStripped => context
.terminal_title_stripped
- .clone()
- .map(ResolvedTokenKind::TerminalTitle),
- AgentSidebarToken::Custom(name) => entry
+ .map(|value| ResolvedTokenKind::TerminalTitle(value.to_string())),
+ AgentSidebarToken::Custom(name) => context
.tokens
.get(name)
.cloned()
@@ -88,16 +96,16 @@ pub(super) fn agent_rows(
.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(crate) struct SpaceTokenContext<'a> {
+ pub(crate) workspace: &'a str,
+ pub(crate) branch: Option<&'a str>,
+ pub(crate) state_text: &'a str,
+ pub(crate) ahead_behind: Option<(usize, usize)>,
+ pub(crate) tokens: &'a std::collections::HashMap<String, String>,
+ pub(crate) suppress_git_details: bool,
}
-pub(super) fn space_rows(
+pub(crate) fn space_rows(
config: &SpacesSidebarConfig,
context: SpaceTokenContext<'_>,
) -> Vec<Vec<ResolvedToken>> {
@@ -141,7 +149,7 @@ pub(super) fn space_rows(
.collect()
}
-pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str {
+pub(crate) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str {
if matches!(previous.kind, ResolvedTokenKind::StateIcon)
|| matches!(current.kind, ResolvedTokenKind::GitStatus { .. })
{
@@ -155,29 +163,44 @@ pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'
mod tests {
use super::*;
use crate::config::{AgentSidebarToken, SpaceSidebarToken};
- 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,
+ struct Entry {
+ workspace: String,
+ tab: Option<String>,
+ pane: Option<String>,
+ agent_label: Option<String>,
+ terminal_title: Option<String>,
+ terminal_title_stripped: Option<String>,
+ canonical_agent: Option<crate::detect::Agent>,
+ tokens: std::collections::HashMap<String, String>,
+ }
+
+ fn entry() -> Entry {
+ Entry {
+ workspace: "repo".into(),
+ tab: None,
+ pane: None,
+ agent_label: Some("pi".into()),
terminal_title: None,
terminal_title_stripped: None,
- agent_label: Some("pi".into()),
- agent_kind_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(),
+ canonical_agent: Some(crate::detect::Agent::Pi),
tokens: std::collections::HashMap::new(),
}
}
+ fn context(entry: &Entry) -> AgentTokenContext<'_> {
+ AgentTokenContext {
+ workspace: &entry.workspace,
+ tab: entry.tab.as_deref(),
+ pane: entry.pane.as_deref(),
+ agent_label: entry.agent_label.as_deref(),
+ terminal_title: entry.terminal_title.as_deref(),
+ terminal_title_stripped: entry.terminal_title_stripped.as_deref(),
+ canonical_agent: entry.canonical_agent,
+ tokens: &entry.tokens,
+ }
+ }
+
#[test]
fn missing_custom_tokens_elide_rows_and_separators() {
let entry = entry();
@@ -193,7 +216,7 @@ mod tests {
..Default::default()
};
- let rows = agent_rows(&config, &entry, "working");
+ let rows = agent_rows(&config, context(&entry), "working");
assert_eq!(rows.len(), 2);
assert_eq!(
@@ -223,7 +246,7 @@ mod tests {
};
assert_eq!(
- agent_rows(&config, &entry, "deep in the mines"),
+ agent_rows(&config, context(&entry), "deep in the mines"),
vec![vec![
ResolvedToken::unstyled(ResolvedTokenKind::StateText("deep in the mines".into())),
ResolvedToken::unstyled(ResolvedTokenKind::Custom("reviewing auth".into())),
@@ -249,7 +272,7 @@ mod tests {
};
assert_eq!(
- agent_rows(&config, &entry, "working"),
+ agent_rows(&config, context(&entry), "working"),
vec![vec![
ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("⠋ raw title".into())),
ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("raw title".into())),
@@ -271,15 +294,15 @@ mod tests {
pi.agent_label = Some("renamed pi".into());
assert_eq!(
- agent_rows(&config, &pi, "working"),
+ agent_rows(&config, context(&pi), "working"),
vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent(
"renamed pi".into()
))]]
);
- pi.agent = None;
+ pi.canonical_agent = None;
assert_eq!(
- agent_rows(&config, &pi, "working"),
+ agent_rows(&config, context(&pi), "working"),
vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace(
"repo".into()
))]]
diff --git a/src/ui/status.rs b/src/ui/status.rs
index 62646482..e47e0f90 100644
--- a/src/ui/status.rs
+++ b/src/ui/status.rs
@@ -1,17 +1,15 @@
use ratatui::{
- layout::{Constraint, Layout, Rect},
- style::{Color, Modifier, Style},
+ buffer::Buffer,
+ layout::Rect,
+ style::{Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, Clear, Paragraph},
- Frame,
+ widgets::{Block, Borders, Clear, Paragraph, Widget},
};
-use super::text::display_width_u16;
use super::widgets::panel_contrast_fg;
use crate::{
- app::state::{CopyFeedback, Palette, ToastKind, ToastNotification},
- config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition},
- detect::AgentState,
+ app::state::{CopyFeedback, Palette},
+ config::ToastClipboardPosition,
};
pub(crate) fn copy_feedback_rect(
@@ -20,7 +18,7 @@ pub(crate) fn copy_feedback_rect(
offset_rows: u16,
position: ToastClipboardPosition,
) -> Rect {
- if area.width == 0 || area.height == 0 {
+ if area.is_empty() {
return Rect::default();
}
@@ -49,129 +47,56 @@ pub(crate) fn copy_feedback_rect(
Rect::new(x, y, width, height)
}
-pub(crate) fn toast_notification_rect(
- area: Rect,
- toast: &ToastNotification,
- offset_for_warning: bool,
- position: ToastHerdrPosition,
-) -> Rect {
- let content_width = display_width_u16(&toast.title)
- .max(display_width_u16(&toast.context))
- .saturating_add(4);
- let width = content_width.saturating_add(2).min(area.width);
- let content_height = if toast.context.is_empty() { 1 } else { 2 };
- let height = (content_height + 2).min(area.height);
- let x = match position {
- ToastHerdrPosition::TopLeft | ToastHerdrPosition::BottomLeft => area.x,
- ToastHerdrPosition::TopRight | ToastHerdrPosition::BottomRight => {
- area.x + area.width.saturating_sub(width)
- }
- };
- let warning_offset = u16::from(offset_for_warning);
- let y = match position {
- ToastHerdrPosition::TopLeft | ToastHerdrPosition::TopRight => {
- area.y + warning_offset.min(area.height)
- }
- ToastHerdrPosition::BottomLeft | ToastHerdrPosition::BottomRight => {
- area.y + area.height.saturating_sub(height + warning_offset)
- }
- };
- Rect::new(x, y, width, height)
-}
-
-pub(super) fn render_toast_notification(
- frame: &mut Frame,
- area: Rect,
- toast: &ToastNotification,
- offset_for_warning: bool,
- position: ToastHerdrPosition,
- p: &Palette,
-) {
- let dot_color = match toast.kind {
- ToastKind::NeedsAttention => p.red,
- ToastKind::Finished => p.blue,
- ToastKind::UpdateInstalled => p.accent,
- };
- let toast_area = toast_notification_rect(area, toast, offset_for_warning, position);
-
- frame.render_widget(Clear, toast_area);
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(p.overlay0))
- .style(Style::default().bg(p.panel_bg));
- let inner = block.inner(toast_area);
- frame.render_widget(block, toast_area);
-
- if inner.height < 1 {
- return;
- }
-
- let [title_row, context_row] =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
-
- let title = Line::from(vec![
- Span::styled("●", Style::default().fg(dot_color)),
- Span::raw(" "),
- Span::styled(
- &toast.title,
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- ),
- ]);
- let context = Line::from(vec![
- Span::styled(" ", Style::default().fg(p.overlay0)),
- Span::styled(&toast.context, Style::default().fg(p.overlay0)),
- ]);
-
- frame.render_widget(Paragraph::new(title), title_row);
- if !toast.context.is_empty() && inner.height >= 2 {
- frame.render_widget(Paragraph::new(context), context_row);
- }
-}
-
-pub(super) fn render_copy_feedback(
- frame: &mut Frame,
+pub(crate) fn render_copy_feedback_buffer(
+ buffer: &mut Buffer,
area: Rect,
feedback: &CopyFeedback,
offset_rows: u16,
position: ToastClipboardPosition,
- p: &Palette,
+ palette: &Palette,
) {
let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position);
if feedback_area.is_empty() {
return;
}
- frame.render_widget(Clear, feedback_area);
+ Clear.render(feedback_area, buffer);
let block = Block::default()
.borders(Borders::ALL)
- .border_style(Style::default().fg(p.green))
- .style(Style::default().bg(p.panel_bg));
+ .border_style(Style::default().fg(palette.green))
+ .style(Style::default().bg(palette.panel_bg));
let inner = block.inner(feedback_area);
- frame.render_widget(block, feedback_area);
+ block.render(feedback_area, buffer);
if inner.height == 0 {
return;
}
let text = Line::from(vec![
- Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)),
+ Span::styled("●", Style::default().fg(palette.green).bg(palette.panel_bg)),
Span::raw(" "),
Span::styled(
&feedback.message,
Style::default()
- .fg(p.text)
- .bg(p.panel_bg)
+ .fg(palette.text)
+ .bg(palette.panel_bg)
.add_modifier(Modifier::BOLD),
),
]);
- frame.render_widget(Paragraph::new(text), inner);
+ Paragraph::new(text).render(inner, buffer);
}
-pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
+pub(crate) fn render_config_diagnostic_buffer(
+ buffer: &mut Buffer,
+ area: Rect,
+ message: &str,
+ palette: &Palette,
+) -> u16 {
let style = Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.yellow)
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.yellow)
.add_modifier(Modifier::BOLD);
+ let mut rendered_rows = 0u16;
for (row, line) in message
.lines()
@@ -181,176 +106,41 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &
{
let text = format!(" {line} ");
let width = (text.len() as u16).min(area.width);
- let notif_area = Rect::new(
+ let diagnostic_area = Rect::new(
area.x + area.width.saturating_sub(width),
area.y + row as u16,
width,
1,
);
- frame.render_widget(Clear, notif_area);
- frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area);
+ Clear.render(diagnostic_area, buffer);
+ Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer);
+ rendered_rows = rendered_rows.saturating_add(1);
}
-}
-pub(super) fn state_icon_symbol(
- state: AgentState,
- seen: bool,
- indicator_style: StatusIndicatorStyle,
-) -> &'static str {
- match (indicator_style, state, seen) {
- (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●",
- (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●",
- (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●",
- (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○",
- (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·",
- (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×",
- (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐",
- (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓",
- (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○",
- (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·",
- }
-}
-
-pub(super) fn state_icon(
- state: AgentState,
- seen: bool,
- indicator_style: StatusIndicatorStyle,
- p: &Palette,
-) -> (&'static str, Style) {
- (
- state_icon_symbol(state, seen, indicator_style),
- Style::default().fg(state_label_color(state, seen, p)),
- )
-}
-
-pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str {
- match (state, seen) {
- (AgentState::Blocked, _) => "blocked",
- (AgentState::Working, _) => "working",
- (AgentState::Idle, false) => "done",
- (AgentState::Idle, true) => "idle",
- (AgentState::Unknown, _) => "idle",
- }
-}
-
-pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color {
- match (state, seen) {
- (AgentState::Blocked, _) => p.red,
- (AgentState::Working, _) => p.yellow,
- (AgentState::Idle, false) => p.teal,
- (AgentState::Idle, true) => p.green,
- (AgentState::Unknown, _) => p.overlay0,
- }
+ rendered_rows
}
#[cfg(test)]
mod tests {
use super::*;
- use crate::config::{ToastClipboardPosition, ToastHerdrPosition};
-
- fn toast() -> ToastNotification {
- ToastNotification {
- kind: ToastKind::Finished,
- title: "done".to_string(),
- context: "workspace".to_string(),
- position: None,
- target: None,
- }
- }
-
- fn feedback() -> CopyFeedback {
- CopyFeedback {
- message: "copied to clipboard".to_string(),
- }
- }
-
- #[test]
- fn state_icons_support_dot_and_distinct_symbol_styles() {
- let palette = Palette::catppuccin();
- for (indicator_style, expected_symbols) in [
- (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]),
- (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]),
- ] {
- for ((state, seen, color), expected_symbol) in [
- (AgentState::Blocked, true, palette.red),
- (AgentState::Working, true, palette.yellow),
- (AgentState::Idle, false, palette.teal),
- (AgentState::Idle, true, palette.green),
- (AgentState::Unknown, true, palette.overlay0),
- ]
- .into_iter()
- .zip(expected_symbols)
- {
- let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette);
- assert_eq!(actual_symbol, expected_symbol);
- assert_eq!(display_width_u16(actual_symbol), 1);
- assert_eq!(style.fg, Some(color));
- }
- }
- }
-
- #[test]
- fn toast_rect_uses_configured_corner() {
- let area = Rect::new(10, 20, 100, 40);
- let toast = toast();
-
- let top_left = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopLeft);
- assert_eq!(top_left.x, area.x);
- assert_eq!(top_left.y, area.y);
-
- let top_right = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight);
- assert_eq!(top_right.x + top_right.width, area.x + area.width);
- assert_eq!(top_right.y, area.y);
-
- let bottom_left =
- toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomLeft);
- assert_eq!(bottom_left.x, area.x);
- assert_eq!(bottom_left.y + bottom_left.height, area.y + area.height);
-
- let bottom_right =
- toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomRight);
- assert_eq!(bottom_right.x + bottom_right.width, area.x + area.width);
- assert_eq!(bottom_right.y + bottom_right.height, area.y + area.height);
- }
-
- #[test]
- fn toast_rect_uses_display_width_for_cjk_labels() {
- let area = Rect::new(0, 0, 100, 20);
- let toast = ToastNotification {
- kind: ToastKind::NeedsAttention,
- title: "重构用户认证模块".to_string(),
- context: "提交 herdr 的反馈".to_string(),
- position: None,
- target: None,
- };
-
- let rect = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight);
-
- let expected_content_width =
- display_width_u16(&toast.title).max(display_width_u16(&toast.context)) + 6;
- assert_eq!(rect.width, expected_content_width);
- assert_eq!(rect.x + rect.width, area.x + area.width);
- }
#[test]
fn copy_feedback_rect_uses_configured_position() {
let area = Rect::new(10, 20, 100, 40);
- let feedback = feedback();
+ let feedback = CopyFeedback {
+ message: "copied to clipboard".to_owned(),
+ };
- let top_center = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter);
- assert_eq!(top_center.y, area.y);
- assert_eq!(
- top_center.x,
- area.x + area.width.saturating_sub(top_center.width) / 2
- );
+ let top = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter);
+ assert_eq!(top.y, area.y);
+ assert_eq!(top.x, area.x + area.width.saturating_sub(top.width) / 2);
- let bottom_center =
- copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter);
- assert_eq!(bottom_center.y + bottom_center.height, area.y + area.height);
+ let bottom = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter);
+ assert_eq!(bottom.bottom(), area.bottom());
assert_eq!(
- bottom_center.x,
- area.x + area.width.saturating_sub(bottom_center.width) / 2
+ bottom.x,
+ area.x + area.width.saturating_sub(bottom.width) / 2
);
}
}
diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs
index 99ca9727..766c1d83 100644
--- a/src/ui/tab_surface.rs
+++ b/src/ui/tab_surface.rs
@@ -1,8 +1,7 @@
use ratatui::{layout::Rect, Frame};
use super::panes::{compute_pane_infos, render_panes, resize_tab_panes};
-use crate::app::state::ViewState;
-use crate::app::{AppState, Mode};
+use crate::app::AppState;
use crate::layout::{PaneInfo, SplitBorder};
use crate::protocol::CursorState;
use crate::terminal::TerminalRuntimeRegistry;
@@ -18,15 +17,6 @@ pub(crate) struct TabSurfaceView<'a> {
pub(crate) split_borders: &'a [SplitBorder],
}
-impl ViewState {
- pub(crate) fn tab_surface(&self) -> TabSurfaceView<'_> {
- TabSurfaceView {
- pane_infos: &self.pane_infos,
- split_borders: &self.split_borders,
- }
- }
-}
-
pub(crate) fn compute_tab_surface(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -105,10 +95,6 @@ pub(crate) fn tab_surface_cursor(
terminal_runtimes: &TerminalRuntimeRegistry,
surface: TabSurfaceView<'_>,
) -> Option<CursorState> {
- if app.mode != Mode::Terminal {
- return None;
- }
-
let ws_idx = app.active?;
let info = surface.pane_infos.iter().find(|info| info.is_focused)?;
if !app.pane_exposes_host_cursor(ws_idx, info.id) {
@@ -189,12 +175,9 @@ mod tests {
app.workspaces = vec![workspace];
app.active = Some(0);
app.selected = 0;
- app.mode = Mode::Terminal;
let full_area = Rect::new(0, 0, 106, 20);
- crate::ui::compute_view(&mut app, full_area);
- let area = app.view.terminal_area;
- assert_eq!(area, Rect::new(26, 1, 80, 19));
+ let area = full_area;
let surface = compute_tab_surface(
&app,
&TerminalRuntimeRegistry::new(),
@@ -207,7 +190,6 @@ mod tests {
app.view.terminal_area = Rect::new(9, 8, 7, 6);
app.view.pane_infos.clear();
- app.view.split_borders.clear();
let surface_view = TabSurfaceView {
pane_infos: &surface.pane_infos,
@@ -238,90 +220,4 @@ mod tests {
.any(|(_, symbol, link)| { symbol == "L" && link == uri }));
assert!(tab_surface_cursor(&app, &TerminalRuntimeRegistry::new(), surface_view,).is_some());
}
-
- fn full_app_frame(app: &mut AppState, area: Rect) -> crate::protocol::FrameData {
- let (buffer, cursor) = crate::server::render_stream::render_virtual(app, area, true);
- let hyperlinks =
- crate::server::render_stream::visible_hyperlinks(app, &TerminalRuntimeRegistry::new());
- crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks(
- &buffer,
- cursor,
- &hyperlinks,
- )
- }
-
- fn frame_digest(frame: &crate::protocol::FrameData) -> String {
- use sha2::{Digest, Sha256};
-
- let encoded = bincode::serde::encode_to_vec(frame, bincode::config::standard()).unwrap();
- format!("{:x}", Sha256::digest(encoded))
- }
-
- fn full_app_characterization_state(uri: &str) -> AppState {
- let mut workspace = Workspace::test_new("characterization");
- workspace.identity_cwd = std::path::PathBuf::from("characterization");
- workspace.cached_git_branch = None;
- workspace.cached_git_ahead_behind = None;
- workspace.cached_git_space = None;
- workspace.test_add_tab(Some("logs"));
- workspace.switch_tab(0);
- let left = workspace.tabs[0].root_pane;
- let right = workspace.test_split(Direction::Horizontal);
- workspace.insert_test_runtime(
- left,
- crate::terminal::TerminalRuntime::test_with_screen_bytes(
- 40,
- 10,
- format!("\x1b]8;;{uri}\x1b\\LINK\x1b]8;;\x1b\\").as_bytes(),
- ),
- );
- workspace.insert_test_runtime(
- right,
- crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 10, b"RIGHT\r\nPANE"),
- );
-
- let mut app = AppState::test_new();
- app.workspaces = vec![workspace];
- app.active = Some(0);
- app.selected = 0;
- app.mode = Mode::Terminal;
- app
- }
-
- #[tokio::test]
- async fn desktop_full_app_semantic_frame_is_characterized() {
- let uri = "https://example.com/full-app";
- let mut app = full_app_characterization_state(uri);
- let frame = full_app_frame(&mut app, Rect::new(0, 0, 106, 20));
-
- assert_eq!((frame.width, frame.height), (106, 20));
- assert_eq!(app.view.sidebar_rect, Rect::new(0, 0, 26, 20));
- assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 80, 1));
- assert_eq!(app.view.terminal_area, Rect::new(26, 1, 80, 19));
- assert_eq!(app.view.pane_infos.len(), 2);
- assert!(!app.view.split_borders.is_empty());
- assert!(frame.cursor.is_some());
- assert_eq!(frame.hyperlinks, vec![uri.to_owned()]);
- assert_eq!(
- frame_digest(&frame),
- "a7c21fa42305a41231c7ae254f264f6ef923f46301d8fc4cd35ab6dfdd651b6b"
- );
- }
-
- #[tokio::test]
- async fn mobile_full_app_semantic_frame_is_characterized() {
- let mut app = full_app_characterization_state("https://example.com/mobile");
- app.mode = Mode::Navigate;
- let frame = full_app_frame(&mut app, Rect::new(0, 0, 44, 20));
-
- assert_eq!((frame.width, frame.height), (44, 20));
- assert_eq!(app.view.layout, crate::app::state::ViewLayout::Mobile);
- assert_eq!(app.view.mobile_header_rect, Rect::new(0, 0, 44, 2));
- assert_eq!(app.view.terminal_area, Rect::new(0, 2, 44, 18));
- assert_eq!(frame.cursor, None);
- assert_eq!(
- frame_digest(&frame),
- "295608a66067f1e1f066c0adb3cf427e8a2d68bba8f68949fb72d464dcd8baab"
- );
- }
}
diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs
deleted file mode 100644
index 603422fb..00000000
--- a/src/ui/tabs.rs
+++ /dev/null
@@ -1,788 +0,0 @@
-use ratatui::{
- layout::Rect,
- style::{Modifier, Style},
- widgets::Paragraph,
- Frame,
-};
-
-use super::text::display_width_u16;
-use super::widgets::panel_contrast_fg;
-use crate::app::AppState;
-
-const MIN_TAB_WIDTH: u16 = 8;
-const NEW_TAB_WIDTH: u16 = 3;
-const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
-const ZOOM_INDICATOR: &str = "ZOOM";
-// The narrowest overflowing tab strip worth keeping interactive: one
-// minimum-width tab, both scroll controls, and the new-tab control.
-const MIN_TAB_STRIP_WIDTH: u16 =
- MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2);
-
-#[derive(Debug, Clone, Default)]
-pub(crate) struct TabBarView {
- pub scroll: usize,
- pub tab_hit_areas: Vec<Rect>,
- pub scroll_left_hit_area: Rect,
- pub scroll_right_hit_area: Rect,
- pub new_tab_hit_area: Rect,
-}
-
-fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 {
- display_width_u16(&tab_chrome_label(ws, tab_idx))
- .saturating_add(4)
- .max(MIN_TAB_WIDTH)
-}
-
-fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String {
- let name = ws
- .tab_display_name(tab_idx)
- .unwrap_or_else(|| (tab_idx + 1).to_string());
- if ws.tabs.get(tab_idx).is_some_and(|tab| tab.zoomed) {
- format!("{name} Z")
- } else {
- name
- }
-}
-
-#[derive(Clone, Copy)]
-struct VisibleStatusSegment<'a> {
- text: &'a str,
- accent: bool,
-}
-
-fn visible_status_segments(app: &AppState) -> Vec<VisibleStatusSegment<'_>> {
- let zoomed = app
- .active
- .and_then(|index| app.workspaces.get(index))
- .is_some_and(|workspace| workspace.zoomed);
- app.tab_bar_right
- .iter()
- .filter_map(|segment| match segment {
- crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment {
- text: ZOOM_INDICATOR,
- accent: true,
- }),
- crate::app::state::TabBarStatusSegment::Text(Some(text))
- if display_width_u16(text) > 0 =>
- {
- Some(VisibleStatusSegment {
- text,
- accent: false,
- })
- }
- crate::app::state::TabBarStatusSegment::Zoom
- | crate::app::state::TabBarStatusSegment::Text(_) => None,
- })
- .collect()
-}
-
-fn tab_bar_status_width(app: &AppState) -> u16 {
- let segments = visible_status_segments(app);
- let content_width = segments.iter().fold(0_u16, |width, segment| {
- width.saturating_add(display_width_u16(segment.text))
- });
- let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX);
- content_width
- .saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators))
-}
-
-fn tab_bar_status_area(app: &AppState, area: Rect) -> Option<Rect> {
- let width = tab_bar_status_width(app);
- if width == 0 {
- return None;
- }
- let reserved = width.saturating_add(1);
- (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH)
- .then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1))
-}
-
-// Tabs win over status decoration on narrow rows. The extra reserved cell is
-// the gap between the interactive strip and the right-aligned status entries.
-pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect {
- let reserved = tab_bar_status_area(app, area)
- .map(|status| status.width.saturating_add(1))
- .unwrap_or(0);
- Rect {
- width: area.width.saturating_sub(reserved),
- ..area
- }
-}
-
-fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
- let mut rects = vec![Rect::default(); ws.tabs.len()];
- if area.width == 0 || area.height == 0 {
- return rects;
- }
-
- let mut x = area.x;
- let right = area.x + area.width;
- for (idx, rect) in rects.iter_mut().enumerate().skip(scroll) {
- if x >= right {
- break;
- }
- let desired = tab_width(ws, idx);
- let remaining = right.saturating_sub(x);
- let width = desired.min(remaining).max(1);
- *rect = Rect::new(x, area.y, width, 1);
- x = x.saturating_add(width + 1);
- }
- rects
-}
-
-fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
- let mut best_scroll = ws.active_tab;
- let mut best_distance = u16::MAX;
- let viewport_center = area.x.saturating_mul(2).saturating_add(area.width);
-
- for scroll in 0..=ws.active_tab {
- let rects = layout_tab_hit_areas(ws, area, scroll);
- let Some(active_rect) = rects.get(ws.active_tab).copied() else {
- continue;
- };
- if active_rect.width == 0 {
- continue;
- }
-
- let active_center = active_rect
- .x
- .saturating_mul(2)
- .saturating_add(active_rect.width);
- let distance = active_center.abs_diff(viewport_center);
- if distance <= best_distance {
- best_distance = distance;
- best_scroll = scroll;
- }
- }
-
- best_scroll
-}
-
-fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 {
- tab_hit_areas
- .iter()
- .rev()
- .find(|rect| rect.width > 0)
- .map(|rect| rect.x + rect.width)
- .unwrap_or(fallback_x)
-}
-
-fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
- (0..ws.tabs.len())
- .find(|&scroll| {
- layout_tab_hit_areas(ws, area, scroll)
- .last()
- .is_some_and(|rect| rect.width > 0)
- })
- .unwrap_or(0)
-}
-
-pub(crate) fn compute_tab_bar_view(
- ws: &crate::workspace::Workspace,
- area: Rect,
- current_scroll: usize,
- follow_active: bool,
- mouse_chrome: bool,
-) -> TabBarView {
- if area.width == 0 || area.height == 0 {
- return TabBarView::default();
- }
-
- if !mouse_chrome {
- let max_scroll = max_tab_scroll(ws, area);
- let scroll = if follow_active {
- centered_tab_scroll(ws, area).min(max_scroll)
- } else {
- current_scroll.min(max_scroll)
- };
- return TabBarView {
- scroll,
- tab_hit_areas: layout_tab_hit_areas(ws, area, scroll),
- scroll_left_hit_area: Rect::default(),
- scroll_right_hit_area: Rect::default(),
- new_tab_hit_area: Rect::default(),
- };
- }
-
- let area_right = area.x + area.width;
- let all_tabs_area = Rect::new(
- area.x,
- area.y,
- area.width.saturating_sub(NEW_TAB_WIDTH),
- area.height,
- );
- let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0);
- let overflow = all_tabs.iter().any(|rect| rect.width == 0);
- if !overflow {
- let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x);
- let new_tab_hit_area = Rect::new(
- new_tab_x,
- area.y,
- area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
- 1,
- );
- return TabBarView {
- scroll: 0,
- tab_hit_areas: all_tabs,
- scroll_left_hit_area: Rect::default(),
- scroll_right_hit_area: Rect::default(),
- new_tab_hit_area,
- };
- }
-
- let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1);
- let tab_area_x = left_hit_area.x + left_hit_area.width;
- let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH);
- let tab_area_right = area_right.saturating_sub(reserved_trailing_width);
- let tab_area = Rect::new(
- tab_area_x,
- area.y,
- tab_area_right.saturating_sub(tab_area_x),
- area.height,
- );
-
- let max_scroll = max_tab_scroll(ws, tab_area);
- let scroll = if follow_active {
- centered_tab_scroll(ws, tab_area).min(max_scroll)
- } else {
- current_scroll.min(max_scroll)
- };
- let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll);
- let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right);
- let right_hit_area = Rect::new(
- trailing_x,
- area.y,
- area_right
- .saturating_sub(trailing_x)
- .min(TAB_SCROLL_BUTTON_WIDTH),
- 1,
- );
- let new_tab_x = right_hit_area.x + right_hit_area.width;
- let new_tab_hit_area = Rect::new(
- new_tab_x,
- area.y,
- area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
- 1,
- );
-
- TabBarView {
- scroll,
- tab_hit_areas,
- scroll_left_hit_area: left_hit_area,
- scroll_right_hit_area: right_hit_area,
- new_tab_hit_area,
- }
-}
-
-fn tab_drop_indicator_x(
- app: &AppState,
- ws: &crate::workspace::Workspace,
- insert_idx: usize,
-) -> Option<u16> {
- let mut visible_tabs = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .filter(|(_, rect)| rect.width > 0);
- let first_visible = visible_tabs.clone().next()?;
- let last_visible = visible_tabs.next_back().unwrap_or(first_visible);
-
- if insert_idx == 0 {
- return Some(if first_visible.0 == 0 {
- first_visible.1.x
- } else {
- app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
- });
- }
-
- if let Some((_, rect)) = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find(|(idx, rect)| *idx == insert_idx && rect.width > 0)
- {
- return Some(rect.x.saturating_sub(1));
- }
-
- if insert_idx >= ws.tabs.len() {
- return Some(if last_visible.0 + 1 >= ws.tabs.len() {
- last_visible.1.x + last_visible.1.width
- } else {
- app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
- });
- }
-
- None
-}
-
-pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let Some(active_ws_idx) = app.active else {
- return;
- };
- let Some(ws) = app.workspaces.get(active_ws_idx) else {
- return;
- };
- let p = &app.palette;
-
- frame.render_widget(
- Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
- area,
- );
-
- let first_visible_idx = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find(|(_, rect)| rect.width > 0)
- .map(|(idx, _)| idx);
- let last_visible_idx = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .rev()
- .find(|(_, rect)| rect.width > 0)
- .map(|(idx, _)| idx);
- let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0;
- let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
- && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
-
- if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 {
- let style = if can_scroll_left {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- };
- frame.render_widget(
- Paragraph::new(" < ").style(style),
- app.view.tab_scroll_left_hit_area,
- );
- }
-
- if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
- let style = if can_scroll_right {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- };
- frame.render_widget(
- Paragraph::new(" > ").style(style),
- app.view.tab_scroll_right_hit_area,
- );
- }
-
- for (idx, tab) in ws.tabs.iter().enumerate() {
- let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
- break;
- };
- if rect.width == 0 {
- continue;
- }
- let active = idx == ws.active_tab;
- let style = if active {
- let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent);
- if tab.is_auto_named() {
- base
- } else {
- base.add_modifier(Modifier::BOLD)
- }
- } else if tab.is_auto_named() {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- } else {
- Style::default().fg(p.overlay1).bg(p.surface0)
- };
- let width = rect.width as usize;
- let name = tab_chrome_label(ws, idx);
- // Pad by terminal columns, not chars, so wide glyphs stay centered.
- let padding = width.saturating_sub(display_width_u16(&name) as usize);
- let left = padding / 2;
- let text = format!(
- "{empty:left$}{name}{empty:right$}",
- empty = "",
- right = padding - left
- );
- frame.render_widget(Paragraph::new(text).style(style), rect);
- }
-
- if let Some(crate::app::state::DragState {
- target:
- crate::app::state::DragTarget::TabReorder {
- ws_idx,
- insert_idx: Some(insert_idx),
- ..
- },
- }) = &app.drag
- {
- if *ws_idx == active_ws_idx {
- if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) {
- frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)]
- .set_symbol("│")
- .set_style(Style::default().fg(p.accent));
- }
- }
- }
-
- if app.mouse_capture && app.view.new_tab_hit_area.width > 0 {
- frame.render_widget(
- Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
- app.view.new_tab_hit_area,
- );
- }
-
- if first_visible_idx.is_some_and(|idx| idx > 0) {
- let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 {
- app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
- } else {
- area.x
- };
- if x < area.x + area.width {
- frame.buffer_mut()[(x, area.y)]
- .set_symbol("…")
- .set_style(Style::default().fg(p.overlay0));
- }
- }
- if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
- let content = tab_bar_content_area(app, area);
- let content_right = content.x + content.width;
- let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
- app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
- } else {
- content_right.saturating_sub(1)
- };
- if x >= area.x && x < area.x + area.width {
- frame.buffer_mut()[(x, area.y)]
- .set_symbol("…")
- .set_style(Style::default().fg(p.overlay0));
- }
- }
-
- if let Some(status_area) = tab_bar_status_area(app, area) {
- let segments = visible_status_segments(app);
- let separator_width = display_width_u16(&app.tab_bar_right_separator);
- let mut x = status_area.x;
- for (index, segment) in segments.iter().enumerate() {
- if index > 0 && separator_width > 0 {
- let rect = Rect::new(x, area.y, separator_width, 1);
- frame.render_widget(
- Paragraph::new(app.tab_bar_right_separator.as_str())
- .style(Style::default().fg(p.overlay0).bg(p.panel_bg)),
- rect,
- );
- x = x.saturating_add(separator_width);
- }
-
- let width = display_width_u16(segment.text);
- let rect = Rect::new(x, area.y, width, 1);
- let style = if segment.accent {
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.accent)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.overlay1).bg(p.panel_bg)
- };
- frame.render_widget(Paragraph::new(segment.text).style(style), rect);
- x = x.saturating_add(width);
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::app::state::AppState;
- use crate::workspace::Workspace;
- use ratatui::{backend::TestBackend, Terminal};
-
- fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String {
- (area.x..area.x + area.width)
- .map(|x| buffer[(x, row)].symbol())
- .collect::<String>()
- .trim_end()
- .to_string()
- }
-
- #[test]
- fn tab_bar_marks_zoomed_tabs_without_renaming_them() {
- let mut app = AppState::test_new();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].zoomed = true;
- let custom_tab = ws.test_add_tab(Some("test"));
- ws.tabs[custom_tab].zoomed = true;
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
- let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas;
-
- let backend = TestBackend::new(30, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0);
- assert!(row.contains(" 1 Z"), "tab row: {row:?}");
- assert!(row.contains(" test Z"), "tab row: {row:?}");
- assert_eq!(app.workspaces[0].tab_display_name(0).as_deref(), Some("1"));
- assert_eq!(
- app.workspaces[0].tab_display_name(custom_tab).as_deref(),
- Some("test")
- );
- }
-
- #[test]
- fn tab_bar_renders_ordered_status_entries_with_separator() {
- let mut app = AppState::test_new();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].zoomed = true;
- app.tab_bar_right = vec![
- crate::app::state::TabBarStatusSegment::Zoom,
- crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())),
- crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())),
- ];
- app.tab_bar_right_separator = " · ".into();
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 60, 1);
- let content = tab_bar_content_area(&app, app.view.tab_bar_rect);
- let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas.clone();
-
- let backend = TestBackend::new(60, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- let buffer = terminal.backend().buffer();
- let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0);
- assert!(
- row.ends_with("ZOOM · wintermute · 14:30"),
- "tab row: {row:?}"
- );
- let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30");
- assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent));
- for rect in &view.tab_hit_areas {
- assert!(rect.x + rect.width <= content.x + content.width);
- }
- }
-
- #[test]
- fn hidden_status_entries_do_not_leave_dangling_separators() {
- let mut app = AppState::test_new();
- app.tab_bar_right = vec![
- crate::app::state::TabBarStatusSegment::Zoom,
- crate::app::state::TabBarStatusSegment::Text(None),
- crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())),
- ];
- app.tab_bar_right_separator = " | ".into();
- app.workspaces = vec![Workspace::test_new("test")];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 40, 1);
- let content = tab_bar_content_area(&app, app.view.tab_bar_rect);
- let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas;
-
- let backend = TestBackend::new(40, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0);
- assert!(row.ends_with("wintermute"), "tab row: {row:?}");
- assert!(!row.contains(" | "), "tab row: {row:?}");
- }
-
- #[test]
- fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() {
- let mut app = AppState::test_new();
- app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some(
- "x".into(),
- ))];
- let mut workspace = Workspace::test_new("test");
- workspace.test_add_tab(None);
- workspace.test_add_tab(None);
- app.workspaces = vec![workspace];
- app.active = Some(0);
-
- let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1);
- assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow);
-
- let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1);
- let content = tab_bar_content_area(&app, wide_enough);
- assert_eq!(content.width, MIN_TAB_STRIP_WIDTH);
- let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true);
- assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH);
- }
-
- #[test]
- fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() {
- let mut app = AppState::test_new();
- app.tab_bar_right = vec![
- crate::app::state::TabBarStatusSegment::Text(Some(
- "a-hostname-wider-than-the-whole-bar".into(),
- )),
- crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())),
- ];
- app.workspaces = vec![Workspace::test_new("test")];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
-
- assert_eq!(
- tab_bar_content_area(&app, app.view.tab_bar_rect),
- app.view.tab_bar_rect
- );
- assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None);
-
- let view = compute_tab_bar_view(
- &app.workspaces[0],
- tab_bar_content_area(&app, app.view.tab_bar_rect),
- 0,
- true,
- true,
- );
- assert!(view.tab_hit_areas[0].width > 0);
- assert!(view.new_tab_hit_area.width > 0);
- }
-
- #[test]
- fn cjk_tab_labels_are_centered_by_display_width() {
- let mut app = AppState::test_new();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("提交 herdr 的反馈".into());
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
- let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas;
-
- let backend = TestBackend::new(30, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- // 17 display columns + 4 padding: two columns each side, wide glyphs
- // starting right after the left padding.
- let rect = app.view.tab_hit_areas[0];
- assert_eq!(rect.width, 21);
- let buffer = terminal.backend().buffer();
- assert_eq!(buffer[(rect.x, rect.y)].symbol(), " ");
- assert_eq!(buffer[(rect.x + 1, rect.y)].symbol(), " ");
- assert_eq!(buffer[(rect.x + 2, rect.y)].symbol(), "提");
- assert_eq!(buffer[(rect.x + rect.width - 2, rect.y)].symbol(), " ");
- assert_eq!(buffer[(rect.x + rect.width - 1, rect.y)].symbol(), " ");
- }
-
- #[test]
- fn tab_labels_are_centered_in_their_cells() {
- let mut app = AppState::test_new();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("omarchy".into());
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
- let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas;
-
- let backend = TestBackend::new(30, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- let rect = app.view.tab_hit_areas[0];
- let buffer = terminal.backend().buffer();
- let cell: String = (rect.x..rect.x + rect.width)
- .map(|x| buffer[(x, rect.y)].symbol())
- .collect();
- assert_eq!(cell, " omarchy ");
- }
-
- #[test]
- fn active_auto_named_tab_keeps_readable_weight() {
- let mut app = AppState::test_new();
- let ws = Workspace::test_new("test");
-
- app.workspaces = vec![ws];
- app.active = Some(0);
- app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
- let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas;
-
- let backend = TestBackend::new(30, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- let tab_rect = app.view.tab_hit_areas[0];
- let style = terminal.backend().buffer()[(tab_rect.x + 1, tab_rect.y)].style();
-
- assert_eq!(style.bg, Some(app.palette.accent));
- assert!(!style.add_modifier.contains(Modifier::DIM));
- assert!(!style.add_modifier.contains(Modifier::BOLD));
- }
-
- #[test]
- fn zoom_marker_counts_toward_tab_width() {
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("abcdefgh".into());
- ws.tabs[0].zoomed = true;
-
- assert_eq!(tab_width(&ws, 0), 14);
- }
-
- #[test]
- fn tab_width_uses_display_width_for_cjk_labels() {
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("提交 herdr 的反馈".into());
-
- assert_eq!(
- tab_width(&ws, 0),
- display_width_u16("提交 herdr 的反馈") + 4
- );
- }
-
- #[test]
- fn tab_bar_renders_trailing_cjk_character() {
- let mut app = AppState::test_new();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("提交 herdr 的反馈".into());
-
- app.active = Some(0);
- app.workspaces = vec![ws];
- app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
- let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false);
- app.view.tab_hit_areas = view.tab_hit_areas;
-
- let backend = TestBackend::new(30, 1);
- let mut terminal = Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
- .unwrap();
-
- let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0);
- assert!(row.contains('馈'), "tab row: {row:?}");
- }
-}
diff --git a/src/ui/text.rs b/src/ui/text.rs
index 45f7983b..9b5a7c68 100644
--- a/src/ui/text.rs
+++ b/src/ui/text.rs
@@ -4,10 +4,6 @@ pub(crate) fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
-pub(crate) fn display_width_u16(text: &str) -> u16 {
- display_width(text).min(u16::MAX as usize) as u16
-}
-
pub(crate) fn truncate_end(text: &str, max_width: usize) -> String {
if display_width(text) <= max_width {
return text.to_string();
@@ -23,22 +19,6 @@ pub(crate) fn truncate_end(text: &str, max_width: usize) -> String {
format!("{prefix}…")
}
-pub(crate) fn middle_elide(text: &str, max_width: usize) -> String {
- if display_width(text) <= max_width {
- return text.to_string();
- }
- if max_width <= 1 {
- return "…".to_string();
- }
-
- let content_width = max_width.saturating_sub(1);
- let left_width = content_width / 2;
- let right_width = content_width.saturating_sub(left_width);
- let prefix = take_prefix_width(text, left_width);
- let suffix = take_suffix_width(text, right_width);
- format!("{prefix}…{suffix}")
-}
-
fn take_prefix_width(text: &str, max_width: usize) -> String {
let mut output = String::new();
let mut width = 0usize;
@@ -53,20 +33,6 @@ fn take_prefix_width(text: &str, max_width: usize) -> String {
output
}
-fn take_suffix_width(text: &str, max_width: usize) -> String {
- let mut output = Vec::new();
- let mut width = 0usize;
- for ch in text.chars().rev() {
- let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
- if width + ch_width > max_width {
- break;
- }
- output.push(ch);
- width += ch_width;
- }
- output.into_iter().rev().collect()
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -78,12 +44,4 @@ mod tests {
assert_eq!(text, "提交 herdr 的反…");
assert!(display_width(&text) <= 16);
}
-
- #[test]
- fn middle_elide_uses_display_width() {
- let text = middle_elide("重构用户认证模块并迁移到统一登录服务", 12);
-
- assert!(text.contains('…'));
- assert!(display_width(&text) <= 12);
- }
}
diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs
index ef416134..33fcc9a0 100644
--- a/src/ui/widgets.rs
+++ b/src/ui/widgets.rs
@@ -1,70 +1,30 @@
use ratatui::{
- layout::{Alignment, Constraint, Layout, Rect},
- style::{Color, Modifier, Style},
- text::{Line, Span},
- widgets::{Block, Borders, Clear, Paragraph, Wrap},
- Frame,
+ layout::{Constraint, Layout, Rect},
+ style::Color,
};
use crate::app::state::Palette;
-pub(super) fn render_panel_shell(
- frame: &mut Frame,
- area: Rect,
- border_color: Color,
- bg: Color,
-) -> Option<Rect> {
- if area.width < 2 || area.height < 2 {
- return None;
- }
-
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(border_color))
- .border_set(ratatui::symbols::border::PLAIN)
- .style(Style::default().bg(bg));
- let inner = block.inner(area);
- frame.render_widget(Clear, area);
- frame.render_widget(block, area);
- Some(inner)
-}
-
-pub(super) fn panel_contrast_fg(p: &Palette) -> Color {
- match p.panel_bg {
- Color::Reset => p.surface_dim,
+pub(super) fn panel_contrast_fg(palette: &Palette) -> Color {
+ match palette.panel_bg {
+ Color::Reset => palette.surface_dim,
color => color,
}
}
-pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> {
- let popup_w = popup_w.min(area.width.saturating_sub(4));
- let popup_h = popup_h.min(area.height.saturating_sub(2));
- if popup_w < 4 || popup_h < 4 {
+pub(crate) fn centered_popup_rect(area: Rect, popup_width: u16, popup_height: u16) -> Option<Rect> {
+ let popup_width = popup_width.min(area.width.saturating_sub(4));
+ let popup_height = popup_height.min(area.height.saturating_sub(2));
+ if popup_width < 4 || popup_height < 4 {
return None;
}
- let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
- let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
- Some(Rect::new(popup_x, popup_y, popup_w, popup_h))
-}
-
-pub(super) fn render_modal_shell(
- frame: &mut Frame,
- area: Rect,
- popup_w: u16,
- popup_h: u16,
- p: &Palette,
-) -> Option<Rect> {
- let popup = centered_popup_rect(area, popup_w, popup_h)?;
- render_panel_shell(frame, popup, p.accent, p.panel_bg)
-}
-
-pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
- let line = Line::from(vec![Span::styled(
- title,
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- )]);
- frame.render_widget(Paragraph::new(line), area);
+ Some(Rect::new(
+ area.x + area.width.saturating_sub(popup_width) / 2,
+ area.y + area.height.saturating_sub(popup_height) / 2,
+ popup_width,
+ popup_height,
+ ))
}
#[derive(Debug, Clone, Copy)]
@@ -110,169 +70,40 @@ pub(crate) fn modal_stack_areas(
}
let areas = Layout::vertical(constraints).split(inner);
- let mut header = Rect::default();
- let mut content = Rect::default();
- let mut footer = None;
- let mut actions = None;
-
+ let mut result = ModalStackAreas {
+ header: Rect::default(),
+ content: Rect::default(),
+ footer: None,
+ actions: None,
+ };
for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) {
match slot {
- Slot::Header => header = area,
- Slot::Content => content = area,
- Slot::Footer => footer = Some(area),
- Slot::Actions => actions = Some(area),
+ Slot::Header => result.header = area,
+ Slot::Content => result.content = area,
+ Slot::Footer => result.footer = Some(area),
+ Slot::Actions => result.actions = Some(area),
}
}
-
- ModalStackAreas {
- header,
- content,
- footer,
- actions,
- }
+ result
}
-pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String {
+fn action_button_width(hint: Option<&str>, label: &str) -> u16 {
match hint {
- Some(hint) => format!(" {hint} {label} "),
- None => format!(" {label} "),
+ Some(hint) => format!(" {hint} {label} ").chars().count() as u16,
+ None => format!(" {label} ").chars().count() as u16,
}
}
-pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 {
- action_button_text(hint, label).chars().count() as u16
+pub(crate) fn close_button_rect(area: Rect) -> Rect {
+ let width = action_button_width(Some("esc"), "close");
+ Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)
}
-pub(crate) struct ActionButtonSpec<'a> {
- pub hint: Option<&'a str>,
- pub label: &'a str,
-}
-
-pub(crate) fn action_button_row_rects(
- area: Rect,
- buttons: &[ActionButtonSpec<'_>],
- gap: u16,
- row_offset: u16,
-) -> Vec<Rect> {
- let widths: Vec<u16> = buttons
- .iter()
- .map(|button| action_button_width(button.hint, button.label))
- .collect();
- centered_button_row(area, &widths, gap, row_offset)
-}
-
-pub(super) fn render_action_button(
- frame: &mut Frame,
- rect: Rect,
- hint: Option<&str>,
- label: &str,
- style: Style,
-) {
- frame.render_widget(
- Paragraph::new(action_button_text(hint, label))
- .style(style)
- .alignment(Alignment::Center),
- rect,
- );
-}
-
-pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) {
- frame.render_widget(
- Paragraph::new(format!(" {text}"))
- .style(style)
- .wrap(Wrap { trim: false }),
- area,
- );
-}
-
-pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec<Rect> {
- let mut rows = Vec::with_capacity(count);
- let mut y = area.y;
- for _ in 0..count {
- if y >= area.y + area.height {
- break;
- }
- let remaining = area.y + area.height - y;
- let height = row_height.min(remaining);
- rows.push(Rect::new(area.x, y, area.width, height));
- y = y.saturating_add(row_height);
- }
- rows
-}
-
-pub(crate) fn render_modal_choice_list<T>(
- frame: &mut Frame,
- area: Rect,
- title: &str,
- description: &str,
- options: &[(&str, T)],
- current_value: T,
- selected_idx: usize,
- p: &Palette,
- row_height: u16,
-) where
- T: Copy + PartialEq,
-{
- let [desc_area, _, list_area] = Layout::vertical([
- Constraint::Length(2),
- Constraint::Length(1),
- Constraint::Min(2),
- ])
- .areas::<3>(area);
-
- render_modal_description(
- frame,
- desc_area,
- description,
- Style::default().fg(p.overlay1),
- );
-
- let rows = modal_choice_rows(list_area, options.len(), row_height);
- for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() {
- let is_active = *value == current_value;
- let is_selected = idx == selected_idx;
- let marker = if is_active { " ✓" } else { "" };
- let style = if is_selected {
- Style::default()
- .bg(p.surface0)
- .fg(p.text)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.subtext0)
- };
- frame.render_widget(
- Paragraph::new(format!(" {title}: {label}{marker}"))
- .style(style)
- .wrap(Wrap { trim: false }),
- *row,
- );
- }
-}
-
-pub(super) fn centered_button_row(
- inner: Rect,
- widths: &[u16],
- gap: u16,
- row_offset: u16,
-) -> Vec<Rect> {
- let total_w = widths
- .iter()
- .copied()
- .sum::<u16>()
- .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16));
- let mut x = inner.x + inner.width.saturating_sub(total_w) / 2;
- let y = inner.y + row_offset.min(inner.height.saturating_sub(1));
- widths
- .iter()
- .map(|w| {
- let rect = Rect::new(
- x,
- y,
- (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))),
- 1,
- );
- x = x.saturating_add(*w).saturating_add(gap);
- rect
- })
- .collect()
+pub(crate) fn continue_button_rect(area: Rect) -> Rect {
+ Rect::new(
+ area.x,
+ area.y,
+ action_button_width(Some("↵"), "continue"),
+ 1,
+ )
}