aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 20:44:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 20:44:34 +0300
commit101de0ce453615c809d95175532bcb86f816163a (patch)
tree1722c86a0d553b72bff1de08289564ad39ce5fa4 /src
parentbf6b6d779a63d8952d8db0a7de5daa3454984620 (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)
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs36
-rw-r--r--src/app/state.rs304
-rw-r--r--src/config.rs76
-rw-r--r--src/main.rs16
-rw-r--r--src/ui.rs384
-rw-r--r--src/workspace.rs68
6 files changed, 789 insertions, 95 deletions
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())
diff --git a/src/ui.rs b/src/ui.rs
index 1f1b4ad0..0a4fcd62 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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()