diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 20:44:34 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 20:44:34 +0300 |
| commit | 101de0ce453615c809d95175532bcb86f816163a (patch) | |
| tree | 1722c86a0d553b72bff1de08289564ad39ce5fa4 | |
| parent | bf6b6d779a63d8952d8db0a7de5daa3454984620 (diff) | |
refactor: two-section sidebar with agent detail panel, theme system with 9 built-in themes
sidebar layout:
- top section: workspace list with accent bar, git branch, state dots
- bottom section: agent detail panel sorted by urgency with braille spinners
- active workspace always highlighted (subtle bg in terminal mode, brighter in nav)
- palette-driven colors throughout (no hardcoded colors in ui.rs)
theme system:
- 9 built-in themes: catppuccin (default), tokyo-night, dracula, nord, gruvbox, one-dark, solarized, kanagawa, rose-pine
- config: theme.name selects base, theme.custom overrides individual tokens
- 15 color tokens: accent, surface0/1, overlay0/1, text, subtext0, mauve, green, yellow, red, blue, teal, peach
- legacy ui.accent still works for backward compatibility
infrastructure:
- Palette struct on AppState, ready for future theme system extensions
- git_branch() reads .git/HEAD for branch detection
- PaneDetail struct with urgency sorting for agent panel
- spinner_tick counter for braille animation (~8fps on 60fps render loop)
| -rw-r--r-- | CONFIGURATION.md | 63 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | src/app/mod.rs | 36 | ||||
| -rw-r--r-- | src/app/state.rs | 304 | ||||
| -rw-r--r-- | src/config.rs | 76 | ||||
| -rw-r--r-- | src/main.rs | 16 | ||||
| -rw-r--r-- | src/ui.rs | 384 | ||||
| -rw-r--r-- | src/workspace.rs | 68 |
8 files changed, 863 insertions, 95 deletions
diff --git a/CONFIGURATION.md b/CONFIGURATION.md index dee645e2..b754cf36 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -76,6 +76,69 @@ toggle_sidebar = "b" | `resize_mode` | `r` | enter or leave resize mode | | `toggle_sidebar` | `b` | collapse or expand the sidebar | +## theme + +herdr ships with 9 built-in color themes. set one in config: + +```toml +[theme] +name = "tokyo-night" +``` + +### built-in themes + +| name | description | +|------|-------------| +| `catppuccin` | soft pastel mocha palette (default) | +| `tokyo-night` | blue-purple aesthetic | +| `dracula` | purple/pink/green classic | +| `nord` | frosty scandinavian blues | +| `gruvbox` | warm retro browns/oranges | +| `one-dark` | atom's beloved palette | +| `solarized` | ethan schoonover's classic | +| `kanagawa` | hokusai-inspired | +| `rose-pine` | muted, elegant | + +theme names are flexible: `tokyo-night`, `tokyonight`, and `tokyo_night` all work. + +### custom overrides + +override individual color tokens on top of any base theme: + +```toml +[theme] +name = "dracula" + +[theme.custom] +accent = "#f5c2e7" +red = "rgb(255, 85, 85)" +green = "#a6e3a1" +``` + +all tokens are optional — only set what you want to change. + +### available tokens + +| token | used for | +|-------|----------| +| `accent` | highlights, active borders, navigation UI | +| `surface0` | selected item background | +| `surface1` | hover/active backgrounds | +| `surface_dim` | active workspace background, separators | +| `overlay0` | muted text, secondary info | +| `overlay1` | slightly brighter secondary text | +| `text` | primary text | +| `subtext0` | workspace names, dimmed labels | +| `mauve` | git branch names, special labels | +| `green` | idle/done states | +| `yellow` | busy/running states | +| `red` | waiting/needs attention states | +| `blue` | unseen notifications | +| `teal` | done notification accents | +| `peach` | interrupted/warning states | + +tokens accept the same color formats as `accent`: hex (`#rrggbb`), named colors, or `rgb(r,g,b)`. + ## ui ```toml @@ -154,6 +154,17 @@ print the full default config with: herdr --default-config ``` +### themes + +herdr ships with 9 built-in themes: catppuccin (default), tokyo night, dracula, nord, gruvbox, one dark, solarized, kanagawa, and rosé pine. + +```toml +[theme] +name = "tokyo-night" +``` + +you can also override individual color tokens on top of any base theme. see [`CONFIGURATION.md`](./CONFIGURATION.md) for the full token reference. + for all keybindings, onboarding, notification, sound, UI options, and environment variables, see [`CONFIGURATION.md`](./CONFIGURATION.md). ## session persistence diff --git a/src/app/mod.rs b/src/app/mod.rs index ef424ed8..6562c5d6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,38 @@ pub struct App { toast_deadline: Option<Instant>, } +/// Resolve the palette from config: base theme + optional custom overrides. +fn resolve_palette(config: &crate::config::Config) -> state::Palette { + // Start with the named theme (default: catppuccin) + let base_name = config.theme.name.as_deref().unwrap_or("catppuccin"); + let mut palette = state::Palette::from_name(base_name).unwrap_or_else(|| { + tracing::warn!( + theme = base_name, + "unknown theme, falling back to catppuccin" + ); + state::Palette::catppuccin() + }); + + // Apply custom overrides if present + if let Some(custom) = &config.theme.custom { + palette = palette.with_overrides(custom); + } + + // Legacy: if ui.accent is set and no theme.custom.accent, use it for compat + if config.ui.accent != "cyan" + && config + .theme + .custom + .as_ref() + .and_then(|c| c.accent.as_ref()) + .is_none() + { + palette.accent = crate::config::parse_color(&config.ui.accent); + } + + palette +} + impl App { pub fn new( config: &Config, @@ -106,6 +138,8 @@ impl App { sound: config.ui.sound.clone(), toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), + spinner_tick: 0, + palette: resolve_palette(&config), }; // Background auto-update (skipped in --no-session / test mode) @@ -155,6 +189,8 @@ impl App { self.state.toast = None; } + self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); + terminal.draw(|frame| { crate::ui::compute_view(&mut self.state, frame.area()); crate::ui::render(&self.state, frame); diff --git a/src/app/state.rs b/src/app/state.rs index 343d935d..2a91dc38 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -7,6 +7,304 @@ use crate::layout::{PaneInfo, SplitBorder}; use crate::selection::Selection; use crate::workspace::Workspace; +// --------------------------------------------------------------------------- +// Theme palette — all UI colors in one place, ready for theming +// --------------------------------------------------------------------------- + +/// All colors used by the UI. Derived from a base accent color for now, +/// but structured so a full theme system can replace it later. +#[allow(dead_code)] // all fields defined for theming — some used later +pub struct Palette { + /// Primary accent (highlight, active borders). + pub accent: Color, + /// Subtle surface background for selected/focused items. + pub surface0: Color, + /// Slightly lighter surface for hover/active states. + pub surface1: Color, + /// Very dim surface for separators. + pub surface_dim: Color, + /// Muted text (secondary info, numbers). + pub overlay0: Color, + /// Slightly brighter overlay text. + pub overlay1: Color, + /// Main text color — soft white. + pub text: Color, + /// Subdued text (workspace numbers, dim labels). + pub subtext0: Color, + /// Branch name / special label color. + pub mauve: Color, + /// Done / idle states. + pub green: Color, + /// Busy / running states. + pub yellow: Color, + /// Needs attention / waiting states. + pub red: Color, + /// Unseen / done notification accent. + pub blue: Color, + /// Notification accent / unseen markers. + pub teal: Color, + /// Interrupted / warning states. + pub peach: Color, +} + +impl Palette { + /// Catppuccin Mocha — the default. + pub fn catppuccin() -> Self { + Self { + accent: Color::Rgb(137, 180, 250), // blue + surface0: Color::Rgb(49, 50, 68), + surface1: Color::Rgb(69, 71, 90), + surface_dim: Color::Rgb(30, 30, 46), + overlay0: Color::Rgb(108, 112, 134), + overlay1: Color::Rgb(127, 132, 156), + text: Color::Rgb(205, 214, 244), + subtext0: Color::Rgb(166, 173, 200), + mauve: Color::Rgb(203, 166, 247), + green: Color::Rgb(166, 227, 161), + yellow: Color::Rgb(249, 226, 175), + red: Color::Rgb(243, 139, 168), + blue: Color::Rgb(137, 180, 250), + teal: Color::Rgb(148, 226, 213), + peach: Color::Rgb(250, 179, 135), + } + } + + /// Tokyo Night — blue-purple aesthetic. + pub fn tokyo_night() -> Self { + Self { + accent: Color::Rgb(122, 162, 247), // blue + surface0: Color::Rgb(36, 40, 59), + surface1: Color::Rgb(65, 72, 104), + surface_dim: Color::Rgb(26, 27, 38), + overlay0: Color::Rgb(86, 95, 137), + overlay1: Color::Rgb(105, 113, 150), + text: Color::Rgb(192, 202, 245), + subtext0: Color::Rgb(169, 177, 214), + mauve: Color::Rgb(187, 154, 247), + green: Color::Rgb(158, 206, 106), + yellow: Color::Rgb(224, 175, 104), + red: Color::Rgb(247, 118, 142), + blue: Color::Rgb(122, 162, 247), + teal: Color::Rgb(125, 207, 255), + peach: Color::Rgb(255, 158, 100), + } + } + + /// Dracula — purple/pink/green. + pub fn dracula() -> Self { + Self { + accent: Color::Rgb(189, 147, 249), // purple + surface0: Color::Rgb(68, 71, 90), + surface1: Color::Rgb(98, 114, 164), + surface_dim: Color::Rgb(40, 42, 54), + overlay0: Color::Rgb(98, 114, 164), + overlay1: Color::Rgb(130, 140, 180), + text: Color::Rgb(248, 248, 242), + subtext0: Color::Rgb(210, 210, 220), + mauve: Color::Rgb(255, 121, 198), // pink + green: Color::Rgb(80, 250, 123), + yellow: Color::Rgb(241, 250, 140), + red: Color::Rgb(255, 85, 85), + blue: Color::Rgb(139, 233, 253), // cyan-ish + teal: Color::Rgb(139, 233, 253), + peach: Color::Rgb(255, 184, 108), + } + } + + /// Nord — frosty blue palette. + pub fn nord() -> Self { + Self { + accent: Color::Rgb(136, 192, 208), // frost + surface0: Color::Rgb(59, 66, 82), + surface1: Color::Rgb(67, 76, 94), + surface_dim: Color::Rgb(46, 52, 64), + overlay0: Color::Rgb(76, 86, 106), + overlay1: Color::Rgb(100, 110, 130), + text: Color::Rgb(236, 239, 244), + subtext0: Color::Rgb(216, 222, 233), + mauve: Color::Rgb(180, 142, 173), + green: Color::Rgb(163, 190, 140), + yellow: Color::Rgb(235, 203, 139), + red: Color::Rgb(191, 97, 106), + blue: Color::Rgb(129, 161, 193), + teal: Color::Rgb(143, 188, 187), + peach: Color::Rgb(208, 135, 112), + } + } + + /// Gruvbox Dark — warm retro palette. + pub fn gruvbox() -> Self { + Self { + accent: Color::Rgb(215, 153, 33), // yellow + surface0: Color::Rgb(60, 56, 54), + surface1: Color::Rgb(80, 73, 69), + surface_dim: Color::Rgb(40, 40, 40), + overlay0: Color::Rgb(146, 131, 116), + overlay1: Color::Rgb(168, 153, 132), + text: Color::Rgb(235, 219, 178), + subtext0: Color::Rgb(213, 196, 161), + mauve: Color::Rgb(211, 134, 155), + green: Color::Rgb(184, 187, 38), + yellow: Color::Rgb(250, 189, 47), + red: Color::Rgb(251, 73, 52), + blue: Color::Rgb(131, 165, 152), + teal: Color::Rgb(142, 192, 124), + peach: Color::Rgb(254, 128, 25), + } + } + + /// One Dark — Atom's classic dark theme. + pub fn one_dark() -> Self { + Self { + accent: Color::Rgb(97, 175, 239), // blue + surface0: Color::Rgb(44, 49, 58), + surface1: Color::Rgb(62, 68, 81), + surface_dim: Color::Rgb(40, 44, 52), + overlay0: Color::Rgb(92, 99, 112), + overlay1: Color::Rgb(115, 122, 135), + text: Color::Rgb(171, 178, 191), + subtext0: Color::Rgb(150, 156, 168), + mauve: Color::Rgb(198, 120, 221), + green: Color::Rgb(152, 195, 121), + yellow: Color::Rgb(229, 192, 123), + red: Color::Rgb(224, 108, 117), + blue: Color::Rgb(97, 175, 239), + teal: Color::Rgb(86, 182, 194), + peach: Color::Rgb(209, 154, 102), + } + } + + /// Solarized Dark — Ethan Schoonover's classic. + pub fn solarized() -> Self { + Self { + accent: Color::Rgb(38, 139, 210), // blue + surface0: Color::Rgb(7, 54, 66), + surface1: Color::Rgb(88, 110, 117), + surface_dim: Color::Rgb(0, 43, 54), + overlay0: Color::Rgb(88, 110, 117), + overlay1: Color::Rgb(101, 123, 131), + text: Color::Rgb(147, 161, 161), + subtext0: Color::Rgb(131, 148, 150), + mauve: Color::Rgb(211, 54, 130), + green: Color::Rgb(133, 153, 0), + yellow: Color::Rgb(181, 137, 0), + red: Color::Rgb(220, 50, 47), + blue: Color::Rgb(38, 139, 210), + teal: Color::Rgb(42, 161, 152), + peach: Color::Rgb(203, 75, 22), + } + } + + /// Kanagawa — inspired by Katsushika Hokusai. + pub fn kanagawa() -> Self { + Self { + accent: Color::Rgb(126, 156, 216), // blue + surface0: Color::Rgb(42, 42, 55), + surface1: Color::Rgb(54, 54, 70), + surface_dim: Color::Rgb(31, 31, 40), + overlay0: Color::Rgb(114, 113, 105), + overlay1: Color::Rgb(135, 134, 125), + text: Color::Rgb(220, 215, 186), + subtext0: Color::Rgb(200, 195, 170), + mauve: Color::Rgb(149, 127, 184), + green: Color::Rgb(118, 148, 106), + yellow: Color::Rgb(192, 163, 110), + red: Color::Rgb(195, 64, 67), + blue: Color::Rgb(126, 156, 216), + teal: Color::Rgb(127, 180, 202), + peach: Color::Rgb(255, 160, 102), + } + } + + /// Rosé Pine — muted, elegant. + pub fn rose_pine() -> Self { + Self { + accent: Color::Rgb(196, 167, 231), // iris + surface0: Color::Rgb(31, 29, 46), + surface1: Color::Rgb(38, 35, 58), + surface_dim: Color::Rgb(25, 23, 36), + overlay0: Color::Rgb(110, 106, 134), + overlay1: Color::Rgb(144, 140, 170), + text: Color::Rgb(224, 222, 244), + subtext0: Color::Rgb(200, 197, 220), + mauve: Color::Rgb(196, 167, 231), // iris + green: Color::Rgb(49, 116, 143), // pine + yellow: Color::Rgb(246, 193, 119), // gold + red: Color::Rgb(235, 111, 146), // love + blue: Color::Rgb(49, 116, 143), // pine + teal: Color::Rgb(156, 207, 216), // foam + peach: Color::Rgb(234, 154, 151), // rose + } + } + + /// Resolve a theme by name. Returns None for unknown names. + pub fn from_name(name: &str) -> Option<Self> { + match name.to_lowercase().replace([' ', '_'], "-").as_str() { + "catppuccin" | "catppuccin-mocha" => Some(Self::catppuccin()), + "tokyo-night" | "tokyonight" => Some(Self::tokyo_night()), + "dracula" => Some(Self::dracula()), + "nord" => Some(Self::nord()), + "gruvbox" | "gruvbox-dark" => Some(Self::gruvbox()), + "one-dark" | "onedark" => Some(Self::one_dark()), + "solarized" | "solarized-dark" => Some(Self::solarized()), + "kanagawa" => Some(Self::kanagawa()), + "rose-pine" | "rosepine" => Some(Self::rose_pine()), + _ => None, + } + } + + /// Apply custom color overrides on top of this palette. + pub fn with_overrides(mut self, custom: &crate::config::CustomThemeColors) -> Self { + use crate::config::parse_color; + if let Some(c) = &custom.accent { + self.accent = parse_color(c); + } + if let Some(c) = &custom.surface0 { + self.surface0 = parse_color(c); + } + if let Some(c) = &custom.surface1 { + self.surface1 = parse_color(c); + } + if let Some(c) = &custom.surface_dim { + self.surface_dim = parse_color(c); + } + if let Some(c) = &custom.overlay0 { + self.overlay0 = parse_color(c); + } + if let Some(c) = &custom.overlay1 { + self.overlay1 = parse_color(c); + } + if let Some(c) = &custom.text { + self.text = parse_color(c); + } + if let Some(c) = &custom.subtext0 { + self.subtext0 = parse_color(c); + } + if let Some(c) = &custom.mauve { + self.mauve = parse_color(c); + } + if let Some(c) = &custom.green { + self.green = parse_color(c); + } + if let Some(c) = &custom.yellow { + self.yellow = parse_color(c); + } + if let Some(c) = &custom.red { + self.red = parse_color(c); + } + if let Some(c) = &custom.blue { + self.blue = parse_color(c); + } + if let Some(c) = &custom.teal { + self.teal = parse_color(c); + } + if let Some(c) = &custom.peach { + self.peach = parse_color(c); + } + self + } +} + /// Computed view geometry — derived from AppState + terminal size. /// Updated before each render, consumed by render and mouse handling. pub struct ViewState { @@ -90,6 +388,10 @@ pub struct AppState { pub sound: SoundConfig, pub toast_config: ToastConfig, pub keybinds: Keybinds, + /// Frame counter for spinner animations (wraps around). + pub spinner_tick: u32, + /// UI color palette — all sidebar/UI colors centralized for theming. + pub palette: Palette, } impl AppState { @@ -188,6 +490,8 @@ impl AppState { toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()), toggle_sidebar_label: "b".into(), }, + spinner_tick: 0, + palette: Palette::catppuccin(), } } } diff --git a/src/config.rs b/src/config.rs index 88092955..fc26b3a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,11 +16,52 @@ pub struct ToastConfig { #[serde(default)] pub struct Config { pub onboarding: Option<bool>, + pub theme: ThemeConfig, pub keys: KeysConfig, pub ui: UiConfig, pub advanced: AdvancedConfig, } +/// Theme configuration: pick a built-in or override individual tokens. +/// +/// ```toml +/// [theme] +/// name = "tokyo-night" # built-in: catppuccin, tokyo-night, dracula, nord, etc. +/// +/// [theme.custom] # override individual tokens on top of the base +/// accent = "#f5c2e7" +/// red = "#ff6188" +/// ``` +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ThemeConfig { + /// Built-in theme name. Default: "catppuccin". + pub name: Option<String>, + /// Custom overrides — applied on top of the selected base theme. + pub custom: Option<CustomThemeColors>, +} + +/// Per-token color overrides. All fields optional — only set what you want to change. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct CustomThemeColors { + pub accent: Option<String>, + pub surface0: Option<String>, + pub surface1: Option<String>, + pub surface_dim: Option<String>, + pub overlay0: Option<String>, + pub overlay1: Option<String>, + pub text: Option<String>, + pub subtext0: Option<String>, + pub mauve: Option<String>, + pub green: Option<String>, + pub yellow: Option<String>, + pub red: Option<String>, + pub blue: Option<String>, + pub teal: Option<String>, + pub peach: Option<String>, +} + #[derive(Debug)] pub struct LoadedConfig { pub config: Config, @@ -939,4 +980,39 @@ allow_nested = true let config: Config = toml::from_str(toml).unwrap(); assert!(config.advanced.allow_nested); } + + #[test] + fn theme_name_parses() { + let toml = r#" +[theme] +name = "dracula" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.theme.name.as_deref(), Some("dracula")); + } + + #[test] + fn theme_custom_overrides_parse() { + let toml = r##" +[theme] +name = "nord" + +[theme.custom] +accent = "#ff79c6" +red = "rgb(255, 85, 85)" +"##; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.theme.name.as_deref(), Some("nord")); + let custom = config.theme.custom.as_ref().unwrap(); + assert_eq!(custom.accent.as_deref(), Some("#ff79c6")); + assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)")); + assert!(custom.green.is_none()); + } + + #[test] + fn theme_defaults_when_missing() { + let config: Config = toml::from_str("").unwrap(); + assert!(config.theme.name.is_none()); + assert!(config.theme.custom.is_none()); + } } diff --git a/src/main.rs b/src/main.rs index 2341e09b..9d5d1b16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,13 +73,25 @@ fn init_logging() { .init(); } -const DEFAULT_CONFIG: &str = r#"# herdr configuration +const DEFAULT_CONFIG: &str = r##"# herdr configuration # Place this file at ~/.config/herdr/config.toml # Show first-run notification setup on startup. # Missing also shows onboarding; set false after you've chosen. # onboarding = true +[theme] +# Built-in themes: catppuccin, tokyo-night, dracula, nord, gruvbox, +# one-dark, solarized, kanagawa, rose-pine +# name = "catppuccin" + +# Override individual color tokens on top of the base theme. +# Accepts: hex (#rrggbb), named colors, or rgb(r,g,b) +# [theme.custom] +# accent = "#f5c2e7" +# red = "#ff6188" +# green = "#a6e3a1" + [keys] # Prefix key to enter navigate mode (default: "ctrl+b") # Examples: "ctrl+b", "f12", "esc", "-" @@ -126,7 +138,7 @@ const DEFAULT_CONFIG: &str = r#"# herdr configuration [advanced] # Allow launching herdr from inside a herdr-managed pane. # allow_nested = false -"#; +"##; fn should_block_nested(config: &config::Config) -> bool { should_block_nested_for_env(config, std::env::var(HERDR_ENV_VAR).ok().as_deref()) @@ -13,9 +13,20 @@ use crate::detect::AgentState; use crate::layout::PaneInfo; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator -const MIN_SIDEBAR_WIDTH: u16 = 16; +const MIN_SIDEBAR_WIDTH: u16 = 18; const MAX_SIDEBAR_WIDTH: u16 = 36; +// Braille spinner frames — smooth rotation +const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame. +/// We want ~8 updates/sec so divide by 8. +fn spinner_frame(tick: u32) -> &'static str { + SPINNERS[(tick as usize / 8) % SPINNERS.len()] +} + +use crate::app::state::Palette; + /// Compute view geometry and reconcile pane sizes. /// Called before render to separate mutation from drawing. pub fn compute_view(app: &mut AppState, area: Rect) { @@ -150,13 +161,15 @@ fn compute_sidebar_width(app: &AppState) -> u16 { .map(|(i, ws)| { let name_len = ws.display_name().len(); let number_len = (i + 1).to_string().len(); - let pane_count = if ws.layout.pane_count() > 1 { + let pane_dots = if ws.layout.pane_count() > 1 { ws.layout.pane_count() } else { - 0 + 1 }; - let line1 = 5 + name_len + number_len + pane_count; // marker + number + name + spaces + pane dots - let line2 = ws.agent_summary().map(|s| s.len() + 2).unwrap_or(0); + // marker + number + space + name + spaces + dots + let line1 = 3 + number_len + name_len + 2 + pane_dots; + // branch line: " branch" + let line2 = ws.branch().map(|b| 3 + b.len()).unwrap_or(0); line1.max(line2) }) .max() @@ -175,10 +188,11 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { | Mode::ContextMenu ); + let p = &app.palette; let sep_style = if is_navigating { - Style::default().fg(app.accent) + Style::default().fg(p.accent) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(p.surface_dim) }; let sep_x = area.x + area.width.saturating_sub(1); let buf = frame.buffer_mut(); @@ -196,17 +210,17 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { break; } let (agg_state, agg_seen) = ws.aggregate_state(); - let (icon, icon_style) = state_icon_style(agg_state, agg_seen); + let (icon, icon_style) = state_dot(agg_state, agg_seen, p); let is_selected = i == app.selected && is_navigating; let row_style = if is_selected { - Style::default().bg(app.accent).fg(Color::Black) + Style::default().bg(p.surface0) } else { Style::default() }; let num_style = if is_selected { - row_style + Style::default().fg(p.overlay1).bg(p.surface0) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(p.overlay0) }; if is_selected { @@ -220,14 +234,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { Paragraph::new(Line::from(vec![ Span::styled(format!("{}", i + 1), num_style), Span::styled(" ", row_style), - Span::styled( - icon, - if is_selected { - icon_style.bg(app.accent) - } else { - icon_style - }, - ), + Span::styled(icon, icon_style), ])), Rect::new(area.x, y, content_w, 1), ); @@ -237,6 +244,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { } fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { + let p = &app.palette; let is_navigating = matches!( app.mode, Mode::Navigate @@ -245,13 +253,13 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { | Mode::ConfirmClose | Mode::ContextMenu ); - let highlight = Style::default().bg(app.accent).fg(Color::Black); let sep_style = if is_navigating { - Style::default().fg(app.accent) + Style::default().fg(p.accent) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(p.surface_dim) }; + // Right border let sep_x = area.x + area.width.saturating_sub(1); let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { @@ -260,108 +268,263 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { } let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - let bottom_y = content.y + content.height.saturating_sub(1); - let new_row_y = bottom_y.saturating_sub(1); - let mut row_y = content.y; + + // Determine which workspace to show in the detail panel + let detail_ws_idx = if is_navigating { + Some(app.selected) + } else { + app.active + }; + + // Split sidebar in half: workspaces on top, agents on bottom + let total_h = content.height as usize; + let ws_h = (total_h + 1) / 2; // top half (ceiling) + let detail_h = total_h.saturating_sub(ws_h); + + let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); + let detail_area = Rect::new( + content.x, + content.y + ws_h as u16, + content.width, + detail_h as u16, + ); + + // --- Top section: Workspaces --- + render_workspace_list(app, frame, ws_area, is_navigating); + + // --- Bottom section: Agent detail --- + if let Some(ws_idx) = detail_ws_idx { + if let Some(ws) = app.workspaces.get(ws_idx) { + render_agent_detail(app, frame, detail_area, ws); + } + } + + render_sidebar_toggle(frame, area, false); +} + +/// Render the workspace list in the top section of the sidebar. +fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) { + let p = &app.palette; + + // Reserve last row for "new" button + let list_bottom = area.y + area.height.saturating_sub(1); + let mut row_y = area.y; for (i, ws) in app.workspaces.iter().enumerate() { - if row_y + 1 >= new_row_y { + if row_y + 1 >= list_bottom { break; } let selected = i == app.selected && is_navigating; - let marker = if Some(i) == app.active { "▸" } else { " " }; + let is_active = Some(i) == app.active; + let highlighted = selected || is_active; // active always gets a bg let (agg_state, agg_seen) = ws.aggregate_state(); - let (icon, icon_style) = state_icon_style(agg_state, agg_seen); - let text_style = if selected { - highlight + + // Determine row height for background fill + let has_second_line = + ws.branch().is_some() || (app.mode == Mode::RenameSession && i == app.selected); + let row_height: u16 = if has_second_line { 2 } else { 1 }; + + // Background fill: selected gets brighter surface, active gets subtle surface + if highlighted { + let bg = if selected { p.surface0 } 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 area.x..area.x + area.width { + buf[(x, y)].set_style(Style::default().bg(bg)); + } + } + } + + // Accent bar on the left edge for active workspace + if is_active { + let buf = frame.buffer_mut(); + for y in row_y..row_y + row_height { + if y >= list_bottom { + break; + } + buf[(area.x, y)].set_symbol("▌"); + buf[(area.x, y)].set_style(Style::default().fg(p.accent)); + } + } + + // Styles — active workspace is always visible, selected (nav mode) is brightest + let name_style = if selected { + Style::default().fg(p.text).add_modifier(Modifier::BOLD) + } else if is_active { + Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else { - Style::default() + Style::default().fg(p.subtext0) }; - let dim_style = if selected { - highlight + let num_style = if selected || is_active { + Style::default().fg(p.overlay1) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(p.overlay0) }; + + // Line 1: marker-space + number + name + state dots + let marker = if is_active { " " } else { " " }; // accent bar handles active indicator let mut line1 = vec![ - Span::styled(marker, text_style), - Span::styled(format!("{} ", i + 1), dim_style), - Span::styled(ws.display_name(), text_style.add_modifier(Modifier::BOLD)), - Span::styled(" ", dim_style), + Span::styled(marker, Style::default()), + Span::styled(format!("{} ", i + 1), num_style), + Span::styled(ws.display_name(), name_style), + Span::styled(" ", Style::default()), ]; + // State dots (one per pane, or single aggregate) if ws.layout.pane_count() == 1 { - line1.push(Span::styled( - icon, - if selected { - icon_style.bg(app.accent) - } else { - icon_style - }, - )); + let (icon, icon_style) = state_dot(agg_state, agg_seen, p); + line1.push(Span::styled(icon, icon_style)); } else { for (pane_state, pane_seen) in ws.pane_states() { - let (pane_icon, pane_style) = state_icon_style(pane_state, pane_seen); - line1.push(Span::styled( - pane_icon, - if selected { - pane_style.bg(app.accent) - } else { - pane_style - }, - )); + let (icon, icon_style) = state_dot(pane_state, pane_seen, p); + line1.push(Span::styled(icon, icon_style)); } } - if selected { - let buf = frame.buffer_mut(); - for y in row_y..=row_y + 1 { - for x in content.x..content.x + content.width { - buf[(x, y)].set_style(highlight); - } + frame.render_widget( + Paragraph::new(Line::from(line1)), + Rect::new(area.x, row_y, area.width, 1), + ); + row_y += 1; + + // Line 2: branch or rename input + if row_y < list_bottom { + if app.mode == Mode::RenameSession && i == app.selected { + let text = format!(" {}\u{2588}", app.name_input); + frame.render_widget(Clear, Rect::new(area.x, row_y, area.width, 1)); + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(p.yellow)), + Rect::new(area.x, row_y, area.width, 1), + ); + row_y += 1; + } else if let Some(branch) = ws.branch() { + let max_branch_len = (area.width as usize).saturating_sub(5); + 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 line2 = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(branch_display, Style::default().fg(branch_color)), + ]); + frame.render_widget( + Paragraph::new(line2), + Rect::new(area.x, row_y, area.width, 1), + ); + row_y += 1; } } + // Spacing between workspace cards + row_y += 1; + } + + // "new" at the bottom of workspace section + if list_bottom > area.y { frame.render_widget( - Paragraph::new(Line::from(line1)), - Rect::new(content.x, row_y, content.width, 1), + Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))), + Rect::new(area.x, list_bottom, area.width, 1), ); + } +} - if app.mode == Mode::RenameSession && i == app.selected { - let text = format!(" {}\u{2588}", app.name_input); - frame.render_widget(Clear, Rect::new(content.x, row_y + 1, content.width, 1)); - frame.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::Yellow)), - Rect::new(content.x, row_y + 1, content.width, 1), - ); - } else if let Some(summary) = ws.agent_summary() { - let line2 = Line::from(vec![ - Span::styled(" ", dim_style), - Span::styled(summary, dim_style), - ]); - frame.render_widget( - Paragraph::new(line2), - Rect::new(content.x, row_y + 1, content.width, 1), - ); - } +/// Render the agent detail panel in the bottom section of the sidebar. +fn render_agent_detail( + app: &AppState, + frame: &mut Frame, + area: Rect, + ws: &crate::workspace::Workspace, +) { + let p = &app.palette; - row_y += 2; + if area.height < 3 { + return; } + let mut row_y = area.y; + + // Horizontal separator + let sep_line = "─".repeat(area.width as usize); frame.render_widget( - Paragraph::new(Span::styled("new", Style::default().fg(Color::DarkGray))), - Rect::new(content.x, new_row_y, content.width, 1), + Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))), + Rect::new(area.x, row_y, area.width, 1), ); - render_sidebar_toggle(frame, area, false); + row_y += 1; + + // Section header + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + " agents", + Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), + )])), + Rect::new(area.x, row_y, area.width, 1), + ); + row_y += 1; + + // Blank line for breathing room + row_y += 1; + + // Per-pane agent entries, sorted by urgency + let details = ws.pane_details(); + for detail in &details { + if row_y >= area.y + area.height { + 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); + + // Agent name in soft white, state label right-aligned in its color + let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD); + + // Calculate padding to right-align state label + let used = 3 + detail.label.len() + 1; // " icon name " + let label_len = label.len(); + let avail = (area.width as usize).saturating_sub(used + label_len); + let padding = " ".repeat(avail); + + let line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(icon, icon_style), + Span::styled(" ", Style::default()), + Span::styled(&detail.label, name_style), + Span::styled(padding, Style::default()), + Span::styled( + label, + Style::default().fg(label_color).add_modifier(Modifier::DIM), + ), + ]); + + frame.render_widget( + Paragraph::new(line), + Rect::new(area.x, row_y, area.width, 1), + ); + row_y += 1; + } } fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) { + // Toggle button not needed when sidebar has content — skip for now + // to avoid conflicting with the agent detail panel at the bottom. + if !collapsed { + return; + } let bottom_y = area.y + area.height.saturating_sub(1); - let content_w = area.width.saturating_sub(1); // exclude separator + let content_w = area.width.saturating_sub(1); if content_w == 0 || area.height == 0 { return; } - let icon = if collapsed { "»" } else { "«" }; - // Center the icon in the content area + let icon = "»"; let x = area.x + content_w / 2; let toggle_area = Rect::new(x, bottom_y, 1, 1); frame.render_widget( @@ -1099,13 +1262,48 @@ fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str) { ); } -fn state_icon_style(state: AgentState, seen: bool) -> (&'static str, Style) { +/// Compact dot icon for workspace-level aggregate state (top section). +fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) { + match (state, seen) { + (AgentState::Waiting, _) => ("●", Style::default().fg(p.red)), + (AgentState::Busy, _) => ("●", Style::default().fg(p.yellow)), + (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), + (AgentState::Idle, true) => ("○", Style::default().fg(p.green)), + (AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)), + } +} + +/// Rich icon for per-pane agent detail (bottom section). +/// Uses animated spinner for busy state. +fn agent_icon(state: AgentState, seen: bool, tick: u32, p: &Palette) -> (&'static str, Style) { + match (state, seen) { + (AgentState::Waiting, _) => ("◉", Style::default().fg(p.red)), + (AgentState::Busy, _) => (spinner_frame(tick), Style::default().fg(p.yellow)), + (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), + (AgentState::Idle, true) => ("✓", Style::default().fg(p.green)), + (AgentState::Unknown, _) => ("○", Style::default().fg(p.overlay0)), + } +} + +/// State label for the agent detail panel. +fn state_label(state: AgentState, seen: bool) -> &'static str { + match (state, seen) { + (AgentState::Waiting, _) => "waiting", + (AgentState::Busy, _) => "running", + (AgentState::Idle, false) => "done", + (AgentState::Idle, true) => "idle", + (AgentState::Unknown, _) => "idle", + } +} + +/// Color for the state label text. +fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color { match (state, seen) { - (AgentState::Waiting, _) => ("●", Style::default().fg(Color::Red)), - (AgentState::Busy, _) => ("●", Style::default().fg(Color::Yellow)), - (AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done - (AgentState::Idle, true) => ("○", Style::default().fg(Color::Green)), - (AgentState::Unknown, _) => ("·", Style::default().fg(Color::DarkGray)), + (AgentState::Waiting, _) => p.red, + (AgentState::Busy, _) => p.yellow, + (AgentState::Idle, false) => p.teal, + (AgentState::Idle, true) => p.green, + (AgentState::Unknown, _) => p.overlay0, } } diff --git a/src/workspace.rs b/src/workspace.rs index ff8e302f..51fd0250 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -157,6 +157,7 @@ impl Workspace { self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd()) } + /// Agent name summary (e.g. "claude, amp"). Used by collapsed sidebar. pub fn agent_summary(&self) -> Option<String> { let mut names = Vec::new(); @@ -206,6 +207,61 @@ impl Workspace { }) .collect() } + + /// Per-pane detail for the agent detail panel, ordered by urgency. + pub fn pane_details(&self) -> Vec<PaneDetail> { + let mut details: Vec<PaneDetail> = self + .layout + .pane_ids() + .iter() + .map(|id| { + let pane = self.panes.get(id); + let agent = pane.and_then(|p| p.detected_agent); + let state = pane.map(|p| p.state).unwrap_or(AgentState::Unknown); + let seen = pane.map(|p| p.seen).unwrap_or(true); + let label = agent + .map(|a| agent_name(a).to_string()) + .unwrap_or_else(|| "shell".to_string()); + PaneDetail { + label, + agent, + state, + seen, + } + }) + .collect(); + // Sort by urgency: waiting > busy > idle(unseen) > idle > unknown + details.sort_by(|a, b| b.urgency().cmp(&a.urgency())); + details + } + + /// Get the git branch for this workspace's root cwd. + pub fn branch(&self) -> Option<String> { + self.root_cwd().and_then(|cwd| git_branch(&cwd)) + } +} + +/// Detail info for a single pane, used by the agent detail panel. +pub struct PaneDetail { + pub label: String, + /// The detected agent, if any. Will be used for context extraction. + #[allow(dead_code)] // used later for triage line extraction + pub agent: Option<Agent>, + pub state: AgentState, + pub seen: bool, +} + +impl PaneDetail { + /// Urgency score for sort ordering (higher = more urgent, shown first). + fn urgency(&self) -> u8 { + match (self.state, self.seen) { + (AgentState::Waiting, _) => 4, + (AgentState::Busy, _) => 3, + (AgentState::Idle, false) => 2, // unseen/done + (AgentState::Idle, true) => 1, + (AgentState::Unknown, _) => 0, + } + } } fn agent_name(agent: Agent) -> &'static str { @@ -245,6 +301,18 @@ fn derive_label_from_cwd(cwd: &Path) -> String { .unwrap_or_else(|| cwd.display().to_string()) } +/// Read the current git branch name from .git/HEAD. +/// Returns None if not in a git repo or HEAD is detached. +pub fn git_branch(cwd: &Path) -> Option<String> { + let repo_root = git_repo_root(cwd)?; + let head_path = repo_root.join(".git").join("HEAD"); + let content = std::fs::read_to_string(head_path).ok()?; + let trimmed = content.trim(); + trimmed + .strip_prefix("ref: refs/heads/") + .map(|s| s.to_string()) +} + fn git_repo_root(start: &Path) -> Option<PathBuf> { let mut current = if start.is_dir() { start.to_path_buf() |
