diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/ui.rs | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/ui.rs')
| -rw-r--r-- | src/ui.rs | 3616 |
1 files changed, 167 insertions, 3449 deletions
@@ -1,15 +1,64 @@ use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::Span, Frame, }; -use crate::app::state::{AgentPanelScope, ToastKind, ToastNotification}; +mod dialogs; +mod keybind_help; +mod menus; +mod onboarding; +mod panes; +mod release_notes; +mod scrollbar; +mod settings; +mod sidebar; +mod status; +mod tabs; +mod widgets; + +use self::dialogs::{render_confirm_close_overlay, render_rename_overlay}; +use self::keybind_help::render_keybind_help_overlay; +use self::menus::{ + render_context_menu, render_global_launcher_menu, render_navigate_overlay, + render_resize_overlay, +}; +use self::onboarding::render_onboarding_overlay; +pub(crate) use self::onboarding::{ + onboarding_notification_button_rects, onboarding_welcome_continue_rect, +}; +use self::panes::{compute_pane_infos, render_panes}; +use self::release_notes::render_release_notes_overlay; +pub(crate) use self::release_notes::{ + release_notes_close_button_rect, release_notes_display_lines, release_notes_sections, + RELEASE_NOTES_MODAL_SIZE, +}; +pub(crate) use self::scrollbar::{ + pane_scrollbar_rect, release_notes_scrollbar_rect, scrollbar_offset_from_drag_row, + scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar, +}; +use self::settings::render_settings_overlay; +use self::sidebar::{render_sidebar, render_sidebar_collapsed}; +use self::status::{render_config_diagnostic, render_toast_notification}; +use self::tabs::render_tab_bar; +pub(crate) use self::{ + dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects}, + settings::settings_button_rects, + sidebar::{ + agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics, + agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections, + collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, + sidebar_section_divider_rect, workspace_drop_indicator_row, workspace_list_rect, + workspace_list_scroll_metrics, workspace_list_scrollbar_rect, + }, +}; +pub(crate) use self::{ + keybind_help::keybind_help_lines, + tabs::compute_tab_bar_view, + widgets::{centered_popup_rect, modal_stack_areas}, +}; 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; @@ -20,211 +69,26 @@ 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 { +pub(super) 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}…") +/// Compute view geometry and reconcile pane sizes. +/// Called before render to separate mutation from drawing. +pub fn compute_view(app: &mut AppState, area: Rect) { + compute_view_internal(app, area, true); } -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 for a client-sized render without resizing pane runtimes. +/// +/// This is used by the headless server when a non-foreground client needs its +/// own frame size while the shared pane runtimes stay pinned to the foreground +/// client. +pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect) { + compute_view_internal(app, area, false); } -/// Compute view geometry and reconcile pane sizes. -/// Called before render to separate mutation from drawing. -pub fn compute_view(app: &mut AppState, area: Rect) { +fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { let sidebar_w = if app.sidebar_collapsed { COLLAPSED_WIDTH } else { @@ -281,7 +145,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) { .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_area); + let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); app.view = crate::app::ViewState { sidebar_rect: sidebar_area, @@ -343,1454 +207,6 @@ pub fn render(app: &AppState, frame: &mut Frame) { } } -const MIN_TAB_WIDTH: u16 = 8; -const NEW_TAB_WIDTH: u16 = 3; -const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; -const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; -const AGENT_PANEL_HEADER_ROWS: u16 = 3; - -#[derive(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 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 tab_width(tab: &crate::workspace::Tab) -> u16 { - (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH) -} - -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 in scroll..ws.tabs.len() { - if x >= right { - break; - } - let desired = tab_width(&ws.tabs[idx]); - let remaining = right.saturating_sub(x); - let width = desired.min(remaining).max(1); - rects[idx] = 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, -) -> TabBarView { - if area.width == 0 || area.height == 0 { - return TabBarView::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 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.last().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 -} - -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.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.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.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 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.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 = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width; - 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 x = app.view.tab_scroll_right_hit_area.x.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)); - } - } -} - -/// 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 is_active = Some(visible_idx) == app.active; - let row_style = if is_selected { - Style::default().bg(p.surface0) - } else if is_active { - Style::default().bg(p.surface_dim) - } else { - Style::default() - }; - let num_style = if is_selected { - Style::default().fg(p.overlay1).bg(p.surface0) - } else if is_active { - Style::default().fg(p.text).bg(p.surface_dim) - } 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!("{}", 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(panel_contrast_fg(p)).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 { @@ -1801,1730 +217,7 @@ fn dim_background(frame: &mut Frame, area: Rect) { } } -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) -} - -fn panel_contrast_fg(p: &Palette) -> Color { - match p.panel_bg { - Color::Reset => p.surface_dim, - color => color, - } -} - -pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); - -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) { - let line = Line::from(vec![ - Span::styled( - title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled("⬆", Style::default().fg(p.accent)), - ]); - frame.render_widget(Paragraph::new(line), 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, - 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 { - "preview before updating" - } 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 sections = release_notes_sections(stack.content, notes.preview); - 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: sections.notes_body.height.max(1) as usize, - }; - let track = release_notes_scrollbar_rect(sections.notes_body, metrics); - let notes_text_area = track - .map(|_| { - Rect::new( - sections.notes_body.x, - sections.notes_body.y, - sections.notes_body.width.saturating_sub(1), - sections.notes_body.height, - ) - }) - .unwrap_or(sections.notes_body); - - if let Some(instructions_area) = sections.instructions { - render_release_notes_preview_panel(frame, instructions_area, ¬es.version, &app.palette); - } - - 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, 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(), - ); -} - -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.trim().to_string(); - if text.is_empty() { - lines.push((0, Line::raw(""))); - continue; - } - let width = 1 + text.chars().count(); - lines.push(( - width, - Line::from(vec![ - Span::raw(" "), - Span::styled( - text.to_uppercase(), - 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) struct ReleaseNotesSections { - pub instructions: Option<Rect>, - pub notes_body: Rect, -} - -pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections { - if preview && area.height >= 6 { - let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area); - ReleaseNotesSections { - instructions: Some(rows[0]), - notes_body: rows[1], - } - } else { - ReleaseNotesSections { - instructions: None, - notes_body: area, - } - } -} - -fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> { - let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD); - let text_style = Style::default().fg(p.text); - let code_style = Style::default() - .fg(p.accent) - .bg(p.surface0) - .add_modifier(Modifier::BOLD); - - vec![ - Line::from(vec![ - Span::styled( - "●", - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - Span::styled(" update available", title_style), - ]), - Line::from(vec![ - Span::styled("quit herdr and run ", text_style), - Span::styled("herdr update", code_style), - Span::styled(" to install", text_style), - ]), - ] -} - -fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) { - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<4>(area); - - let text_area = Rect::new( - rows[0].x + 1, - rows[0].y, - rows[0].width.saturating_sub(2), - rows[0].height, - ); - frame.render_widget( - Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }), - text_area, - ); - - let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1); - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - "─".repeat(divider_area.width as usize), - Style::default().fg(p.surface1), - )])), - divider_area, - ); -} - -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(panel_contrast_fg(&app.palette)) - .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(panel_contrast_fg(&app.palette)) - .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(panel_contrast_fg(&app.palette)) - .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(panel_contrast_fg(&app.palette)) - .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), - Span::styled("q", key), - Span::styled(" quit", 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 available", - 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 rect = Rect::new(inner.x, y, inner.width, 1); - - if *item == "update available" { - let line = if selected { - Line::from(vec![ - Span::styled( - " update available ", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - "● ", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - ]) - } else { - Line::from(vec![ - Span::styled(" update available ", Style::default().fg(app.palette.text)), - Span::styled( - "● ", - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - ]) - }; - frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect); - continue; - } - - let style = if selected { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .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, - ); - } -} - -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)); - - if !kb.custom_commands.is_empty() { - groups.push(( - "custom", - kb.custom_commands - .iter() - .map(|binding| (binding.label.clone(), "custom command")) - .collect(), - )); - } - - 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(panel_contrast_fg(&app.palette)) - .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(" 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(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); - - 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(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), - ); -} - -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(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 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(panel_contrast_fg(p)) - .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(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], - ); - } -} - -/// 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(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); -} - -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(panel_contrast_fg(p)) - .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(" ")); @@ -3540,9 +233,13 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V #[cfg(test)] mod tests { + use super::keybind_help::keybind_help_groups; + use super::release_notes::{release_notes_lines, release_notes_preview_lines}; + use super::scrollbar::scrollbar_thumb; use super::*; - use crate::{detect::Agent, workspace::Workspace}; + use crate::{app::state::Palette, layout::PaneInfo, workspace::Workspace}; use ratatui::{backend::TestBackend, Terminal}; + use ratatui::{style::Color, text::Line}; #[tokio::test] async fn focused_pane_cursor_wins_during_terminal_render() { @@ -3607,6 +304,37 @@ mod tests { } #[test] + fn expanded_sidebar_workspace_rows_show_state_before_name_without_numbers() { + let mut app = crate::app::state::AppState::test_new(); + let mut ws = Workspace::test_new("one"); + let repo = temp_git_repo("main"); + ws.identity_cwd = repo.clone(); + let root_pane = ws.tabs[0].root_pane; + ws.tabs[0].pane_cwds.insert(root_pane, repo.clone()); + + app.workspaces = vec![ws]; + app.selected = 0; + app.mode = Mode::Navigate; + + 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 card = app.view.workspace_card_areas[0].rect; + let line1 = buffer_row_text(buffer, card, card.y); + let line2 = buffer_row_text(buffer, card, card.y + 1); + + assert!(line1.starts_with(" · one")); + assert!(!line1.contains("1 one")); + assert_eq!(line2, " main"); + + std::fs::remove_dir_all(repo).ok(); + } + + #[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"); @@ -3765,78 +493,11 @@ mod tests { } #[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() { + fn pane_scrollbar_rect_overlays_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), + inner_rect: Rect::new(1, 1, 10, 6), scrollbar_rect: Some(Rect::new(10, 1, 1, 6)), is_focused: true, }; @@ -3844,6 +505,40 @@ mod tests { assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); } + #[tokio::test] + async fn compute_view_keeps_terminal_width_when_pane_scrollbar_is_visible() { + let mut app = crate::app::state::AppState::test_new(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + ws.tabs[0].runtimes.insert( + pane_id, + crate::pane::PaneRuntime::test_with_scrollback_bytes( + 12, + 4, + 4096, + b"000000000000\r\n111111111111\r\n222222222222\r\n333333333333\r\n444444444444\r\n", + ), + ); + + app.workspaces = vec![ws]; + app.active = Some(0); + app.selected = 0; + + compute_view(&mut app, Rect::new(0, 0, 40, 12)); + + let info = app.view.pane_infos.first().expect("pane info"); + assert_eq!(info.inner_rect.width, app.view.terminal_area.width); + assert_eq!( + info.scrollbar_rect, + Some(Rect::new( + info.inner_rect.x + info.inner_rect.width.saturating_sub(1), + info.inner_rect.y, + 1, + info.inner_rect.height, + )) + ); + } + #[test] fn scrollbar_stays_hidden_without_scrollback() { let metrics = crate::pane::ScrollMetrics { @@ -3915,6 +610,29 @@ mod tests { .collect::<String>() } + 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() + } + + fn temp_git_repo(branch: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("unix time") + .as_nanos(); + let root = std::env::temp_dir().join(format!("herdr-ui-test-{unique}")); + std::fs::create_dir_all(root.join(".git")).expect("create .git dir"); + std::fs::write( + root.join(".git/HEAD"), + format!("ref: refs/heads/{branch}\n"), + ) + .expect("write HEAD"); + root + } + #[test] fn release_notes_inline_code_spans_are_styled_without_backticks() { let palette = Palette::catppuccin(); @@ -3933,10 +651,10 @@ mod tests { let lines = release_notes_preview_lines("0.5.0", &palette); assert_eq!(lines.len(), 2); - assert_eq!(line_text(&lines[0]), "● update available"); + assert_eq!(line_text(&lines[0]), "● update ready"); assert_eq!( line_text(&lines[1]), - "quit herdr and run herdr update to install" + "detach from this session, then run herdr update in your shell" ); assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent)); assert_eq!(lines[0].spans[1].style.fg, Some(palette.text)); |
