aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/dialogs.rs213
-rw-r--r--src/ui/keybind_help.rs240
-rw-r--r--src/ui/menus.rs210
-rw-r--r--src/ui/onboarding.rs215
-rw-r--r--src/ui/panes.rs252
-rw-r--r--src/ui/release_notes.rs345
-rw-r--r--src/ui/scrollbar.rs191
-rw-r--r--src/ui/settings.rs238
-rw-r--r--src/ui/sidebar.rs940
-rw-r--r--src/ui/status.rs135
-rw-r--r--src/ui/tabs.rs356
-rw-r--r--src/ui/widgets.rs209
12 files changed, 3544 insertions, 0 deletions
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs
new file mode 100644
index 00000000..78f1d1cb
--- /dev/null
+++ b/src/ui/dialogs.rs
@@ -0,0 +1,213 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, Paragraph},
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button,
+ render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec,
+};
+use crate::app::{AppState, Mode};
+
+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])
+}
+
+pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ super::dim_background(frame, area);
+
+ let title = match app.mode {
+ Mode::RenameWorkspace => "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),
+ );
+}
+
+pub(super) 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")
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(popup) = confirm_close_popup_rect(area) else {
+ return;
+ };
+
+ let warn = Style::default()
+ .fg(app.palette.red)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(app.palette.overlay0);
+
+ let title_line = Line::from(vec![Span::styled(" 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 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])
+}
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
new file mode 100644
index 00000000..ccff95ab
--- /dev/null
+++ b/src/ui/keybind_help.rs
@@ -0,0 +1,240 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Paragraph, Wrap},
+ Frame,
+};
+
+use super::release_notes::release_notes_close_button_rect;
+use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
+use super::widgets::{
+ modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header,
+ render_modal_shell,
+};
+use crate::app::AppState;
+
+fn optional_keybind_label(label: &Option<String>) -> String {
+ label.clone().unwrap_or_else(|| "unset".to_string())
+}
+
+pub(super) 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 mut 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"),
+ ];
+ if let Some(label) = &kb.detach_label {
+ workspace_tab.insert(3, (label.clone(), "detach from server"));
+ }
+ 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
+}
+
+pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
+ super::dim_background(frame, frame.area());
+
+ let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
+ return;
+ };
+ if inner.height < 6 || inner.width < 20 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+
+ render_modal_header(frame, header_rows[0], "keybinds", &app.palette);
+ render_action_button(
+ frame,
+ release_notes_close_button_rect(header_rows[0]),
+ Some("esc"),
+ "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(),
+ );
+}
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
new file mode 100644
index 00000000..537ac9a1
--- /dev/null
+++ b/src/ui/menus.rs
@@ -0,0 +1,210 @@
+use ratatui::{
+ layout::{Alignment, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, List, ListItem, ListState, Paragraph},
+ Frame,
+};
+
+use super::widgets::{panel_contrast_fg, render_panel_shell};
+use crate::app::AppState;
+
+fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) {
+ frame.render_widget(Clear, area);
+ let buf = frame.buffer_mut();
+ for x in area.x..area.x + area.width {
+ buf[(x, area.y)].set_style(Style::default().bg(bg));
+ }
+ frame.render_widget(Paragraph::new(line), area);
+}
+
+pub(super) fn render_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);
+ render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
+
+ if app.update_available.is_some() {
+ let status = Line::from(vec![Span::styled(
+ " update ready",
+ Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ )]);
+ let width = 13u16.min(overlay_area.width);
+ let status_area = Rect::new(
+ overlay_area.x + overlay_area.width.saturating_sub(width),
+ overlay_area.y,
+ width,
+ overlay_area.height,
+ );
+ frame.render_widget(Clear, status_area);
+ frame.render_widget(
+ Paragraph::new(status).alignment(Alignment::Right),
+ status_area,
+ );
+ }
+}
+
+pub(super) fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
+ let rect = app.global_menu_rect();
+ let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg)
+ else {
+ return;
+ };
+
+ let items = app.global_menu_labels();
+ for (idx, item) in items.iter().enumerate() {
+ let y = inner.y + idx as u16;
+ if y >= inner.y + inner.height {
+ break;
+ }
+ let selected = idx == app.global_menu.highlighted;
+ let rect = Rect::new(inner.x, y, inner.width, 1);
+
+ if *item == "update ready" {
+ let line = if selected {
+ Line::from(vec![
+ Span::styled(
+ " update ready ",
+ 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 ready ", 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,
+ );
+ }
+}
+
+pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let key = Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(app.palette.overlay0);
+
+ let mode_style = Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.mauve)
+ .add_modifier(Modifier::BOLD);
+
+ let line = Line::from(vec![
+ Span::styled(" RESIZE ", mode_style),
+ Span::raw(" "),
+ Span::styled("h/l", key),
+ Span::styled(" width ", dim),
+ Span::styled("j/k", key),
+ Span::styled(" height ", dim),
+ Span::styled("esc", key),
+ Span::styled(" done", dim),
+ ]);
+
+ let overlay_y = area.y + area.height.saturating_sub(1);
+ let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
+ render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
+}
+
+pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) {
+ let Some(menu) = &app.context_menu else {
+ return;
+ };
+
+ let p = &app.palette;
+ let Some(menu_rect) = app.context_menu_rect() else {
+ return;
+ };
+ let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else {
+ return;
+ };
+
+ let items: Vec<ListItem> = menu
+ .items()
+ .iter()
+ .map(|item| ListItem::new(Line::from(*item)))
+ .collect();
+ let list = List::new(items)
+ .style(Style::default().fg(p.text))
+ .highlight_style(
+ Style::default()
+ .bg(p.accent)
+ .fg(panel_contrast_fg(p))
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ");
+ let mut state = ListState::default().with_selected(Some(menu.list.highlighted));
+ frame.render_stateful_widget(list, inner, &mut state);
+}
diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs
new file mode 100644
index 00000000..6f35c1a6
--- /dev/null
+++ b/src/ui/onboarding.rs
@@ -0,0 +1,215 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, action_button_width, modal_stack_areas, panel_contrast_fg,
+ render_action_button, render_modal_header, render_modal_shell, ActionButtonSpec,
+};
+use crate::app::AppState;
+
+const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
+
+pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ super::dim_background(frame, area);
+
+ match app.onboarding_step {
+ 0 => render_onboarding_welcome(app, frame, area),
+ _ => render_onboarding_notifications(app, frame, area),
+ }
+}
+
+pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect {
+ Rect::new(
+ area.x,
+ area.y,
+ action_button_width(Some("↵"), "continue"),
+ 1,
+ )
+}
+
+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),
+ );
+}
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
new file mode 100644
index 00000000..7e1490db
--- /dev/null
+++ b/src/ui/panes.rs
@@ -0,0 +1,252 @@
+use ratatui::{
+ layout::Rect,
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph},
+ Frame,
+};
+
+use super::scrollbar::{render_pane_scrollbar, should_show_scrollbar};
+use super::widgets::panel_contrast_fg;
+use crate::app::state::Palette;
+use crate::app::{AppState, Mode};
+use crate::layout::PaneInfo;
+
+/// Compute pane layout info and optionally resize pane runtimes to match.
+pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) -> 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 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 > 0)
+ {
+ scrollbar_rect = Some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ area.y,
+ 1,
+ area.height,
+ ));
+ }
+ if resize_panes {
+ 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 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 > 0)
+ {
+ scrollbar_rect = Some(Rect::new(
+ pane_inner.x + pane_inner.width.saturating_sub(1),
+ pane_inner.y,
+ 1,
+ pane_inner.height,
+ ));
+ }
+ if resize_panes {
+ rt.resize(inner_rect.height, inner_rect.width);
+ }
+ }
+
+ info.inner_rect = inner_rect;
+ info.scrollbar_rect = scrollbar_rect;
+ }
+
+ pane_infos
+}
+
+pub(super) 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) {
+ 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);
+ }
+
+ rt.render(frame, info.inner_rect, info.is_focused && terminal_active);
+ render_pane_scrollbar(app, frame, info, rt);
+
+ let should_dim = !info.is_focused && multi_pane && !terminal_active;
+ if should_dim {
+ let inner = info.inner_rect;
+ let buf = frame.buffer_mut();
+ for y in inner.y..inner.y + inner.height {
+ for x in inner.x..inner.x + inner.width {
+ let cell = &mut buf[(x, y)];
+ 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));
+ }
+ }
+ }
+
+ 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),
+ _ => Color::DarkGray,
+ }
+}
+
+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,
+ );
+}
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
new file mode 100644
index 00000000..346b06d5
--- /dev/null
+++ b/src/ui/release_notes.rs
@@ -0,0 +1,345 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Paragraph, Wrap},
+ Frame,
+};
+
+use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
+use super::widgets::{
+ action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button,
+ render_modal_header, render_modal_shell,
+};
+use crate::app::{
+ state::{Palette, ReleaseNotesState},
+ AppState,
+};
+
+pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
+
+pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(notes) = &app.release_notes else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(
+ frame,
+ area,
+ RELEASE_NOTES_MODAL_SIZE.0,
+ RELEASE_NOTES_MODAL_SIZE.1,
+ &app.palette,
+ ) else {
+ return;
+ };
+ if inner.height < 8 || inner.width < 20 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+
+ let header_title_area = Rect::new(
+ header_rows[0].x + 1,
+ header_rows[0].y,
+ header_rows[0].width.saturating_sub(2),
+ header_rows[0].height,
+ );
+ let header_subtitle_area = Rect::new(
+ header_rows[1].x + 1,
+ header_rows[1].y,
+ header_rows[1].width.saturating_sub(2),
+ header_rows[1].height,
+ );
+
+ render_modal_header(
+ frame,
+ header_title_area,
+ &format!("v{}", notes.version),
+ &app.palette,
+ );
+ let subtitle = if notes.preview {
+ "update ready"
+ } else {
+ "what's new in this release"
+ };
+ frame.render_widget(
+ Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)),
+ header_subtitle_area,
+ );
+ render_action_button(
+ frame,
+ release_notes_close_button_rect(header_rows[0]),
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ let 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, &notes.version, &app.palette);
+ }
+
+ let body = Paragraph::new(
+ release_notes_display_lines(notes, &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,
+ }
+ }
+}
+
+pub(super) 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 ready", title_style),
+ ]),
+ Line::from(vec![
+ Span::styled("detach from this session, then run ", text_style),
+ Span::styled("herdr update", code_style),
+ Span::styled(" in your shell", 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_display_lines<'a>(
+ notes: &'a ReleaseNotesState,
+ p: &Palette,
+) -> Vec<(usize, Line<'a>)> {
+ release_notes_lines(notes.body.as_str(), p)
+}
+
+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)
+}
diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs
new file mode 100644
index 00000000..34e739d0
--- /dev/null
+++ b/src/ui/scrollbar.rs
@@ -0,0 +1,191 @@
+use ratatui::{
+ layout::Rect,
+ style::{Color, Style},
+ Frame,
+};
+
+use crate::app::AppState;
+use crate::layout::PaneInfo;
+
+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)
+}
+
+pub(super) 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));
+ }
+}
+
+pub(super) 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,
+ );
+}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
new file mode 100644
index 00000000..1123f408
--- /dev/null
+++ b/src/ui/settings.rs
@@ -0,0 +1,238 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{List, ListItem, ListState, Paragraph, Tabs},
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg,
+ render_action_button, render_panel_shell, ActionButtonSpec,
+};
+use crate::app::{state::Palette, AppState};
+
+pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ use crate::app::state::SettingsSection;
+
+ let p = &app.palette;
+ let Some(popup) = centered_popup_rect(area, 56, 20) else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else {
+ return;
+ };
+ if inner.height < 4 || inner.width < 10 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 3, 2, 0, 1);
+ let header_rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<3>(stack.header);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " settings",
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD),
+ )])),
+ header_rows[0],
+ );
+
+ let 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]);
+
+ let sep = "─".repeat(inner.width as usize);
+ frame.render_widget(
+ Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
+ header_rows[2],
+ );
+
+ let content_area = stack.content;
+
+ match app.settings.section {
+ SettingsSection::Theme => {
+ render_settings_theme(app, frame, content_area);
+ }
+ SettingsSection::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,
+ );
+ }
+ }
+
+ 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],
+ );
+ }
+}
+
+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])
+}
+
+fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
+ use crate::app::state::THEME_NAMES;
+
+ let p = &app.palette;
+ let items: Vec<ListItem> = THEME_NAMES
+ .iter()
+ .map(|name| {
+ let is_current = name.to_lowercase().replace([' ', '_'], "-")
+ == app.theme_name.to_lowercase().replace([' ', '_'], "-");
+ let marker = if is_current { " ✓" } else { "" };
+ ListItem::new(Line::from(vec![
+ Span::styled(*name, Style::default().fg(p.subtext0)),
+ Span::styled(marker, Style::default().fg(p.green)),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(
+ Style::default()
+ .bg(p.surface0)
+ .fg(p.text)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ▸ ")
+ .style(Style::default().fg(p.subtext0));
+
+ let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
+ frame.render_stateful_widget(list, area, &mut state);
+}
+
+fn render_settings_toggle(
+ frame: &mut Frame,
+ area: Rect,
+ p: &Palette,
+ title: &str,
+ description: &str,
+ current_value: bool,
+ selected_idx: usize,
+) {
+ 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);
+}
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
new file mode 100644
index 00000000..36d1385a
--- /dev/null
+++ b/src/ui/sidebar.rs
@@ -0,0 +1,940 @@
+use ratatui::{
+ layout::{Alignment, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::scrollbar::{render_scrollbar, should_show_scrollbar};
+use super::status::{agent_icon, state_dot, state_label, state_label_color};
+use crate::app::state::{AgentPanelScope, Palette};
+use crate::app::{AppState, Mode};
+use crate::detect::AgentState;
+
+const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
+const AGENT_PANEL_HEADER_ROWS: u16 = 3;
+
+pub(crate) struct AgentPanelEntry {
+ pub ws_idx: usize,
+ pub tab_idx: usize,
+ pub pane_id: crate::layout::PaneId,
+ pub primary_label: String,
+ pub primary_tab_label: Option<String>,
+ pub agent_label: Option<String>,
+ pub state: AgentState,
+ pub seen: bool,
+}
+
+fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
+ if total_h == 0 {
+ return (0, 0);
+ }
+
+ if total_h < 6 {
+ let ws_h = (total_h + 1) / 2;
+ return (ws_h, total_h.saturating_sub(ws_h));
+ }
+
+ let ratio = split_ratio.clamp(0.1, 0.9);
+ let ws_h = ((total_h as f32) * ratio).round() as u16;
+ let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
+ let detail_h = total_h.saturating_sub(ws_h);
+ (ws_h, detail_h)
+}
+
+pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height == 0 {
+ return (Rect::default(), Rect::default());
+ }
+
+ let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
+ let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
+ let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
+ (ws_area, detail_area)
+}
+
+pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height < 6 {
+ return Rect::default();
+ }
+
+ let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
+ Rect::new(content.x, content.y + ws_h, content.width, 1)
+}
+
+fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
+ if matches!(
+ app.mode,
+ Mode::Navigate
+ | Mode::RenameWorkspace
+ | Mode::Resize
+ | Mode::ConfirmClose
+ | Mode::ContextMenu
+ | Mode::Settings
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ ) {
+ Some(app.selected)
+ } else {
+ app.active
+ }
+}
+
+fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
+ match scope {
+ AgentPanelScope::CurrentWorkspace => "current",
+ AgentPanelScope::AllWorkspaces => "all",
+ }
+}
+
+pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
+ if area.width == 0 || area.height < 2 {
+ return Rect::default();
+ }
+
+ let label = agent_panel_toggle_label(scope);
+ let width = label.chars().count() as u16;
+ Rect::new(
+ area.x + area.width.saturating_sub(width),
+ area.y + 1,
+ width,
+ 1,
+ )
+}
+
+pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
+ match app.agent_panel_scope {
+ AgentPanelScope::CurrentWorkspace => {
+ let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
+ return Vec::new();
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+ ws.pane_details()
+ .into_iter()
+ .map(|detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ primary_label: detail.label,
+ primary_tab_label: None,
+ agent_label: None,
+ state: detail.state,
+ seen: detail.seen,
+ })
+ .collect()
+ }
+ AgentPanelScope::AllWorkspaces => app
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ let multi_tab = ws.tabs.len() > 1;
+ let workspace_label = ws.display_name();
+ ws.pane_details()
+ .into_iter()
+ .map(move |detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ primary_label: workspace_label.clone(),
+ primary_tab_label: multi_tab.then_some(detail.tab_label),
+ agent_label: Some(detail.agent_label),
+ state: detail.state,
+ seen: detail.seen,
+ })
+ })
+ .collect(),
+ }
+}
+
+fn truncate_text(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width == 0 {
+ return String::new();
+ }
+ if max_width == 1 {
+ return "…".to_string();
+ }
+ let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
+ format!("{prefix}…")
+}
+
+fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
+ let Some(tab_label) = entry.primary_tab_label.as_deref() else {
+ return truncate_text(&entry.primary_label, max_width);
+ };
+
+ let separator = " · ";
+ let separator_width = separator.chars().count();
+ if max_width <= separator_width + 2 {
+ return truncate_text(
+ &format!("{}{}{}", entry.primary_label, separator, tab_label),
+ max_width,
+ );
+ }
+
+ let available = max_width.saturating_sub(separator_width);
+ let min_tab = 4.min(available.saturating_sub(1)).max(1);
+ let preferred_workspace = ((available * 2) / 3).max(1);
+ let mut workspace_budget = preferred_workspace
+ .min(available.saturating_sub(min_tab))
+ .max(1);
+ let mut tab_budget = available.saturating_sub(workspace_budget);
+
+ let workspace_len = entry.primary_label.chars().count();
+ let tab_len = tab_label.chars().count();
+
+ if workspace_len < workspace_budget {
+ let spare = workspace_budget - workspace_len;
+ workspace_budget = workspace_len;
+ tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
+ }
+ if tab_len < tab_budget {
+ let spare = tab_budget - tab_len;
+ tab_budget = tab_len;
+ workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
+ }
+
+ format!(
+ "{}{}{}",
+ truncate_text(&entry.primary_label, workspace_budget),
+ separator,
+ truncate_text(tab_label, tab_budget)
+ )
+}
+
+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
+}
+
+/// 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.
+pub(super) 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)
+}
+
+pub(super) 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)
+ };
+
+ let sep_x = area.x + area.width.saturating_sub(1);
+ let buf = frame.buffer_mut();
+ for y in area.y..area.y + area.height {
+ buf[(sep_x, y)].set_symbol("│");
+ buf[(sep_x, y)].set_style(sep_style);
+ }
+
+ let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+
+ render_workspace_list(app, frame, ws_area, is_navigating);
+ render_agent_detail(app, frame, detail_area);
+ render_sidebar_toggle(app, frame, area, false, p);
+}
+
+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,
+ };
+
+ 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 (icon, icon_style) = state_dot(agg_state, agg_seen, p);
+ let line1 = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(icon, icon_style),
+ Span::styled(" ", Style::default()),
+ Span::styled(ws.display_name(), name_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, "▕");
+ }
+
+ 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,
+ );
+ }
+}
+
+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,
+) {
+ 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);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{detect::Agent, workspace::Workspace};
+
+ #[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());
+ }
+}
diff --git a/src/ui/status.rs b/src/ui/status.rs
new file mode 100644
index 00000000..2eece8a0
--- /dev/null
+++ b/src/ui/status.rs
@@ -0,0 +1,135 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+use super::widgets::panel_contrast_fg;
+use crate::{
+ app::state::{Palette, ToastKind, ToastNotification},
+ detect::AgentState,
+};
+
+pub(super) 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);
+}
+
+pub(super) 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,
+ );
+}
+
+pub(super) 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)),
+ }
+}
+
+pub(super) 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, _) => (super::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)),
+ }
+}
+
+pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str {
+ match (state, seen) {
+ (AgentState::Blocked, _) => "blocked",
+ (AgentState::Working, _) => "working",
+ (AgentState::Idle, false) => "done",
+ (AgentState::Idle, true) => "idle",
+ (AgentState::Unknown, _) => "idle",
+ }
+}
+
+pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color {
+ match (state, seen) {
+ (AgentState::Blocked, _) => p.red,
+ (AgentState::Working, _) => p.yellow,
+ (AgentState::Idle, false) => p.teal,
+ (AgentState::Idle, true) => p.green,
+ (AgentState::Unknown, _) => p.overlay0,
+ }
+}
diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs
new file mode 100644
index 00000000..400ee65f
--- /dev/null
+++ b/src/ui/tabs.rs
@@ -0,0 +1,356 @@
+use ratatui::{
+ layout::Rect,
+ style::{Modifier, Style},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::widgets::panel_contrast_fg;
+use crate::app::AppState;
+
+const MIN_TAB_WIDTH: u16 = 8;
+const NEW_TAB_WIDTH: u16 = 3;
+const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct TabBarView {
+ pub scroll: usize,
+ pub tab_hit_areas: Vec<Rect>,
+ pub scroll_left_hit_area: Rect,
+ pub scroll_right_hit_area: Rect,
+ pub new_tab_hit_area: Rect,
+}
+
+fn tab_width(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
+}
+
+pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let Some(active_ws_idx) = app.active else {
+ return;
+ };
+ let Some(ws) = app.workspaces.get(active_ws_idx) else {
+ return;
+ };
+
+ let p = &app.palette;
+
+ frame.render_widget(
+ Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
+ area,
+ );
+
+ let first_visible_idx = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx);
+ let last_visible_idx = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx);
+ let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0;
+ let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
+ && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
+
+ if app.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));
+ }
+ }
+}
diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs
new file mode 100644
index 00000000..354b7884
--- /dev/null
+++ b/src/ui/widgets.rs
@@ -0,0 +1,209 @@
+use ratatui::{
+ layout::{Alignment, Constraint, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+use crate::app::state::Palette;
+
+pub(super) fn render_panel_shell(
+ frame: &mut Frame,
+ area: Rect,
+ border_color: Color,
+ bg: Color,
+) -> Option<Rect> {
+ if area.width < 2 || area.height < 2 {
+ return None;
+ }
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(border_color))
+ .border_set(ratatui::symbols::border::PLAIN)
+ .style(Style::default().bg(bg));
+ let inner = block.inner(area);
+ frame.render_widget(Clear, area);
+ frame.render_widget(block, area);
+ Some(inner)
+}
+
+pub(super) fn panel_contrast_fg(p: &Palette) -> Color {
+ match p.panel_bg {
+ Color::Reset => p.surface_dim,
+ color => color,
+ }
+}
+
+pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> {
+ let popup_w = popup_w.min(area.width.saturating_sub(4));
+ let popup_h = popup_h.min(area.height.saturating_sub(2));
+ if popup_w < 4 || popup_h < 4 {
+ return None;
+ }
+
+ let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
+ let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
+ Some(Rect::new(popup_x, popup_y, popup_w, popup_h))
+}
+
+pub(super) fn render_modal_shell(
+ frame: &mut Frame,
+ area: Rect,
+ popup_w: u16,
+ popup_h: u16,
+ p: &Palette,
+) -> Option<Rect> {
+ let popup = centered_popup_rect(area, popup_w, popup_h)?;
+ render_panel_shell(frame, popup, p.accent, p.panel_bg)
+}
+
+pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
+ let line = Line::from(vec![
+ Span::styled(
+ title,
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD),
+ ),
+ 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,
+ }
+}
+
+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
+}
+
+pub(crate) struct ActionButtonSpec<'a> {
+ pub hint: Option<&'a str>,
+ pub label: &'a str,
+}
+
+pub(crate) fn action_button_row_rects(
+ area: Rect,
+ buttons: &[ActionButtonSpec<'_>],
+ gap: u16,
+ row_offset: u16,
+) -> Vec<Rect> {
+ let widths: Vec<u16> = buttons
+ .iter()
+ .map(|button| action_button_width(button.hint, button.label))
+ .collect();
+ centered_button_row(area, &widths, gap, row_offset)
+}
+
+pub(super) fn render_action_button(
+ frame: &mut Frame,
+ rect: Rect,
+ hint: Option<&str>,
+ label: &str,
+ style: Style,
+) {
+ frame.render_widget(
+ Paragraph::new(action_button_text(hint, label))
+ .style(style)
+ .alignment(Alignment::Center),
+ rect,
+ );
+}
+
+pub(super) fn centered_button_row(
+ inner: Rect,
+ widths: &[u16],
+ gap: u16,
+ row_offset: u16,
+) -> Vec<Rect> {
+ let total_w = widths
+ .iter()
+ .copied()
+ .sum::<u16>()
+ .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16));
+ let mut x = inner.x + inner.width.saturating_sub(total_w) / 2;
+ let y = inner.y + row_offset.min(inner.height.saturating_sub(1));
+ widths
+ .iter()
+ .map(|w| {
+ let rect = Rect::new(
+ x,
+ y,
+ (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))),
+ 1,
+ );
+ x = x.saturating_add(*w).saturating_add(gap);
+ rect
+ })
+ .collect()
+}