aboutsummaryrefslogtreecommitdiff
path: root/src/ui.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-23 00:36:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-27 21:37:05 +0300
commita57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch)
tree25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/ui.rs
initial release
terminal workspace manager for AI coding agents. single rust binary, runs inside your existing terminal. workspaces, tiled panes, intelligent agent state detection. https://herdr.dev
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs870
1 files changed, 870 insertions, 0 deletions
diff --git a/src/ui.rs b/src/ui.rs
new file mode 100644
index 00000000..242853ea
--- /dev/null
+++ b/src/ui.rs
@@ -0,0 +1,870 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
+ Frame,
+};
+use tui_term::widget::PseudoTerminal;
+
+use crate::app::{AppState, Mode};
+use crate::detect::AgentState;
+use crate::layout::PaneInfo;
+
+const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
+const MIN_SIDEBAR_WIDTH: u16 = 16;
+const MAX_SIDEBAR_WIDTH: u16 = 36;
+
+/// Compute view geometry and reconcile pane sizes.
+/// Called before render to separate mutation from drawing.
+pub fn compute_view(app: &mut AppState, area: Rect) {
+ let sidebar_w = if app.sidebar_collapsed {
+ COLLAPSED_WIDTH
+ } else {
+ compute_sidebar_width(app)
+ };
+
+ let [sidebar_area, main_area] =
+ Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area);
+
+ let terminal_area = main_area;
+
+ // Compute split borders
+ let split_borders = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .map(|ws| ws.layout.splits(terminal_area))
+ .unwrap_or_default();
+
+ // Compute pane layout + reconcile sizes
+ let pane_infos = compute_pane_infos(app, terminal_area);
+
+ app.view = crate::app::ViewState {
+ sidebar_rect: sidebar_area,
+ terminal_area,
+ pane_infos,
+ split_borders,
+ };
+}
+
+/// Render the UI — reads AppState but does not mutate it.
+pub fn render(app: &AppState, frame: &mut Frame) {
+ let sidebar_area = app.view.sidebar_rect;
+ let terminal_area = app.view.terminal_area;
+
+ if app.sidebar_collapsed {
+ render_sidebar_collapsed(app, frame, sidebar_area);
+ } else {
+ render_sidebar(app, frame, sidebar_area);
+ }
+ render_panes(app, frame, terminal_area);
+
+ match app.mode {
+ Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
+ Mode::Resize => render_resize_overlay(app, frame, terminal_area),
+ Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
+ Mode::ContextMenu => {
+ render_navigate_overlay(app, frame, terminal_area);
+ render_context_menu(app, frame);
+ }
+ Mode::CreateSession | Mode::RenameSession => {}
+ Mode::Terminal => {}
+ }
+
+ // Update notification (rendered on top of everything)
+ if let Some(version) = &app.update_available {
+ if !app.update_dismissed {
+ render_update_notification(frame, terminal_area, version, app.accent);
+ }
+ }
+}
+
+/// 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();
+ };
+
+ if ws.zoomed {
+ let focused_id = ws.layout.focused();
+ if let Some(rt) = ws.runtimes.get(&focused_id) {
+ rt.resize(area.height, area.width);
+ }
+ return vec![PaneInfo {
+ id: focused_id,
+ rect: area,
+ inner_rect: area,
+ is_focused: true,
+ }];
+ }
+
+ let multi_pane = ws.layout.pane_count() > 1;
+ let terminal_active = app.mode == Mode::Terminal;
+ let mut pane_infos = ws.layout.panes(area);
+
+ for info in &mut pane_infos {
+ let 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
+ };
+ info.inner_rect = inner;
+
+ if let Some(rt) = ws.runtimes.get(&info.id) {
+ rt.resize(inner.height, inner.width);
+ }
+ }
+
+ pane_infos
+}
+
+/// Auto-scale sidebar width based on longest workspace name.
+fn compute_sidebar_width(app: &AppState) -> u16 {
+ if app.workspaces.is_empty() {
+ return app.sidebar_width; // config default for empty state
+ }
+ let max_name = app
+ .workspaces
+ .iter()
+ .map(|ws| {
+ let pane_count = ws.layout.pane_count();
+ let bracket = if pane_count > 1 { 3 + pane_count } else { 0 }; // " [●●]"
+ ws.name.len() + bracket
+ })
+ .max()
+ .unwrap_or(8);
+ // marker(2) + dot(2) + name + bracket + padding(2) + borders(2)
+ let needed = (max_name as u16) + 8;
+ needed.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
+}
+
+/// Collapsed sidebar: borderless strip of state dots with selection highlight.
+fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
+ let is_navigating = matches!(
+ app.mode,
+ Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu
+ );
+
+ // Thin vertical separator line on the right edge
+ let sep_style = if is_navigating {
+ Style::default().fg(app.accent)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+ 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 content_w = area.width.saturating_sub(1); // leave room for separator
+
+ for (i, ws) in app.workspaces.iter().enumerate() {
+ if i as u16 >= area.height {
+ break;
+ }
+ let (agg_state, agg_seen) = ws.aggregate_state();
+ let (icon, icon_style) = state_icon_style(agg_state, agg_seen);
+ let is_selected = i == app.selected && is_navigating;
+
+ let num_label = format!("{}", i + 1);
+
+ let row_style = if is_selected {
+ Style::default().bg(app.accent).fg(Color::Black)
+ } else {
+ Style::default()
+ };
+ let dim_style = if is_selected {
+ row_style
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ let y = area.y + i as u16;
+ let row_area = Rect::new(area.x, y, content_w, 1);
+
+ // Fill background for selected row
+ if is_selected {
+ let buf = frame.buffer_mut();
+ for x in row_area.x..row_area.x + row_area.width {
+ buf[(x, y)].set_style(row_style);
+ }
+ }
+
+ let line = Line::from(vec![
+ Span::styled(&num_label, dim_style),
+ Span::styled(" ", if is_selected { row_style } else { Style::default() }),
+ Span::styled(
+ icon,
+ if is_selected {
+ // Keep icon's fg color, just add the highlight bg
+ icon_style.bg(app.accent)
+ } else {
+ icon_style
+ },
+ ),
+ ]);
+ frame.render_widget(Paragraph::new(line), row_area);
+ }
+
+ // Toggle button at bottom
+ render_sidebar_toggle(frame, area, true);
+}
+
+fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
+ let is_navigating = matches!(
+ app.mode,
+ Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu
+ );
+
+ let highlight_style = if is_navigating {
+ Style::default()
+ .fg(Color::Black)
+ .bg(app.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::Black).bg(Color::DarkGray)
+ };
+
+ // Thin separator on right edge (consistent with collapsed)
+ let sep_style = if is_navigating {
+ Style::default().fg(app.accent)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+ 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);
+ }
+
+ // Content area (left of separator)
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+
+ // Title line
+ let title = match app.mode {
+ Mode::Terminal => format!(" {} ⏎", app.prefix_label),
+ Mode::Navigate | Mode::ContextMenu => " NAVIGATE".to_string(),
+ Mode::Resize => " RESIZE".to_string(),
+ Mode::CreateSession => " NEW".to_string(),
+ Mode::RenameSession => " RENAME".to_string(),
+ Mode::ConfirmClose => " CLOSE?".to_string(),
+ };
+ let title_style = if is_navigating {
+ Style::default()
+ .fg(app.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+ frame.render_widget(
+ Paragraph::new(Span::styled(&title, title_style)),
+ Rect::new(content.x, content.y, content.width, 1),
+ );
+
+ // Workspace list (below title)
+ let list_area = Rect::new(
+ content.x,
+ content.y + 1,
+ content.width,
+ content.height.saturating_sub(1),
+ );
+
+ let items: Vec<ListItem> = app
+ .workspaces
+ .iter()
+ .enumerate()
+ .map(|(i, ws)| {
+ let selected = i == app.selected && is_navigating;
+ // When highlighted: text goes black on highlight bg, but icons keep their color
+ let text_style = if selected {
+ highlight_style
+ } else {
+ Style::default()
+ };
+ let dim_style = if selected {
+ highlight_style
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ let marker = if Some(i) == app.active { "▸" } else { " " };
+ let (agg_state, agg_seen) = ws.aggregate_state();
+ let (rollup_icon, rollup_style) = state_icon_style(agg_state, agg_seen);
+ // Icon keeps its fg color, but picks up the highlight bg when selected
+ let icon_style = if selected {
+ rollup_style.bg(highlight_style.bg.unwrap_or(app.accent))
+ } else {
+ rollup_style
+ };
+
+ let num_label = format!("{}", i + 1);
+ let num_style = if selected {
+ highlight_style
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ let mut spans = vec![
+ Span::styled(num_label, num_style),
+ Span::styled(format!("{marker}"), text_style),
+ Span::styled(rollup_icon, icon_style),
+ Span::styled(format!(" {}", ws.name), text_style),
+ ];
+
+ let pane_states = ws.pane_states();
+ let has_any_agent = pane_states.iter().any(|(s, _)| *s != AgentState::Unknown);
+
+ if ws.layout.pane_count() > 1 {
+ if has_any_agent {
+ spans.push(Span::styled(" [", dim_style));
+ for (pane_state, pane_seen) in &pane_states {
+ let (icon, style) = state_icon_style(*pane_state, *pane_seen);
+ let pane_icon_style = if selected {
+ style.bg(highlight_style.bg.unwrap_or(app.accent))
+ } else {
+ style
+ };
+ spans.push(Span::styled(icon, pane_icon_style));
+ }
+ spans.push(Span::styled("]", dim_style));
+ } else {
+ let count = ws.layout.pane_count();
+ spans.push(Span::styled(format!(" [{count}]"), dim_style));
+ }
+ }
+
+ ListItem::new(Line::from(spans))
+ })
+ .collect();
+
+ // No highlight_style on List — we handle it per-span above to preserve icon colors
+ let list = List::new(items);
+ let mut state = ListState::default().with_selected(Some(app.selected));
+ frame.render_stateful_widget(list, list_area, &mut state);
+
+ if app.mode == Mode::CreateSession {
+ let input_y = list_area.y + list_area.height.saturating_sub(1);
+ let input_area = Rect::new(content.x, input_y, content.width, 1);
+ let text = format!(" Name: {}\u{2588}", app.name_input);
+ frame.render_widget(
+ Paragraph::new(text).style(Style::default().fg(Color::Yellow)),
+ input_area,
+ );
+ }
+
+ if app.mode == Mode::RenameSession {
+ let input_y = list_area.y + app.selected as u16;
+ let input_area = Rect::new(content.x, input_y, content.width, 1);
+ let text = format!(" ✎ {}\u{2588}", app.name_input);
+ frame.render_widget(Clear, input_area);
+ frame.render_widget(
+ Paragraph::new(text).style(Style::default().fg(Color::Yellow)),
+ input_area,
+ );
+ }
+
+ // Toggle button at bottom (skip when input field occupies that row)
+ if app.mode != Mode::CreateSession {
+ render_sidebar_toggle(frame, area, false);
+ }
+}
+
+fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) {
+ let bottom_y = area.y + area.height.saturating_sub(1);
+ let content_w = area.width.saturating_sub(1); // exclude separator
+ if content_w == 0 || area.height == 0 {
+ return;
+ }
+ let icon = if collapsed { "»" } else { "«" };
+ // Center the icon in the content area
+ let x = area.x + content_w / 2;
+ let toggle_area = Rect::new(x, bottom_y, 1, 1);
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ icon,
+ Style::default().fg(Color::DarkGray),
+ )),
+ toggle_area,
+ );
+}
+
+fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(ws_idx) = app.active else {
+ render_empty(frame, area, app.accent);
+ return;
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ render_empty(frame, area, app.accent);
+ 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.accent),
+ ratatui::symbols::border::THICK,
+ )
+ } else if info.is_focused {
+ (
+ Style::default().fg(app.accent),
+ ratatui::symbols::border::PLAIN,
+ )
+ } else {
+ (
+ Style::default().fg(Color::DarkGray),
+ 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
+ if let Ok(parser) = rt.parser.read() {
+ let pt = PseudoTerminal::new(parser.screen());
+ frame.render_widget(pt, info.inner_rect);
+ }
+
+ // 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);
+ }
+ }
+}
+
+/// 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,
+ }
+}
+
+fn render_selection_highlight(
+ selection: &Option<crate::selection::Selection>,
+ frame: &mut Frame,
+ pane_id: crate::layout::PaneId,
+ inner: Rect,
+) {
+ 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) {
+ let cell = &mut buf[(inner.x + x, inner.y + y)];
+ // Fixed highlight: white text on blue background.
+ // Consistent regardless of the cell's original colors.
+ cell.set_style(
+ Style::default()
+ .fg(Color::White)
+ .bg(Color::Rgb(40, 80, 140)),
+ );
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_empty(frame: &mut Frame, area: Rect, accent: Color) {
+ let lines = vec![
+ Line::from(""),
+ Line::from(""),
+ Line::from(Span::styled(
+ " No active workspace",
+ Style::default().fg(Color::DarkGray),
+ )),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
+ Span::styled(
+ "n",
+ Style::default()
+ .fg(accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" to create one", Style::default().fg(Color::DarkGray)),
+ ]),
+ ];
+ frame.render_widget(
+ Paragraph::new(lines).block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray)),
+ ),
+ area,
+ );
+}
+
+/// 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.accent)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(Color::DarkGray);
+ let label = Style::default().fg(Color::White);
+
+ let is_full = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .is_some_and(|ws| ws.zoomed);
+
+ let full_key = if is_full { "unfull" } else { "full" };
+
+ let line1 = Line::from(vec![
+ Span::styled(" n", key),
+ Span::styled(" new ", dim),
+ Span::styled("N", key),
+ Span::styled(" rename ", dim),
+ Span::styled("d", key),
+ Span::styled(" close ", dim),
+ Span::styled("v", key),
+ Span::styled(" split│ ", dim),
+ Span::styled("-", key),
+ Span::styled(" split─ ", dim),
+ Span::styled("⇥", key),
+ Span::styled(" pane ", dim),
+ Span::styled("f", key),
+ Span::styled(format!(" {full_key} "), dim),
+ Span::styled("r", key),
+ Span::styled(" resize ", dim),
+ Span::styled("b", key),
+ Span::styled(" sidebar ", dim),
+ Span::styled("q", key),
+ Span::styled(" quit", dim),
+ ]);
+
+ let ws_name = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .map(|ws| ws.name.as_str())
+ .unwrap_or("—");
+
+ let pane_info = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .filter(|ws| ws.layout.pane_count() > 1)
+ .map(|ws| {
+ let ids = ws.layout.pane_ids();
+ let pos = ids
+ .iter()
+ .position(|id| *id == ws.layout.focused())
+ .unwrap_or(0);
+ format!(" [{}/{}]", pos + 1, ids.len())
+ })
+ .unwrap_or_default();
+
+ let mode_style = Style::default()
+ .fg(Color::Black)
+ .bg(app.accent)
+ .add_modifier(Modifier::BOLD);
+
+ let line2 = Line::from(vec![
+ Span::styled(" NAVIGATE ", mode_style),
+ Span::raw(" "),
+ Span::styled(ws_name, label),
+ Span::styled(&pane_info, dim),
+ Span::raw(" "),
+ Span::styled("esc", key),
+ Span::styled(" back ", dim),
+ Span::styled("↑↓", key),
+ Span::styled(" select ", dim),
+ Span::styled("⏎", key),
+ Span::styled(" open", dim),
+ ]);
+
+ let overlay_height = 2;
+ let overlay_y = area.y + area.height.saturating_sub(overlay_height);
+ let overlay_area = Rect::new(area.x, overlay_y, area.width, overlay_height);
+
+ // Clear the area behind the overlay
+ frame.render_widget(Clear, overlay_area);
+
+ let bg = Style::default().bg(Color::Black);
+ let buf = frame.buffer_mut();
+ for y in overlay_area.y..overlay_area.y + overlay_area.height {
+ for x in overlay_area.x..overlay_area.x + overlay_area.width {
+ buf[(x, y)].set_style(bg);
+ }
+ }
+
+ let [row1, row2] =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(overlay_area);
+ frame.render_widget(Paragraph::new(line1), row1);
+ frame.render_widget(Paragraph::new(line2), row2);
+}
+
+/// Floating overlay for resize mode.
+fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let key = Style::default()
+ .fg(app.accent)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(Color::DarkGray);
+
+ let mode_style = Style::default()
+ .fg(Color::Black)
+ .bg(Color::Magenta)
+ .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(Color::Black);
+ 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.
+fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let ws_name = app
+ .workspaces
+ .get(app.selected)
+ .map(|ws| ws.name.as_str())
+ .unwrap_or("?");
+ 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 popup_w = 44u16.min(area.width.saturating_sub(4));
+ let popup_h = 5u16;
+ let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
+ let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
+ let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
+
+ let key = Style::default()
+ .fg(app.accent)
+ .add_modifier(Modifier::BOLD);
+ let warn = Style::default()
+ .fg(Color::Red)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(Color::DarkGray);
+
+ 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(Color::White).add_modifier(Modifier::BOLD)),
+ Span::styled(format!(" — {pane_text}"), dim),
+ ]);
+
+ let action_line = Line::from(vec![
+ Span::raw(" "),
+ Span::styled("y", key),
+ Span::styled("/", dim),
+ Span::styled("enter", key),
+ Span::styled(" confirm ", dim),
+ Span::styled("any key", key),
+ Span::styled(" cancel", dim),
+ ]);
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::Red))
+ .style(Style::default().bg(Color::Black));
+
+ let inner = block.inner(popup);
+ frame.render_widget(Clear, popup);
+ frame.render_widget(block, popup);
+
+ 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]);
+ frame.render_widget(Paragraph::new(action_line), rows[2]);
+ }
+}
+
+/// Right-click context menu popup anchored near the click position.
+fn render_context_menu(app: &AppState, frame: &mut Frame) {
+ use crate::app::CONTEXT_MENU_ITEMS;
+
+ let Some(menu) = &app.context_menu else {
+ return;
+ };
+
+ let menu_w = 14u16;
+ let menu_h = CONTEXT_MENU_ITEMS.len() as u16 + 2; // +2 for border
+ let area = frame.area();
+
+ // Position: try to place below-right of click, clamp to screen
+ let x = menu.x.min(area.width.saturating_sub(menu_w));
+ let y = menu.y.min(area.height.saturating_sub(menu_h));
+ let menu_rect = Rect::new(x, y, menu_w, menu_h);
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(app.accent))
+ .style(Style::default().bg(Color::Black));
+ let inner = block.inner(menu_rect);
+
+ frame.render_widget(Clear, menu_rect);
+ frame.render_widget(block, menu_rect);
+
+ let highlight = Style::default()
+ .fg(Color::Black)
+ .bg(app.accent)
+ .add_modifier(Modifier::BOLD);
+ let normal = Style::default().fg(Color::White);
+
+ for (i, item) in CONTEXT_MENU_ITEMS.iter().enumerate() {
+ if i as u16 >= inner.height {
+ break;
+ }
+ let style = if i == menu.selected { highlight } else { normal };
+ let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
+ frame.render_widget(
+ Paragraph::new(format!(" {item}")).style(style),
+ row,
+ );
+ }
+}
+
+fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, accent: Color) {
+ let text = format!(" ✦ herdr v{version} installed — restart to update ");
+ let width = text.len() as u16 + 2;
+ let x = area.x + area.width.saturating_sub(width) / 2;
+ let y = area.y + area.height.saturating_sub(3);
+ let notif_area = Rect::new(x, y, width.min(area.width), 1);
+
+ frame.render_widget(Clear, notif_area);
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ text,
+ Style::default()
+ .fg(Color::Black)
+ .bg(accent)
+ .add_modifier(Modifier::BOLD),
+ )),
+ notif_area,
+ );
+}
+
+/// Visual badge for a pane's state + seen flag.
+///
+/// | State | Icon | Color |
+/// |--------------------|------|--------|
+/// | Busy | ● | Yellow |
+/// | Done (idle+unseen) | ● | Blue |
+/// | Idle (seen) | ○ | Green |
+/// | Unknown | · | Gray |
+///
+/// Filled dot = needs attention (working, or finished unseen).
+/// Hollow dot = nothing to do here.
+fn state_icon_style(state: AgentState, seen: bool) -> (&'static str, Style) {
+ match (state, seen) {
+ (AgentState::Waiting, _) => ("●", Style::default().fg(Color::Red)),
+ (AgentState::Busy, _) => ("●", Style::default().fg(Color::Yellow)),
+ (AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done
+ (AgentState::Idle, true) => ("○", Style::default().fg(Color::Green)),
+ (AgentState::Unknown, _) => ("·", Style::default().fg(Color::DarkGray)),
+ }
+}
+
+fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec<Span<'static>> {
+ let mut spans = Vec::new();
+ spans.push(Span::raw(" "));
+ for (i, (k, desc)) in items.iter().enumerate() {
+ if i > 0 {
+ spans.push(Span::styled(" ", dim_style));
+ }
+ spans.push(Span::styled(k.to_string(), key_style));
+ spans.push(Span::styled(format!(" {desc}"), dim_style));
+ }
+ spans
+}