Files
herdr/src/ui.rs
T

3403 lines
109 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
Frame,
};
use crate::app::state::{AgentPanelScope, ToastKind, ToastNotification};
use crate::app::{AppState, Mode};
use crate::detect::AgentState;
use crate::layout::PaneInfo;
const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18;
pub(crate) const MAX_SIDEBAR_WIDTH: u16 = 36;
// Braille spinner frames — smooth rotation
const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame.
/// We want ~8 updates/sec so divide by 8.
fn spinner_frame(tick: u32) -> &'static str {
SPINNERS[(tick as usize / 8) % SPINNERS.len()]
}
use crate::app::state::Palette;
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 agent_label: Option<String>,
pub state: AgentState,
pub seen: bool,
}
fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
if total_h == 0 {
return (0, 0);
}
if total_h < 6 {
let ws_h = (total_h + 1) / 2;
return (ws_h, total_h.saturating_sub(ws_h));
}
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)
}
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 {
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)
}
pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
if content.width == 0 || content.height < 6 {
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_current_workspace_idx(app: &AppState) -> Option<usize> {
if matches!(
app.mode,
Mode::Navigate
| Mode::RenameWorkspace
| Mode::Resize
| Mode::ConfirmClose
| Mode::ContextMenu
| Mode::Settings
| Mode::GlobalMenu
| Mode::KeybindHelp
) {
Some(app.selected)
} else {
app.active
}
}
fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
match scope {
AgentPanelScope::CurrentWorkspace => "current",
AgentPanelScope::AllWorkspaces => "all",
}
}
pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
if area.width == 0 || area.height < 2 {
return Rect::default();
}
let label = agent_panel_toggle_label(scope);
let width = label.chars().count() as u16;
Rect::new(
area.x + area.width.saturating_sub(width),
area.y + 1,
width,
1,
)
}
pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
match app.agent_panel_scope {
AgentPanelScope::CurrentWorkspace => {
let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
return Vec::new();
};
let Some(ws) = app.workspaces.get(ws_idx) else {
return Vec::new();
};
ws.pane_details()
.into_iter()
.map(|detail| AgentPanelEntry {
ws_idx,
tab_idx: detail.tab_idx,
pane_id: detail.pane_id,
primary_label: detail.label,
primary_tab_label: None,
agent_label: None,
state: detail.state,
seen: detail.seen,
})
.collect()
}
AgentPanelScope::AllWorkspaces => app
.workspaces
.iter()
.enumerate()
.flat_map(|(ws_idx, ws)| {
let multi_tab = ws.tabs.len() > 1;
let workspace_label = ws.display_name();
ws.pane_details()
.into_iter()
.map(move |detail| AgentPanelEntry {
ws_idx,
tab_idx: detail.tab_idx,
pane_id: detail.pane_id,
primary_label: workspace_label.clone(),
primary_tab_label: multi_tab.then_some(detail.tab_label),
agent_label: Some(detail.agent_label),
state: detail.state,
seen: detail.seen,
})
})
.collect(),
}
}
fn truncate_text(text: &str, max_width: usize) -> String {
let len = text.chars().count();
if len <= max_width {
return text.to_string();
}
if max_width == 0 {
return String::new();
}
if max_width == 1 {
return "…".to_string();
}
let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
format!("{prefix}…")
}
fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
let Some(tab_label) = entry.primary_tab_label.as_deref() else {
return truncate_text(&entry.primary_label, max_width);
};
let separator = " · ";
let separator_width = separator.chars().count();
if max_width <= separator_width + 2 {
return truncate_text(
&format!("{}{}{}", entry.primary_label, separator, tab_label),
max_width,
);
}
let available = max_width.saturating_sub(separator_width);
let min_tab = 4.min(available.saturating_sub(1)).max(1);
let preferred_workspace = ((available * 2) / 3).max(1);
let mut workspace_budget = preferred_workspace
.min(available.saturating_sub(min_tab))
.max(1);
let mut tab_budget = available.saturating_sub(workspace_budget);
let workspace_len = entry.primary_label.chars().count();
let tab_len = tab_label.chars().count();
if workspace_len < workspace_budget {
let spare = workspace_budget - workspace_len;
workspace_budget = workspace_len;
tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
}
if tab_len < tab_budget {
let spare = tab_budget - tab_len;
tab_budget = tab_len;
workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
}
format!(
"{}{}{}",
truncate_text(&entry.primary_label, workspace_budget),
separator,
truncate_text(tab_label, tab_budget)
)
}
/// Compute view geometry and reconcile pane sizes.
/// Called before render to separate mutation from drawing.
pub fn compute_view(app: &mut AppState, area: Rect) {
let sidebar_w = if app.sidebar_collapsed {
COLLAPSED_WIDTH
} else {
app.sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
};
let [sidebar_area, main_area] =
Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area);
let has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some();
let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 {
let [tab_bar_rect, terminal_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
(tab_bar_rect, terminal_area)
} else {
(Rect::default(), main_area)
};
app.workspace_scroll = app
.workspace_scroll
.min(app.workspaces.len().saturating_sub(1));
if !app.sidebar_collapsed {
let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split);
let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom;
app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll);
} else {
app.agent_panel_scroll = 0;
}
let workspace_card_areas = if app.sidebar_collapsed {
Vec::new()
} else {
compute_workspace_card_areas(app, sidebar_area)
};
let tab_hit_areas = app
.active
.and_then(|i| app.workspaces.get(i))
.map(|ws| compute_tab_hit_areas(ws, tab_bar_rect))
.unwrap_or_default();
let new_tab_hit_area = compute_new_tab_hit_area(&tab_hit_areas, tab_bar_rect);
let split_borders = app
.active
.and_then(|i| app.workspaces.get(i))
.map(|ws| ws.layout.splits(terminal_area))
.unwrap_or_default();
let pane_infos = compute_pane_infos(app, terminal_area);
app.view = crate::app::ViewState {
sidebar_rect: sidebar_area,
workspace_card_areas,
tab_bar_rect,
tab_hit_areas,
new_tab_hit_area,
terminal_area,
pane_infos,
split_borders,
};
}
/// Render the UI — reads AppState but does not mutate it.
pub fn render(app: &AppState, frame: &mut Frame) {
let sidebar_area = app.view.sidebar_rect;
let tab_bar_area = app.view.tab_bar_rect;
let terminal_area = app.view.terminal_area;
if app.sidebar_collapsed {
render_sidebar_collapsed(app, frame, sidebar_area);
} else {
render_sidebar(app, frame, sidebar_area);
}
render_tab_bar(app, frame, tab_bar_area);
render_panes(app, frame, terminal_area);
match app.mode {
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
Mode::ContextMenu => {
render_context_menu(app, frame);
}
Mode::Settings => render_settings_overlay(app, frame, frame.area()),
Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()),
Mode::GlobalMenu => render_global_launcher_menu(app, frame),
Mode::KeybindHelp => render_keybind_help_overlay(app, frame),
Mode::Terminal => {}
}
// Notifications (rendered on top of everything)
let has_config_diagnostic = app.config_diagnostic.is_some();
if let Some(message) = &app.config_diagnostic {
render_config_diagnostic(frame, terminal_area, message, &app.palette);
}
if let Some(toast) = &app.toast {
render_toast_notification(
frame,
terminal_area,
toast,
has_config_diagnostic,
&app.palette,
);
}
}
const MIN_TAB_WIDTH: u16 = 8;
const NEW_TAB_WIDTH: u16 = 3;
const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
const AGENT_PANEL_HEADER_ROWS: u16 = 3;
fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
if ws.branch().is_some() {
2
} else {
1
}
}
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;
for ws in app.workspaces.iter().skip(scroll) {
let needed = workspace_row_height(ws).saturating_add(1);
if used_rows.saturating_add(needed) > body.height {
break;
}
used_rows = used_rows.saturating_add(needed);
visible += 1;
}
visible
}
pub(crate) fn workspace_list_scroll_metrics(
app: &AppState,
area: Rect,
) -> crate::pane::ScrollMetrics {
let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
let total_rows = app.workspaces.len();
let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
let offset_from_bottom = total_rows
.saturating_sub(app.workspace_scroll)
.saturating_sub(viewport_rows);
crate::pane::ScrollMetrics {
offset_from_bottom,
max_offset_from_bottom,
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 agent_panel_visible_count(area: Rect) -> usize {
let body = agent_panel_body_rect(area, false);
if body.width == 0 || body.height < 2 {
return 0;
}
let mut used_rows = 0u16;
let mut visible = 0usize;
while used_rows.saturating_add(2) <= body.height {
used_rows = used_rows.saturating_add(2);
visible += 1;
if used_rows < body.height {
used_rows = used_rows.saturating_add(1);
}
}
visible
}
pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
let viewport_rows = agent_panel_visible_count(area);
let total_rows = agent_panel_entries(app).len();
let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
let offset_from_bottom = total_rows
.saturating_sub(app.agent_panel_scroll)
.saturating_sub(viewport_rows);
crate::pane::ScrollMetrics {
offset_from_bottom,
max_offset_from_bottom,
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_card_areas(
app: &AppState,
area: Rect,
) -> Vec<crate::app::state::WorkspaceCardArea> {
let ws_area = workspace_list_rect(area, app.sidebar_section_split);
if ws_area == Rect::default() {
return 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();
}
let mut row_y = body.y;
let body_bottom = body.y + body.height;
let mut cards = Vec::new();
for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
let row_height = workspace_row_height(ws);
if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
break;
}
cards.push(crate::app::state::WorkspaceCardArea {
ws_idx,
rect: Rect::new(body.x, row_y, body.width, row_height),
});
row_y = row_y.saturating_add(row_height + 1);
}
cards
}
fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec<Rect> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let mut x = area.x;
let mut rects = Vec::new();
let right = area.x + area.width;
for tab in &ws.tabs {
if x >= right.saturating_sub(NEW_TAB_WIDTH) {
break;
}
let desired = (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH);
let remaining = right.saturating_sub(NEW_TAB_WIDTH).saturating_sub(x);
let width = desired.min(remaining).max(1);
rects.push(Rect::new(x, area.y, width, 1));
x = x.saturating_add(width + 1);
}
rects
}
fn compute_new_tab_hit_area(tab_hit_areas: &[Rect], area: Rect) -> Rect {
if area.width == 0 || area.height == 0 {
return Rect::default();
}
let x = tab_hit_areas
.last()
.map(|rect| rect.x + rect.width + 1)
.unwrap_or(area.x)
.min(area.x + area.width.saturating_sub(NEW_TAB_WIDTH));
Rect::new(x, area.y, NEW_TAB_WIDTH.min(area.width), 1)
}
fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let Some(ws_idx) = app.active else {
return;
};
let Some(ws) = app.workspaces.get(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,
);
for (idx, tab) in ws.tabs.iter().enumerate() {
let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
break;
};
let active = idx == ws.active_tab;
let style = if active {
let base = Style::default().fg(p.panel_bg).bg(p.accent);
if tab.is_auto_named() {
base.add_modifier(Modifier::DIM)
} 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.display_name();
let text = format!(" {:width$}", name, width = width.saturating_sub(1));
frame.render_widget(Paragraph::new(text).style(style), rect);
}
if 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,
);
}
}
/// Compute pane layout info and resize pane runtimes to match.
fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let Some(ws_idx) = app.active else {
return Vec::new();
};
let Some(ws) = app.workspaces.get(ws_idx) else {
return Vec::new();
};
let multi_pane = ws.layout.pane_count() > 1;
let terminal_active = app.mode == Mode::Terminal;
if ws.zoomed {
let focused_id = ws.layout.focused();
let mut inner_rect = area;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&focused_id) {
if rt
.scroll_metrics()
.is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 1)
{
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
area.x + area.width.saturating_sub(1),
area.y,
1,
area.height,
));
}
rt.resize(inner_rect.height, inner_rect.width);
}
return vec![PaneInfo {
id: focused_id,
rect: area,
inner_rect,
scrollbar_rect,
is_focused: true,
}];
}
let mut pane_infos = ws.layout.panes(area);
for info in &mut pane_infos {
let pane_inner = if multi_pane {
let border_set = if info.is_focused && terminal_active {
ratatui::symbols::border::THICK
} else {
ratatui::symbols::border::PLAIN
};
let block = Block::default()
.borders(Borders::ALL)
.border_set(border_set);
block.inner(info.rect)
} else {
area
};
let mut inner_rect = pane_inner;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&info.id) {
if rt
.scroll_metrics()
.is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 1)
{
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
pane_inner.x + pane_inner.width.saturating_sub(1),
pane_inner.y,
1,
pane_inner.height,
));
}
rt.resize(inner_rect.height, inner_rect.width);
}
info.inner_rect = inner_rect;
info.scrollbar_rect = scrollbar_rect;
}
pane_infos
}
/// 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 + 1) / 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)
}
/// Collapsed sidebar: workspace glance on top, compact agent list below.
fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
let is_navigating = matches!(app.mode, Mode::Navigate);
let p = &app.palette;
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();
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
let is_selected = visible_idx == app.selected && is_navigating;
let row_style = if is_selected {
Style::default().bg(p.surface0)
} else {
Style::default()
};
let num_style = if is_selected {
Style::default().fg(p.overlay1).bg(p.surface0)
} else {
Style::default().fg(p.overlay0)
};
if is_selected {
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!("{}", visible_idx + 1), num_style),
Span::styled(" ", row_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();
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(p.surface_dim));
}
}
let detail_ws_idx = if is_navigating {
Some(app.selected)
} else {
app.active
};
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() {
if let Some(ws_idx) = detail_ws_idx {
if let Some(ws) = app.workspaces.get(ws_idx) {
for (detail_idx, detail) in ws.pane_details().iter().enumerate() {
let y = detail_content_area.y + detail_idx as u16;
if y >= detail_content_area.y + detail_content_area.height {
break;
}
let pane_num = ws
.public_pane_number(detail.pane_id)
.unwrap_or(detail_idx + 1);
let pane_style = Style::default().fg(p.overlay0);
let (icon, icon_style) =
agent_icon(detail.state, detail.seen, app.spinner_tick, p);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{pane_num}"), pane_style),
Span::styled(" ", pane_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_indicator_row(
cards: &[crate::app::state::WorkspaceCardArea],
area: Rect,
insert_idx: usize,
) -> Option<u16> {
if area.height == 0 {
return None;
}
let list_bottom = area.y + area.height.saturating_sub(1);
let first = cards.first()?;
if insert_idx == first.ws_idx {
return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
}
if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
}
cards
.last()
.filter(|card| insert_idx == card.ws_idx.saturating_add(1))
.map(|card| card.rect.y.saturating_add(card.rect.height))
.filter(|y| *y < list_bottom)
}
fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
let p = &app.palette;
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)
};
// Right border
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);
// --- Top section: Workspaces ---
render_workspace_list(app, frame, ws_area, is_navigating);
// --- Bottom section: Agent detail ---
render_agent_detail(app, frame, detail_area);
render_sidebar_toggle(app, frame, area, false, p);
}
/// Render the workspace list in the top section of the sidebar.
fn render_workspace_list(app: &AppState, 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 {
insert_idx: Some(insert_idx),
..
}) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx),
_ => None,
};
// Section header + reserve last row for "new" button
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;
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();
if highlighted {
let bg = if selected {
p.surface0
} else if is_dragged {
p.surface1
} else {
p.surface_dim
};
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));
}
}
}
if is_active {
let buf = frame.buffer_mut();
for y in row_y..row_y + row_height {
if y >= list_bottom {
break;
}
buf[(card.rect.x, y)].set_symbol("▌");
buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
}
}
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 num_style = if selected || is_active {
Style::default().fg(p.overlay1)
} else {
Style::default().fg(p.overlay0)
};
let mut line1 = vec![
Span::styled(" ", Style::default()),
Span::styled(format!("{} ", i + 1), num_style),
Span::styled(ws.display_name(), name_style),
Span::styled(" ", Style::default()),
];
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
line1.push(Span::styled(icon, icon_style));
frame.render_widget(
Paragraph::new(Line::from(line1)),
Rect::new(card.rect.x, row_y, card.rect.width, 1),
);
if row_height > 1 && row_y + 1 < list_bottom {
if let Some(branch) = ws.branch() {
let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
let mut parts = Vec::new();
if ahead > 0 {
parts.push((format!("↑{}", ahead), p.green));
}
if behind > 0 {
parts.push((format!("↓{}", behind), p.red));
}
(!parts.is_empty()).then_some(parts)
});
let reserved = upstream_label
.as_ref()
.map(|parts| {
parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
})
.unwrap_or(0);
let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved);
let branch_display = if branch.len() > max_branch_len {
format!("{}…", &branch[..max_branch_len.saturating_sub(1)])
} else {
branch
};
let branch_color = if selected || is_active {
p.mauve
} else {
p.overlay0
};
let mut spans = vec![
Span::styled(" ", Style::default()),
Span::styled(branch_display, Style::default().fg(branch_color)),
];
if let Some(parts) = upstream_label {
spans.push(Span::styled(" ", Style::default()));
for (idx, (label, color)) in parts.into_iter().enumerate() {
if idx > 0 {
spans.push(Span::styled(" ", Style::default()));
}
spans.push(Span::styled(label, Style::default().fg(color)));
}
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
);
}
}
}
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, "▕");
}
// Footer actions for workspace section
if 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.update_available.is_some() {
Line::from(vec![
Span::styled("menu", Style::default().fg(p.overlay0)),
Span::styled(
" ●",
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
),
])
} else {
Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))])
};
frame.render_widget(
Paragraph::new(menu_line).alignment(Alignment::Right),
menu_rect,
);
}
}
/// Render the agent detail panel in the bottom section of the sidebar.
fn render_agent_detail(app: &AppState, 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 toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
if toggle_rect != Rect::default() {
frame.render_widget(
Paragraph::new(Span::styled(
agent_panel_toggle_label(app.agent_panel_scope),
Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Right),
toggle_rect,
);
}
let details = agent_panel_entries(app);
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;
}
let mut row_y = body.y;
let body_bottom = body.y + body.height;
for detail in details.iter().skip(app.agent_panel_scroll) {
if row_y.saturating_add(1) >= body_bottom {
break;
}
let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
let label_color = state_label_color(detail.state, detail.seen, p);
let label = state_label(detail.state, detail.seen);
let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD);
let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM);
let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
let primary_label =
format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
let name_line = Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(icon, icon_style),
Span::styled(" ", Style::default()),
Span::styled(primary_label, name_style),
]);
frame.render_widget(
Paragraph::new(name_line),
Rect::new(body.x, row_y, body.width, 1),
);
row_y += 1;
let mut status_spans = vec![
Span::styled(" ", Style::default()),
Span::styled(label, status_style),
];
if let Some(agent_label) = &detail.agent_label {
status_spans.push(Span::styled(" · ", agent_style));
status_spans.push(Span::styled(agent_label, agent_style));
}
frame.render_widget(
Paragraph::new(Line::from(status_spans)),
Rect::new(body.x, row_y, body.width, 1),
);
row_y += 1;
if row_y < body_bottom {
row_y += 1;
}
}
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)
}
fn render_sidebar_toggle(
app: &AppState,
frame: &mut Frame,
area: Rect,
collapsed: bool,
p: &Palette,
) {
// Toggle button not needed when sidebar has content — skip for now
// to avoid conflicting with the agent detail panel at the bottom.
if !collapsed {
return;
}
let toggle_area = collapsed_sidebar_toggle_rect(area);
if toggle_area == Rect::default() {
return;
}
let icon_style = if app.update_available.is_some() {
Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(p.overlay0)
};
frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), toggle_area);
}
fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(ws_idx) = app.active else {
render_empty(app, frame, area);
return;
};
let Some(ws) = app.workspaces.get(ws_idx) else {
render_empty(app, frame, area);
return;
};
let multi_pane = ws.layout.pane_count() > 1;
let terminal_active = app.mode == Mode::Terminal;
for info in &app.view.pane_infos {
if let Some(rt) = ws.runtimes.get(&info.id) {
// Draw borders for multi-pane layouts
if multi_pane {
let (border_style, border_set) = if info.is_focused && terminal_active {
(
Style::default().fg(app.palette.accent),
ratatui::symbols::border::THICK,
)
} else if info.is_focused {
(
Style::default().fg(app.palette.accent),
ratatui::symbols::border::PLAIN,
)
} else {
(
Style::default().fg(app.palette.overlay0),
ratatui::symbols::border::PLAIN,
)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.border_set(border_set);
frame.render_widget(block, info.rect);
}
// Draw terminal content. Only the focused pane should own the cursor.
rt.render(frame, info.inner_rect, info.is_focused && terminal_active);
render_pane_scrollbar(app, frame, info, rt);
// Dim unfocused panes only in navigate mode
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)];
let style = cell.style();
let fg = style.fg.unwrap_or(Color::White);
let dimmed_fg = dim_color(fg);
cell.set_style(style.fg(dimmed_fg));
}
}
}
// Selection highlight
render_selection_highlight(
&app.selection,
frame,
info.id,
info.inner_rect,
rt.scroll_metrics(),
&app.palette,
);
}
}
}
/// Render selection highlight for a pane by inverting fg/bg colors.
/// Reduce a color's brightness by blending it toward black.
fn dim_color(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
Color::White => Color::DarkGray,
Color::Gray => Color::DarkGray,
Color::DarkGray => Color::Rgb(30, 30, 30),
Color::Red => Color::Rgb(60, 0, 0),
Color::Green => Color::Rgb(0, 60, 0),
Color::Yellow => Color::Rgb(60, 60, 0),
Color::Blue => Color::Rgb(0, 0, 60),
Color::Magenta => Color::Rgb(60, 0, 60),
Color::Cyan => Color::Rgb(0, 60, 60),
Color::LightRed => Color::Rgb(80, 30, 30),
Color::LightGreen => Color::Rgb(30, 80, 30),
Color::LightYellow => Color::Rgb(80, 80, 30),
Color::LightBlue => Color::Rgb(30, 30, 80),
Color::LightMagenta => Color::Rgb(80, 30, 80),
Color::LightCyan => Color::Rgb(30, 80, 80),
// Indexed colors and others: just use DIM modifier as fallback
_ => Color::DarkGray,
}
}
pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
info.scrollbar_rect
}
pub(crate) fn release_notes_scrollbar_rect(
body: Rect,
metrics: crate::pane::ScrollMetrics,
) -> Option<Rect> {
(should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new(
body.x + body.width - 1,
body.y,
1,
body.height,
))
}
pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
metrics.max_offset_from_bottom > 0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ScrollbarThumb {
pub top: u16,
pub len: u16,
}
pub(crate) fn scrollbar_thumb(
metrics: crate::pane::ScrollMetrics,
track: Rect,
) -> Option<ScrollbarThumb> {
if metrics.max_offset_from_bottom == 0 || track.height == 0 {
return None;
}
let track_height = track.height as usize;
let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows;
if total_rows == 0 {
return None;
}
let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32)
.round()
.max(1.0)
.min(track_height as f32) as usize;
let max_thumb_top = track_height.saturating_sub(thumb_len);
let scrolled_from_top = metrics
.max_offset_from_bottom
.saturating_sub(metrics.offset_from_bottom);
let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 {
0
} else {
((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32)
.round()
.clamp(0.0, max_thumb_top as f32) as usize
};
Some(ScrollbarThumb {
top: track.y + thumb_top as u16,
len: thumb_len as u16,
})
}
pub(crate) fn scrollbar_thumb_grab_offset(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
) -> Option<u16> {
let thumb = scrollbar_thumb(metrics, track)?;
(row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top)
}
fn scrollbar_offset_from_thumb_top(
metrics: crate::pane::ScrollMetrics,
track: Rect,
thumb_top: usize,
) -> usize {
if metrics.max_offset_from_bottom == 0 {
return 0;
}
let thumb_len = scrollbar_thumb(metrics, track)
.map(|thumb| thumb.len as usize)
.unwrap_or(1);
let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize);
if max_thumb_top == 0 {
return 0;
}
let desired_top = thumb_top.min(max_thumb_top);
let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32
/ max_thumb_top as f32)
.round() as usize;
metrics
.max_offset_from_bottom
.saturating_sub(scrolled_from_top)
}
pub(crate) fn scrollbar_offset_from_row(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
) -> usize {
let thumb = match scrollbar_thumb(metrics, track) {
Some(thumb) => thumb,
None => return 0,
};
let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
let row_offset = clamped_row.saturating_sub(track.y) as usize;
let thumb_center = (thumb.len as usize) / 2;
let desired_top = row_offset.saturating_sub(thumb_center);
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
pub(crate) fn scrollbar_offset_from_drag_row(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
grab_row_offset: u16,
) -> usize {
let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
let row_offset = clamped_row.saturating_sub(track.y) as usize;
let desired_top = row_offset.saturating_sub(grab_row_offset as usize);
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
fn render_scrollbar(
frame: &mut Frame,
metrics: crate::pane::ScrollMetrics,
track: Rect,
track_color: Color,
thumb_color: Color,
thumb_symbol: &str,
) {
if metrics.max_offset_from_bottom == 0 {
return;
}
let Some(thumb) = scrollbar_thumb(metrics, track) else {
return;
};
let buf = frame.buffer_mut();
for y in track.y..track.y + track.height {
let cell = &mut buf[(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)];
cell.set_symbol(thumb_symbol);
cell.set_style(Style::default().fg(thumb_color));
}
}
fn render_pane_scrollbar(
app: &AppState,
frame: &mut Frame,
info: &PaneInfo,
rt: &crate::pane::PaneRuntime,
) {
let Some(metrics) = rt.scroll_metrics() else {
return;
};
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,
metrics,
track,
track_color,
thumb_color,
thumb_symbol,
);
}
fn render_selection_highlight(
selection: &Option<crate::selection::Selection>,
frame: &mut Frame,
pane_id: crate::layout::PaneId,
inner: Rect,
scroll_metrics: Option<crate::pane::ScrollMetrics>,
p: &Palette,
) {
if let Some(sel) = selection {
if sel.is_visible() && sel.pane_id == pane_id {
let buf = frame.buffer_mut();
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::default().fg(p.panel_bg).bg(p.blue));
}
}
}
}
}
}
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(
format!("{}", app.keybinds.new_workspace_label),
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,
);
}
const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
fn dim_background(frame: &mut Frame, area: Rect) {
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
let cell = &mut buf[(x, y)];
cell.set_style(cell.style().add_modifier(Modifier::DIM));
}
}
}
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(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 {
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))
}
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)
}
fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {title} "),
Style::default().fg(p.text).add_modifier(Modifier::BOLD),
)),
area,
);
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ModalStackAreas {
pub header: Rect,
pub content: Rect,
pub footer: Option<Rect>,
pub actions: Option<Rect>,
}
pub(crate) fn modal_stack_areas(
inner: Rect,
header_height: u16,
footer_height: u16,
actions_height: u16,
gap: u16,
) -> ModalStackAreas {
#[derive(Clone, Copy)]
enum Slot {
Header,
Content,
Footer,
Actions,
}
let mut constraints = Vec::new();
let mut slots = Vec::new();
let mut push = |slot: Slot, constraint: Constraint| {
if !slots.is_empty() {
constraints.push(Constraint::Length(gap));
}
constraints.push(constraint);
slots.push(slot);
};
push(Slot::Header, Constraint::Length(header_height));
push(Slot::Content, Constraint::Min(0));
if footer_height > 0 {
push(Slot::Footer, Constraint::Length(footer_height));
}
if actions_height > 0 {
push(Slot::Actions, Constraint::Length(actions_height));
}
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;
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),
}
}
ModalStackAreas {
header,
content,
footer,
actions,
}
}
fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
dim_background(frame, area);
match app.onboarding_step {
0 => render_onboarding_welcome(app, frame, area),
_ => render_onboarding_notifications(app, frame, area),
}
}
fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(notes) = &app.release_notes else {
return;
};
dim_background(frame, area);
let Some(inner) = render_modal_shell(frame, area, 76, 20, &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);
render_modal_header(
frame,
header_rows[0],
&format!("v{}", notes.version),
&app.palette,
);
frame.render_widget(
Paragraph::new(" what's new in this release")
.style(Style::default().fg(app.palette.overlay1)),
header_rows[1],
);
render_action_button(
frame,
release_notes_close_button_rect(header_rows[0]),
Some("esc"),
"close",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
let body_area = stack.content;
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: 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(
release_notes_lines(notes.body.as_str(), &app.palette)
.into_iter()
.map(|(_, line)| line)
.collect::<Vec<_>>(),
)
.wrap(Wrap { trim: false })
.scroll((notes.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(" q / esc / enter ", Style::default().fg(app.palette.text)),
])),
stack.footer.unwrap_or_default(),
);
}
fn release_notes_inline_spans<'a>(
text: &str,
base_style: Style,
code_style: Style,
) -> (usize, Vec<Span<'a>>) {
let mut spans = Vec::new();
let mut width = 0;
let mut remaining = text;
while let Some(start) = remaining.find('`') {
let (before, after_start) = remaining.split_at(start);
if !before.is_empty() {
width += before.chars().count();
spans.push(Span::styled(before.to_string(), base_style));
}
let after_start = &after_start[1..];
let Some(end) = after_start.find('`') else {
let literal = format!("`{after_start}");
width += literal.chars().count();
spans.push(Span::styled(literal, base_style));
remaining = "";
break;
};
let (code, after_end) = after_start.split_at(end);
width += code.chars().count();
if !code.is_empty() {
spans.push(Span::styled(code.to_string(), code_style));
}
remaining = &after_end[1..];
}
if !remaining.is_empty() {
width += remaining.chars().count();
spans.push(Span::styled(remaining.to_string(), base_style));
}
if spans.is_empty() {
spans.push(Span::styled(String::new(), base_style));
}
(width, spans)
}
pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> {
let mut lines = Vec::new();
let mut in_fenced_code_block = false;
let text_style = Style::default().fg(p.text);
let inline_code_style = Style::default()
.fg(p.accent)
.bg(p.surface0)
.add_modifier(Modifier::BOLD);
for raw in body.lines() {
let trimmed = raw.trim_end();
if trimmed.trim_start().starts_with("```") {
in_fenced_code_block = !in_fenced_code_block;
continue;
}
if in_fenced_code_block {
let code_bg = p.surface1;
let gutter_style = Style::default().fg(p.accent).bg(code_bg);
let code_style = Style::default().fg(p.text).bg(code_bg);
let width = 2 + trimmed.chars().count();
let mut spans = vec![
Span::styled("▏", gutter_style),
Span::styled(" ", code_style),
];
if !trimmed.is_empty() {
spans.push(Span::styled(trimmed.to_string(), code_style));
}
lines.push((width, Line::from(spans)));
continue;
}
if trimmed.is_empty() {
lines.push((0, Line::raw("")));
continue;
}
if let Some(rest) = trimmed.strip_prefix("### ") {
let text = rest.to_lowercase();
let width = 1 + text.chars().count();
lines.push((
width,
Line::from(vec![
Span::raw(" "),
Span::styled(
text,
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
),
]),
));
continue;
}
if let Some(rest) = trimmed.strip_prefix("- ") {
let (text_width, mut spans) =
release_notes_inline_spans(rest, text_style, inline_code_style);
let width = 3 + text_width;
let mut line_spans = vec![Span::styled(" • ", Style::default().fg(p.accent))];
line_spans.append(&mut spans);
lines.push((width, Line::from(line_spans)));
continue;
}
let (text_width, mut spans) =
release_notes_inline_spans(trimmed, text_style, inline_code_style);
let width = 1 + text_width;
let mut line_spans = vec![Span::raw(" ")];
line_spans.append(&mut spans);
lines.push((width, Line::from(line_spans)));
}
lines
}
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)
}
pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect {
Rect::new(
area.x,
area.y,
action_button_width(Some("↵"), "continue"),
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(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) {
let rects = action_button_row_rects(
area,
&[
ActionButtonSpec {
hint: Some("esc"),
label: "back",
},
ActionButtonSpec {
hint: Some("↵"),
label: "start",
},
],
2,
0,
);
(rects[0], rects[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 navigate 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]);
let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
render_action_button(
frame,
continue_rect,
Some("↵"),
"continue",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
}
fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else {
return;
};
if inner.height < 11 {
return;
}
let stack = modal_stack_areas(inner, 3, 0, 1, 1);
let header_rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas::<3>(stack.header);
let option_rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas::<5>(stack.content);
render_modal_header(frame, header_rows[0], "notification style", &app.palette);
frame.render_widget(
Paragraph::new(" herdr watches background panes and can alert you")
.style(Style::default().fg(app.palette.overlay1)),
header_rows[1],
);
frame.render_widget(
Paragraph::new(" when agents finish or need attention.")
.style(Style::default().fg(app.palette.overlay1)),
header_rows[2],
);
let options = [
"quiet no interruptions",
"visual only top-right toasts",
"sound only sound alerts",
"both sound and toasts",
];
for (idx, option) in options.iter().enumerate() {
let selected = idx == app.onboarding_list.selected;
let prefix = if selected { "" } else { " " };
let style = if selected {
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
} else {
Style::default().fg(app.palette.text)
};
frame.render_widget(
Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style),
option_rows[idx],
);
}
let (back_rect, save_rect) =
onboarding_notification_button_rects(stack.actions.unwrap_or_default());
render_action_button(
frame,
back_rect,
Some("esc"),
"back",
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
.add_modifier(Modifier::BOLD),
);
render_action_button(
frame,
save_rect,
Some("↵"),
"start",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
}
/// Floating overlay for navigate mode — appears at bottom of terminal area.
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(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD);
let kb = &app.keybinds;
let line = Line::from(vec![
Span::styled(" NAVIGATE ", mode_style),
Span::raw(" "),
Span::styled("esc", key),
Span::styled(" back ", dim),
Span::styled("↑↓", key),
Span::styled(" ws ", dim),
Span::styled("⇥", key),
Span::styled(" pane ", dim),
Span::styled(kb.new_tab_label.as_str(), key),
Span::styled(" new tab ", dim),
Span::styled(kb.split_vertical_label.as_str(), key),
Span::styled(" split│ ", dim),
Span::styled(kb.split_horizontal_label.as_str(), key),
Span::styled(" split─ ", dim),
Span::styled(kb.close_pane_label.as_str(), key),
Span::styled(" close ", dim),
Span::styled(kb.fullscreen_label.as_str(), key),
Span::styled(" full ", dim),
Span::styled(kb.resize_mode_label.as_str(), key),
Span::styled(" resize ", dim),
Span::styled("?", key),
Span::styled(" keybinds ", dim),
Span::styled("s", key),
Span::styled(" settings", dim),
]);
let overlay_y = area.y + area.height.saturating_sub(1);
let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
frame.render_widget(Clear, overlay_area);
let bg = Style::default().bg(app.palette.panel_bg);
let buf = frame.buffer_mut();
for x in overlay_area.x..overlay_area.x + overlay_area.width {
buf[(x, overlay_y)].set_style(bg);
}
frame.render_widget(Paragraph::new(line), overlay_area);
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,
);
}
}
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 style = if selected {
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.palette.text)
};
frame.render_widget(
Paragraph::new(format!(" {item} "))
.style(style)
.alignment(Alignment::Left),
Rect::new(inner.x, y, inner.width, 1),
);
}
}
fn optional_keybind_label(label: &Option<String>) -> String {
label.clone().unwrap_or_else(|| "unset".to_string())
}
fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'static str)>)> {
let kb = &app.keybinds;
let mut groups = Vec::new();
groups.push((
"global",
vec![
(
crate::config::format_key_combo((app.prefix_code, app.prefix_mods)),
"navigate mode",
),
("prefix + ?".to_string(), "keybinds"),
],
));
groups.push((
"navigation",
vec![
("esc".to_string(), "back"),
("↑ / ↓".to_string(), "workspace list"),
("h j k l / arrows".to_string(), "move focus"),
("tab / shift+tab".to_string(), "cycle pane"),
("enter".to_string(), "open workspace"),
("s".to_string(), "settings"),
("q".to_string(), "quit"),
],
));
let workspace_tab = vec![
(kb.new_workspace_label.clone(), "new workspace"),
(kb.rename_workspace_label.clone(), "rename workspace"),
(kb.close_workspace_label.clone(), "close workspace"),
(
optional_keybind_label(&kb.previous_workspace_label),
"previous workspace",
),
(
optional_keybind_label(&kb.next_workspace_label),
"next workspace",
),
(kb.new_tab_label.clone(), "new tab"),
(optional_keybind_label(&kb.rename_tab_label), "rename tab"),
(
optional_keybind_label(&kb.previous_tab_label),
"previous tab",
),
(optional_keybind_label(&kb.next_tab_label), "next tab"),
(optional_keybind_label(&kb.close_tab_label), "close tab"),
];
groups.push(("workspaces / tabs", workspace_tab));
let panes = vec![
(kb.split_vertical_label.clone(), "split vertical"),
(kb.split_horizontal_label.clone(), "split horizontal"),
(kb.close_pane_label.clone(), "close pane"),
(kb.fullscreen_label.clone(), "fullscreen"),
(kb.resize_mode_label.clone(), "resize mode"),
(kb.toggle_sidebar_label.clone(), "toggle sidebar"),
(
optional_keybind_label(&kb.focus_pane_left_label),
"focus pane left",
),
(
optional_keybind_label(&kb.focus_pane_down_label),
"focus pane down",
),
(
optional_keybind_label(&kb.focus_pane_up_label),
"focus pane up",
),
(
optional_keybind_label(&kb.focus_pane_right_label),
"focus pane right",
),
];
groups.push(("panes", panes));
groups
}
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 = keybind_help_groups(app);
let key_width = groups
.iter()
.flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
.max()
.unwrap_or(8);
let mut lines = Vec::new();
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.to_string(), label_style),
]),
));
}
lines.push((0, Line::raw("")));
}
lines
}
fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
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"),
"close",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(
Paragraph::new(" available commands and configured shortcuts")
.style(Style::default().fg(app.palette.overlay1)),
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,
"▐",
);
}
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("jump", 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("close", Style::default().fg(app.palette.overlay0)),
Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)),
])),
stack.footer.unwrap_or_default(),
);
}
/// Floating overlay for resize mode.
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(app.palette.panel_bg)
.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);
frame.render_widget(Clear, overlay_area);
let bg = Style::default().bg(app.palette.panel_bg);
let buf = frame.buffer_mut();
for x in overlay_area.x..overlay_area.x + overlay_area.width {
buf[(x, overlay_y)].set_style(bg);
}
frame.render_widget(Paragraph::new(line), overlay_area);
}
/// Centered popup confirmation dialog with dimmed background.
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])
}
fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
dim_background(frame, area);
let title = match app.mode {
Mode::RenameWorkspace => "rename workspace",
Mode::RenameTab if app.creating_new_tab => "new tab",
Mode::RenameTab => "rename tab",
_ => 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);
frame.render_widget(Clear, input_rect);
frame.render_widget(
Paragraph::new(format!(" {}█", app.name_input)).style(
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0),
),
input_rect,
);
let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
render_action_button(
frame,
save_rect,
Some("↵"),
"save",
Style::default()
.fg(app.palette.panel_bg)
.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),
);
}
fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let ws_name = app
.workspaces
.get(app.selected)
.map(|ws| ws.display_name())
.unwrap_or_else(|| "?".to_string());
let pane_count = 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")
};
// Dim the entire background
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
let cell = &mut buf[(x, y)];
cell.set_style(cell.style().add_modifier(Modifier::DIM));
}
}
// Centered popup
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(" Close workspace?", warn)]);
let detail_line = Line::from(vec![
Span::styled(
format!(" {ws_name}"),
Style::default()
.fg(app.palette.text)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" — {pane_text}"), 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),
])
.areas::<3>(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(app.palette.panel_bg)
.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 action_button_text(hint: Option<&str>, label: &str) -> String {
match hint {
Some(hint) => format!(" {hint} {label} "),
None => format!(" {label} "),
}
}
pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 {
action_button_text(hint, label).chars().count() as u16
}
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,
);
}
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 confirm_close_popup_rect(area: Rect) -> Option<Rect> {
centered_popup_rect(area, 44, 5)
}
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,
2,
);
(rects[0], rects[1])
}
pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) {
let rects = action_button_row_rects(
inner,
&[
ActionButtonSpec {
hint: Some("↵"),
label: "apply",
},
ActionButtonSpec {
hint: Some("esc"),
label: "close",
},
],
2,
inner.height.saturating_sub(1),
);
(rects[0], rects[1])
}
/// Right-click context menu popup anchored near the click position.
// ---------------------------------------------------------------------------
// Settings overlay
// ---------------------------------------------------------------------------
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, 56, 20) else {
return;
};
// Dim everything behind the modal
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);
// Title
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" settings",
Style::default().fg(p.text).add_modifier(Modifier::BOLD),
)])),
header_rows[0],
);
// Tab bar
let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label()))
.select(
SettingsSection::ALL
.iter()
.position(|section| *section == app.settings.section)
.unwrap_or(0),
)
.style(Style::default().fg(p.overlay1))
.highlight_style(
Style::default()
.fg(p.panel_bg)
.bg(p.accent)
.add_modifier(Modifier::BOLD),
)
.divider(" ")
.padding(" ", " ");
frame.render_widget(tabs, header_rows[1]);
// Separator
let sep = "─".repeat(inner.width as usize);
frame.render_widget(
Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
header_rows[2],
);
// Section content
let content_area = stack.content;
match app.settings.section {
SettingsSection::Theme => {
render_settings_theme(app, frame, content_area);
}
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_settings_toggle(
frame,
content_area,
p,
"visual toasts",
"show top-right notifications for background events",
app.toast_config.enabled,
app.settings.list.selected,
);
}
}
// Footer buttons + hints
if let Some(footer_area) = stack.footer {
let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
.areas::<2>(footer_area);
let (apply_rect, close_rect) = settings_button_rects(inner);
render_action_button(
frame,
apply_rect,
Some("↵"),
"apply",
Style::default()
.fg(p.panel_bg)
.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],
);
}
}
/// Render the theme picker list inside the settings panel.
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);
}
/// Reusable toggle widget for boolean settings (sound, toast).
fn render_settings_toggle(
frame: &mut Frame,
area: Rect,
p: &crate::app::state::Palette,
title: &str,
description: &str,
current_value: bool,
selected_idx: usize,
) {
let [desc_area, _, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(2),
])
.areas::<3>(area);
let max_desc_len = (desc_area.width as usize).saturating_sub(2);
let desc_text = if description.len() > max_desc_len {
format!(" {}…", &description[..max_desc_len.saturating_sub(2)])
} else {
format!(" {description}")
};
frame.render_widget(
Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))),
desc_area,
);
let items: Vec<ListItem> = ["on", "off"]
.into_iter()
.map(|label| {
let is_active = (label == "on") == current_value;
let marker = if is_active { " ✓" } else { "" };
ListItem::new(Line::from(vec![
Span::styled(format!("{title}: {label}"), 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(" ▸ ");
let mut state = ListState::default().with_selected(Some(selected_idx.min(1)));
frame.render_stateful_widget(list, list_area, &mut state);
}
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(p.panel_bg)
.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);
}
fn render_toast_notification(
frame: &mut Frame,
area: Rect,
toast: &ToastNotification,
offset_for_warning: bool,
p: &Palette,
) {
let dot_color = match toast.kind {
ToastKind::NeedsAttention => p.red,
ToastKind::Finished => p.blue,
ToastKind::UpdateInstalled => p.accent,
};
let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4;
let width = content_width.saturating_add(2).min(area.width);
let height = 4u16.min(area.height);
let x = area.x + area.width.saturating_sub(width);
let y = area.y
+ area
.height
.saturating_sub(height + if offset_for_warning { 1 } else { 0 });
let toast_area = Rect::new(x, y, width, height);
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 < 2 {
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);
frame.render_widget(Paragraph::new(context), context_row);
}
/// Visual badge for a pane's state + seen flag.
///
/// | State | Icon | Color |
/// |--------------------|------|--------|
/// | Blocked | ● | Red |
/// | Working | ● | Yellow |
/// | Done (idle+unseen) | ● | Blue |
/// | Idle (seen) | ○ | Green |
/// | Unknown | · | Gray |
///
/// Filled dot = needs attention (blocked/working, or finished unseen).
/// Hollow dot = nothing to do here.
fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
let text = format!(" config warning: {message} ");
let width = text.len() as u16 + 2;
let notif_area = Rect::new(
area.x + area.width.saturating_sub(width.min(area.width)),
area.y,
width.min(area.width),
1,
);
frame.render_widget(Clear, notif_area);
frame.render_widget(
Paragraph::new(Span::styled(
text,
Style::default()
.fg(p.panel_bg)
.bg(p.yellow)
.add_modifier(Modifier::BOLD),
)),
notif_area,
);
}
/// Compact dot icon for workspace-level aggregate state (top section).
fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) {
match (state, seen) {
(AgentState::Blocked, _) => ("●", Style::default().fg(p.red)),
(AgentState::Working, _) => ("●", Style::default().fg(p.yellow)),
(AgentState::Idle, false) => ("●", Style::default().fg(p.teal)),
(AgentState::Idle, true) => ("○", Style::default().fg(p.green)),
(AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)),
}
}
/// Rich icon for per-pane agent detail (bottom section).
/// Uses animated spinner for the working state.
fn agent_icon(state: AgentState, seen: bool, tick: u32, p: &Palette) -> (&'static str, Style) {
match (state, seen) {
(AgentState::Blocked, _) => ("◉", Style::default().fg(p.red)),
(AgentState::Working, _) => (spinner_frame(tick), Style::default().fg(p.yellow)),
(AgentState::Idle, false) => ("●", Style::default().fg(p.teal)),
(AgentState::Idle, true) => ("✓", Style::default().fg(p.green)),
(AgentState::Unknown, _) => ("○", Style::default().fg(p.overlay0)),
}
}
/// State label for the agent detail panel.
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",
}
}
/// Color for the state label text.
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,
}
}
fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::new();
spans.push(Span::raw(" "));
for (i, (k, desc)) in items.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", dim_style));
}
spans.push(Span::styled(k.to_string(), key_style));
spans.push(Span::styled(format!(" {desc}"), dim_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{detect::Agent, workspace::Workspace};
use ratatui::{backend::TestBackend, Terminal};
#[tokio::test]
async fn focused_pane_cursor_wins_during_terminal_render() {
let mut app = crate::app::state::AppState::test_new();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
ws.tabs[0].runtimes.insert(
first_pane,
crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"),
);
ws.tabs[0].runtimes.insert(
second_pane,
crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"),
);
ws.tabs[0].layout.focus_pane(first_pane);
app.workspaces = vec![ws];
app.active = Some(0);
app.selected = 0;
app.mode = Mode::Terminal;
compute_view(&mut app, Rect::new(0, 0, 80, 20));
let focused = app
.view
.pane_infos
.iter()
.find(|info| info.id == first_pane)
.expect("focused pane info");
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| render(&app, frame)).unwrap();
terminal
.backend_mut()
.assert_cursor_position((focused.inner_rect.x + 4, focused.inner_rect.y));
}
#[test]
fn tab_bar_dims_auto_named_tabs_and_emphasizes_custom_tabs() {
let mut app = crate::app::state::AppState::test_new();
let mut ws = Workspace::test_new("test");
let custom_tab = ws.test_add_tab(Some("logs"));
ws.switch_tab(custom_tab);
app.workspaces = vec![ws];
app.active = Some(0);
app.selected = 0;
app.mode = Mode::Terminal;
compute_view(&mut app, Rect::new(0, 0, 80, 20));
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| render(&app, frame)).unwrap();
let buffer = terminal.backend().buffer();
let auto_rect = app.view.tab_hit_areas[0];
let custom_rect = app.view.tab_hit_areas[1];
let auto_style = buffer[(auto_rect.x + 1, auto_rect.y)].style();
let custom_style = buffer[(custom_rect.x + 1, custom_rect.y)].style();
assert_eq!(auto_style.fg, Some(app.palette.overlay0));
assert!(auto_style.add_modifier.contains(Modifier::DIM));
assert_eq!(custom_style.fg, Some(app.palette.panel_bg));
assert!(custom_style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
let mut app = crate::app::state::AppState::test_new();
let mut first = Workspace::test_new("one");
let first_pane = first.tabs[0].root_pane;
first.tabs[0]
.panes
.get_mut(&first_pane)
.unwrap()
.detected_agent = Some(Agent::Pi);
let mut second = Workspace::test_new("two");
let second_tab = second.test_add_tab(Some("logs"));
let second_pane = second.tabs[second_tab].root_pane;
second.tabs[second_tab]
.panes
.get_mut(&second_pane)
.unwrap()
.detected_agent = Some(Agent::Claude);
app.workspaces = vec![first, second];
app.active = Some(0);
app.selected = 0;
app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
let entries = agent_panel_entries(&app);
assert_eq!(entries[0].primary_label, "one");
assert!(entries[0].primary_tab_label.is_none());
assert_eq!(entries[0].agent_label.as_deref(), Some("pi"));
assert_eq!(entries[1].primary_label, "two");
assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs"));
assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
}
#[test]
fn all_workspaces_primary_label_truncates_workspace_and_tab() {
let entry = AgentPanelEntry {
ws_idx: 0,
tab_idx: 0,
pane_id: crate::layout::PaneId::from_raw(1),
primary_label: "agent-browser".into(),
primary_tab_label: Some("test-escalation".into()),
agent_label: Some("claude".into()),
state: AgentState::Idle,
seen: true,
};
let label = format_agent_panel_primary_label(&entry, 18);
assert_eq!(label, "agent-bro… · test…");
}
#[test]
fn expanded_sidebar_sections_handle_tiny_heights() {
let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
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 pane_scrollbar_rect_uses_rightmost_inner_column() {
let info = PaneInfo {
id: crate::layout::PaneId::from_raw(1),
rect: Rect::new(0, 0, 12, 8),
inner_rect: Rect::new(1, 1, 9, 6),
scrollbar_rect: Some(Rect::new(10, 1, 1, 6)),
is_focused: true,
};
assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6)));
}
#[test]
fn scrollbar_stays_hidden_without_scrollback() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 0,
viewport_rows: 5,
};
assert!(!should_show_scrollbar(metrics));
}
#[test]
fn scrollbar_shows_with_scrollback() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
assert!(should_show_scrollbar(metrics));
}
#[test]
fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
let track = Rect::new(9, 4, 1, 5);
let thumb = scrollbar_thumb(metrics, track).expect("thumb");
assert_eq!(thumb.top + thumb.len, track.y + track.height);
}
#[test]
fn scrollbar_offset_mapping_hits_top_middle_and_bottom() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
let track = Rect::new(9, 4, 1, 5);
assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20);
assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10);
assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0);
}
#[test]
fn dragging_from_current_thumb_row_preserves_offset() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 7,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
let track = Rect::new(9, 4, 1, 8);
let thumb = scrollbar_thumb(metrics, track).expect("thumb");
let row = thumb.top + thumb.len / 2;
let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab");
assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7);
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn release_notes_inline_code_spans_are_styled_without_backticks() {
let palette = Palette::catppuccin();
let lines = release_notes_lines("- `herdr pane run ...` now works", &palette);
assert_eq!(lines.len(), 1);
assert_eq!(line_text(&lines[0].1), " • herdr pane run ... now works");
assert_eq!(lines[0].1.spans[1].content.as_ref(), "herdr pane run ...");
assert_eq!(lines[0].1.spans[1].style.fg, Some(palette.accent));
assert_eq!(lines[0].1.spans[1].style.bg, Some(palette.surface0));
}
#[test]
fn release_notes_fenced_code_blocks_render_as_preformatted_lines() {
let palette = Palette::catppuccin();
let lines = release_notes_lines(
"### Fixed\n```bash\njust check\n- not a bullet\n```\n- after",
&palette,
);
assert_eq!(lines.len(), 4);
assert_eq!(line_text(&lines[0].1), " fixed");
assert_eq!(line_text(&lines[1].1), "▏ just check");
assert_eq!(line_text(&lines[2].1), "▏ - not a bullet");
assert_eq!(line_text(&lines[3].1), " • after");
assert_eq!(lines[1].1.spans[0].style.fg, Some(palette.accent));
assert_eq!(lines[1].1.spans[0].style.bg, Some(palette.surface1));
assert_eq!(lines[1].1.spans[1].style.bg, Some(palette.surface1));
assert_eq!(lines[1].1.spans[2].style.bg, Some(palette.surface1));
}
#[test]
fn release_notes_fenced_code_blocks_preserve_blank_lines() {
let palette = Palette::catppuccin();
let lines = release_notes_lines("```\nfirst\n\nsecond\n```", &palette);
assert_eq!(lines.len(), 3);
assert_eq!(line_text(&lines[0].1), "▏ first");
assert_eq!(line_text(&lines[1].1), "▏ ");
assert_eq!(line_text(&lines[2].1), "▏ second");
}
#[test]
fn keybind_help_shows_unset_for_optional_actions() {
let app = crate::app::state::AppState::test_new();
let groups = keybind_help_groups(&app);
let workspace_tab = groups
.iter()
.find(|(name, _)| *name == "workspaces / tabs")
.expect("workspace tab group")
.1
.clone();
let panes = groups
.iter()
.find(|(name, _)| *name == "panes")
.expect("panes group")
.1
.clone();
assert!(workspace_tab.contains(&("unset".to_string(), "previous workspace")));
assert!(workspace_tab.contains(&("unset".to_string(), "next workspace")));
assert!(workspace_tab.contains(&("unset".to_string(), "rename tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "previous tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "next tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "close tab")));
assert!(panes.contains(&("unset".to_string(), "focus pane left")));
assert!(panes.contains(&("unset".to_string(), "focus pane down")));
assert!(panes.contains(&("unset".to_string(), "focus pane up")));
assert!(panes.contains(&("unset".to_string(), "focus pane right")));
}
}