aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/terminal_titles.rs2
-rw-r--r--src/config.rs4
-rw-r--r--src/config/sidebar.rs290
-rw-r--r--src/main.rs3
-rw-r--r--src/ui/sidebar.rs267
-rw-r--r--src/ui/sidebar/tokens.rs172
6 files changed, 614 insertions, 124 deletions
diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs
index 3c7d9711..a371d2dc 100644
--- a/src/app/terminal_titles.rs
+++ b/src/app/terminal_titles.rs
@@ -9,7 +9,7 @@ impl App {
.flatten()
.any(|token| {
matches!(
- token,
+ token.parts().0,
crate::config::AgentSidebarToken::TerminalTitle
| crate::config::AgentSidebarToken::TerminalTitleStripped
)
diff --git a/src/config.rs b/src/config.rs
index b9a476e8..f07f66ce 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -25,8 +25,8 @@ pub use self::{
ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
},
sidebar::{
- AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SpaceSidebarToken,
- SpacesSidebarConfig,
+ AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle,
+ SpaceSidebarToken, SpacesSidebarConfig,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs
index e583f5af..f7201134 100644
--- a/src/config/sidebar.rs
+++ b/src/config/sidebar.rs
@@ -35,6 +35,71 @@ fn validate_sidebar_rows<T>(rows: &[Vec<T>]) -> Result<(), String> {
Ok(())
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SidebarTokenColor {
+ r: u8,
+ g: u8,
+ b: u8,
+}
+
+impl SidebarTokenColor {
+ pub(crate) fn ratatui(self) -> ratatui::style::Color {
+ ratatui::style::Color::Rgb(self.r, self.g, self.b)
+ }
+}
+
+impl Serialize for SidebarTokenColor {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b))
+ }
+}
+
+impl<'de> Deserialize<'de> for SidebarTokenColor {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ let hex = value.strip_prefix('#').filter(|hex| {
+ hex.is_ascii()
+ && matches!(hex.len(), 3 | 6)
+ && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
+ });
+ let Some(hex) = hex else {
+ return Err(serde::de::Error::custom(
+ "sidebar token fg must be #RGB or #RRGGBB",
+ ));
+ };
+ let (r, g, b) = if hex.len() == 3 {
+ let mut digits = hex
+ .bytes()
+ .map(|byte| char::from(byte).to_digit(16).expect("validated hex digit") as u8 * 17);
+ (
+ digits.next().expect("three hex digits"),
+ digits.next().expect("three hex digits"),
+ digits.next().expect("three hex digits"),
+ )
+ } else {
+ (
+ u8::from_str_radix(&hex[0..2], 16).expect("validated hex digits"),
+ u8::from_str_radix(&hex[2..4], 16).expect("validated hex digits"),
+ u8::from_str_radix(&hex[4..6], 16).expect("validated hex digits"),
+ )
+ };
+ Ok(Self { r, g, b })
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct SidebarTokenStyle {
+ pub fg: Option<SidebarTokenColor>,
+ pub bold: Option<bool>,
+ pub dim: Option<bool>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentSidebarToken {
StateIcon,
@@ -46,6 +111,10 @@ pub enum AgentSidebarToken {
TerminalTitle,
TerminalTitleStripped,
Custom(String),
+ Styled {
+ token: Box<AgentSidebarToken>,
+ style: SidebarTokenStyle,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -56,21 +125,76 @@ pub enum SpaceSidebarToken {
Branch,
GitStatus,
Custom(String),
+ Styled {
+ token: Box<SpaceSidebarToken>,
+ style: SidebarTokenStyle,
+ },
}
-fn parse_sidebar_token<'de, D, T>(deserializer: D, builtins: &[(&str, T)]) -> Result<T, D::Error>
+impl AgentSidebarToken {
+ pub(crate) fn parts(&self) -> (&Self, SidebarTokenStyle) {
+ match self {
+ Self::Styled { token, style } => (token, *style),
+ token => (token, SidebarTokenStyle::default()),
+ }
+ }
+}
+
+impl SpaceSidebarToken {
+ pub(crate) fn parts(&self) -> (&Self, SidebarTokenStyle) {
+ match self {
+ Self::Styled { token, style } => (token, *style),
+ token => (token, SidebarTokenStyle::default()),
+ }
+ }
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RawStyledSidebarToken {
+ token: String,
+ #[serde(default)]
+ fg: Option<SidebarTokenColor>,
+ #[serde(default)]
+ bold: Option<bool>,
+ #[serde(default)]
+ dim: Option<bool>,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum RawSidebarToken {
+ Plain(String),
+ Styled(RawStyledSidebarToken),
+}
+
+impl RawSidebarToken {
+ fn parts(self) -> (String, Option<SidebarTokenStyle>) {
+ match self {
+ Self::Plain(token) => (token, None),
+ Self::Styled(token) => (
+ token.token,
+ Some(SidebarTokenStyle {
+ fg: token.fg,
+ bold: token.bold,
+ dim: token.dim,
+ }),
+ ),
+ }
+ }
+}
+
+fn parse_sidebar_token<T>(value: String, builtins: &[(&str, T)]) -> Result<T, String>
where
- D: serde::Deserializer<'de>,
T: Clone + From<String>,
{
- let value = String::deserialize(deserializer)?;
if let Some((_, token)) = builtins.iter().find(|(name, _)| *name == value) {
return Ok(token.clone());
}
let Some(name) = value.strip_prefix('$') else {
- return Err(serde::de::Error::custom(format!(
+ return Err(format!(
"unknown sidebar token `{value}`; custom tokens must start with `$`"
- )));
+ ));
};
if name.is_empty()
|| name.len() > 32
@@ -78,28 +202,71 @@ where
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
{
- return Err(serde::de::Error::custom(format!(
- "invalid custom sidebar token `{value}`"
- )));
+ return Err(format!("invalid custom sidebar token `{value}`"));
}
Ok(T::from(name.to_string()))
}
+fn serialize_styled_token<S>(
+ name: String,
+ style: SidebarTokenStyle,
+ serializer: S,
+) -> Result<S::Ok, S::Error>
+where
+ S: serde::Serializer,
+{
+ use serde::ser::SerializeMap;
+ let mut map = serializer.serialize_map(None)?;
+ map.serialize_entry("token", &name)?;
+ if let Some(fg) = style.fg {
+ map.serialize_entry("fg", &fg)?;
+ }
+ if let Some(bold) = style.bold {
+ map.serialize_entry("bold", &bold)?;
+ }
+ if let Some(dim) = style.dim {
+ map.serialize_entry("dim", &dim)?;
+ }
+ map.end()
+}
+
+fn agent_token_name(token: &AgentSidebarToken) -> String {
+ match token {
+ AgentSidebarToken::StateIcon => "state_icon".into(),
+ AgentSidebarToken::StateText => "state_text".into(),
+ AgentSidebarToken::Workspace => "workspace".into(),
+ AgentSidebarToken::Tab => "tab".into(),
+ AgentSidebarToken::Pane => "pane".into(),
+ AgentSidebarToken::Agent => "agent".into(),
+ AgentSidebarToken::TerminalTitle => "terminal_title".into(),
+ AgentSidebarToken::TerminalTitleStripped => "terminal_title_stripped".into(),
+ AgentSidebarToken::Custom(name) => format!("${name}"),
+ AgentSidebarToken::Styled { token, .. } => agent_token_name(token),
+ }
+}
+
+fn space_token_name(token: &SpaceSidebarToken) -> String {
+ match token {
+ SpaceSidebarToken::StateIcon => "state_icon".into(),
+ SpaceSidebarToken::StateText => "state_text".into(),
+ SpaceSidebarToken::Workspace => "workspace".into(),
+ SpaceSidebarToken::Branch => "branch".into(),
+ SpaceSidebarToken::GitStatus => "git_status".into(),
+ SpaceSidebarToken::Custom(name) => format!("${name}"),
+ SpaceSidebarToken::Styled { token, .. } => space_token_name(token),
+ }
+}
+
impl Serialize for AgentSidebarToken {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
- Self::StateIcon => serializer.serialize_str("state_icon"),
- Self::StateText => serializer.serialize_str("state_text"),
- Self::Workspace => serializer.serialize_str("workspace"),
- Self::Tab => serializer.serialize_str("tab"),
- Self::Pane => serializer.serialize_str("pane"),
- Self::Agent => serializer.serialize_str("agent"),
- Self::TerminalTitle => serializer.serialize_str("terminal_title"),
- Self::TerminalTitleStripped => serializer.serialize_str("terminal_title_stripped"),
- Self::Custom(name) => serializer.serialize_str(&format!("${name}")),
+ Self::Styled { token, style } => {
+ serialize_styled_token(agent_token_name(token), *style, serializer)
+ }
+ token => serializer.serialize_str(&agent_token_name(token)),
}
}
}
@@ -115,8 +282,9 @@ impl<'de> Deserialize<'de> for AgentSidebarToken {
where
D: serde::Deserializer<'de>,
{
- parse_sidebar_token(
- deserializer,
+ let (value, style) = RawSidebarToken::deserialize(deserializer)?.parts();
+ let token = parse_sidebar_token(
+ value,
&[
("state_icon", Self::StateIcon),
("state_text", Self::StateText),
@@ -128,6 +296,11 @@ impl<'de> Deserialize<'de> for AgentSidebarToken {
("terminal_title_stripped", Self::TerminalTitleStripped),
],
)
+ .map_err(serde::de::Error::custom)?;
+ Ok(style.map_or(token.clone(), |style| Self::Styled {
+ token: Box::new(token),
+ style,
+ }))
}
}
@@ -137,12 +310,10 @@ impl Serialize for SpaceSidebarToken {
S: serde::Serializer,
{
match self {
- Self::StateIcon => serializer.serialize_str("state_icon"),
- Self::StateText => serializer.serialize_str("state_text"),
- Self::Workspace => serializer.serialize_str("workspace"),
- Self::Branch => serializer.serialize_str("branch"),
- Self::GitStatus => serializer.serialize_str("git_status"),
- Self::Custom(name) => serializer.serialize_str(&format!("${name}")),
+ Self::Styled { token, style } => {
+ serialize_styled_token(space_token_name(token), *style, serializer)
+ }
+ token => serializer.serialize_str(&space_token_name(token)),
}
}
}
@@ -158,8 +329,9 @@ impl<'de> Deserialize<'de> for SpaceSidebarToken {
where
D: serde::Deserializer<'de>,
{
- parse_sidebar_token(
- deserializer,
+ let (value, style) = RawSidebarToken::deserialize(deserializer)?.parts();
+ let token = parse_sidebar_token(
+ value,
&[
("state_icon", Self::StateIcon),
("state_text", Self::StateText),
@@ -168,6 +340,11 @@ impl<'de> Deserialize<'de> for SpaceSidebarToken {
("git_status", Self::GitStatus),
],
)
+ .map_err(serde::de::Error::custom)?;
+ Ok(style.map_or(token.clone(), |style| Self::Styled {
+ token: Box::new(token),
+ style,
+ }))
}
}
@@ -337,6 +514,65 @@ row_gap = 3
}
#[test]
+ fn parses_occurrence_styles_without_changing_plain_tokens() {
+ let config: crate::config::Config = toml::from_str(
+ r##"
+[ui.sidebar.agents]
+rows = [[{ token = "workspace", fg = "#abc", bold = false }, "workspace"], [{ token = "$summary", dim = false }]]
+
+[ui.sidebar.agents.rows_by_agent]
+claude = [[{ token = "agent", fg = "#112233", bold = true, dim = false }]]
+
+[ui.sidebar.spaces]
+rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = true }]]
+"##,
+ )
+ .unwrap();
+
+ let (token, style) = config.ui.sidebar.agents.rows[0][0].parts();
+ assert_eq!(token, &AgentSidebarToken::Workspace);
+ assert_eq!(style.bold, Some(false));
+ assert_eq!(
+ style.fg.unwrap().ratatui(),
+ ratatui::style::Color::Rgb(0xaa, 0xbb, 0xcc)
+ );
+ assert_eq!(
+ config.ui.sidebar.agents.rows[0][1],
+ AgentSidebarToken::Workspace
+ );
+
+ let (token, style) = config.ui.sidebar.agents.rows_by_agent["claude"][0][0].parts();
+ assert_eq!(token, &AgentSidebarToken::Agent);
+ assert_eq!(style.bold, Some(true));
+ assert_eq!(style.dim, Some(false));
+
+ let (token, style) = config.ui.sidebar.spaces.rows[0][0].parts();
+ assert_eq!(token, &SpaceSidebarToken::GitStatus);
+ assert_eq!(
+ style.fg.unwrap().ratatui(),
+ ratatui::style::Color::Rgb(0xff, 0x00, 0xaa)
+ );
+ let (token, style) = config.ui.sidebar.spaces.rows[1][0].parts();
+ assert_eq!(token, &SpaceSidebarToken::Custom("jj".into()));
+ assert_eq!(style.bold, Some(true));
+ }
+
+ #[test]
+ fn rejects_invalid_occurrence_styles() {
+ for entry in [
+ r##"{ token = "workspace", fg = "red" }"##,
+ r##"{ token = "workspace", fg = "#abcd" }"##,
+ r##"{ token = "workspace", underline = true }"##,
+ ] {
+ let input = format!("[ui.sidebar.agents]\nrows = [[{entry}]]\n");
+ assert!(
+ toml::from_str::<crate::config::Config>(&input).is_err(),
+ "accepted {entry}"
+ );
+ }
+ }
+
+ #[test]
fn rejects_unknown_bare_and_malformed_custom_tokens() {
for token in ["summary", "$", "$bad.name"] {
let input = format!("[ui.sidebar.agents]\\nrows = [[\"{token}\"]]\\n");
diff --git a/src/main.rs b/src/main.rs
index 0649da9e..e1d9d87f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -313,6 +313,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent,
# terminal_title, and terminal_title_stripped.
# Custom values reported through pane metadata use a $name token.
+# A token occurrence may be styled with { token = "workspace", fg = "#89b4fa", bold = true, dim = false }.
+# Omitted style fields preserve the contextual default.
# [ui.sidebar.agents]
# Blank rows between agent entries. Set to 1 to restore the previous spacing.
# row_gap = 0
@@ -323,6 +325,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status.
# Custom values reported through workspace metadata use a $name token, for example $jj_status.
+# Inline token styles accept strict #RGB/#RRGGBB foregrounds plus bold and dim booleans.
# [ui.sidebar.spaces]
# Blank rows between space entries. Set to 1 to restore the previous spacing.
# row_gap = 0
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index ebe7d0a1..f89b6e6d 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -8,7 +8,7 @@ use ratatui::{
Frame,
};
-use self::tokens::{ResolvedToken, SpaceTokenContext};
+use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext};
use super::scrollbar::{render_scrollbar, should_show_scrollbar};
use super::status::{agent_icon, state_dot, state_label, state_label_color};
use super::text::{display_width, display_width_u16, truncate_end};
@@ -905,9 +905,9 @@ fn resolved_token_spans(
) -> Vec<Span<'static>> {
let fixed_widths = resolved
.iter()
- .map(|token| match token {
- ResolvedToken::StateIcon => display_width(state_icon.0),
- ResolvedToken::GitStatus { ahead, behind } => {
+ .map(|token| match &token.kind {
+ ResolvedTokenKind::StateIcon => display_width(state_icon.0),
+ ResolvedTokenKind::GitStatus { ahead, behind } => {
usize::from(*ahead > 0) * display_width(&format!("↑{ahead}"))
+ usize::from(*behind > 0) * display_width(&format!("↓{behind}"))
+ usize::from(*ahead > 0 && *behind > 0)
@@ -917,15 +917,15 @@ fn resolved_token_spans(
.collect::<Vec<_>>();
let flexible_widths = resolved
.iter()
- .map(|token| match token {
- ResolvedToken::StateText(text)
- | ResolvedToken::Workspace(text)
- | ResolvedToken::Tab(text)
- | ResolvedToken::Pane(text)
- | ResolvedToken::Agent(text)
- | ResolvedToken::TerminalTitle(text)
- | ResolvedToken::Branch(text)
- | ResolvedToken::Custom(text) => display_width(text),
+ .map(|token| match &token.kind {
+ ResolvedTokenKind::StateText(text)
+ | ResolvedTokenKind::Workspace(text)
+ | ResolvedTokenKind::Tab(text)
+ | ResolvedTokenKind::Pane(text)
+ | ResolvedTokenKind::Agent(text)
+ | ResolvedTokenKind::TerminalTitle(text)
+ | ResolvedTokenKind::Branch(text)
+ | ResolvedTokenKind::Custom(text) => display_width(text),
_ => 0,
})
.collect::<Vec<_>>();
@@ -1010,55 +1010,58 @@ fn resolved_token_spans(
Style::default().fg(p.overlay0).add_modifier(Modifier::DIM),
));
}
- match token {
- ResolvedToken::StateIcon => {
- spans.push(Span::styled(state_icon.0.to_string(), state_icon.1));
- }
- ResolvedToken::StateText(text) => {
+ match &token.kind {
+ ResolvedTokenKind::StateIcon => {
spans.push(Span::styled(
- truncate_end(text, budgets[index]),
- state_text_style,
+ state_icon.0.to_string(),
+ apply_token_style(state_icon.1, token.style),
));
}
- ResolvedToken::Workspace(text) => {
+ ResolvedTokenKind::StateText(text) => {
spans.push(Span::styled(
truncate_end(text, budgets[index]),
- workspace_style,
+ apply_token_style(state_text_style, token.style),
));
}
- ResolvedToken::Tab(text) | ResolvedToken::Pane(text) | ResolvedToken::Agent(text) => {
+ ResolvedTokenKind::Workspace(text) => {
spans.push(Span::styled(
truncate_end(text, budgets[index]),
- secondary_style,
+ apply_token_style(workspace_style, token.style),
));
}
- ResolvedToken::Branch(text) => {
+ ResolvedTokenKind::Tab(text)
+ | ResolvedTokenKind::Pane(text)
+ | ResolvedTokenKind::Agent(text)
+ | ResolvedTokenKind::Branch(text) => {
spans.push(Span::styled(
truncate_end(text, budgets[index]),
- secondary_style,
+ apply_token_style(secondary_style, token.style),
));
}
- ResolvedToken::GitStatus { ahead, behind } => {
+ ResolvedTokenKind::GitStatus { ahead, behind } => {
if *ahead > 0 {
spans.push(Span::styled(
format!("↑{ahead}"),
- Style::default().fg(p.green),
+ apply_token_style(Style::default().fg(p.green), token.style),
));
}
if *ahead > 0 && *behind > 0 {
- spans.push(Span::raw(" "));
+ spans.push(Span::styled(
+ " ",
+ apply_token_style(Style::default(), token.style),
+ ));
}
if *behind > 0 {
spans.push(Span::styled(
format!("↓{behind}"),
- Style::default().fg(p.red),
+ apply_token_style(Style::default().fg(p.red), token.style),
));
}
}
- ResolvedToken::TerminalTitle(text) | ResolvedToken::Custom(text) => {
+ ResolvedTokenKind::TerminalTitle(text) | ResolvedTokenKind::Custom(text) => {
spans.push(Span::styled(
truncate_end(text, budgets[index]),
- custom_style,
+ apply_token_style(custom_style, token.style),
));
}
}
@@ -1066,6 +1069,27 @@ fn resolved_token_spans(
spans
}
+fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) -> Style {
+ if let Some(fg) = patch.fg {
+ style = style.fg(fg.ratatui());
+ }
+ if let Some(bold) = patch.bold {
+ style = if bold {
+ style.add_modifier(Modifier::BOLD)
+ } else {
+ style.remove_modifier(Modifier::BOLD)
+ };
+ }
+ if let Some(dim) = patch.dim {
+ style = if dim {
+ style.add_modifier(Modifier::DIM)
+ } else {
+ style.remove_modifier(Modifier::DIM)
+ };
+ }
+ style
+}
+
fn render_workspace_list(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -1429,6 +1453,17 @@ mod tests {
.to_string()
}
+ fn find_symbol_x(buffer: &ratatui::buffer::Buffer, row: u16, width: u16, symbol: &str) -> u16 {
+ (0..width)
+ .find(|x| buffer[(*x, row)].symbol() == symbol)
+ .unwrap_or_else(|| {
+ panic!(
+ "missing symbol {symbol:?} in row {}",
+ row_text(buffer, row, width)
+ )
+ })
+ }
+
#[test]
fn default_agent_rows_remove_redundant_state_text() {
let mut app = crate::app::state::AppState::test_new();
@@ -1436,6 +1471,7 @@ mod tests {
let pane_id = workspace.tabs[0].root_pane;
app.workspaces = vec![workspace];
app.ensure_test_terminals();
+ app.active = Some(0);
let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
@@ -1458,6 +1494,169 @@ mod tests {
assert_eq!(second, " pi");
assert!(!first.contains("working"));
assert!(!second.contains("working"));
+
+ let workspace_x = find_symbol_x(buffer, body.y, body.width, "o");
+ let workspace_style = buffer[(workspace_x, body.y)].style();
+ assert_eq!(workspace_style.fg, Some(app.palette.text));
+ assert!(workspace_style.add_modifier.contains(Modifier::BOLD));
+ assert!(!workspace_style.add_modifier.contains(Modifier::DIM));
+ assert_eq!(workspace_style.bg, Some(app.palette.surface_dim));
+
+ let agent_x = find_symbol_x(buffer, body.y + 1, body.width, "p");
+ let agent_style = buffer[(agent_x, body.y + 1)].style();
+ assert_eq!(agent_style.fg, Some(app.palette.overlay0));
+ assert!(agent_style.add_modifier.contains(Modifier::DIM));
+ assert!(!agent_style.add_modifier.contains(Modifier::BOLD));
+ assert_eq!(agent_style.bg, Some(app.palette.surface_dim));
+ }
+
+ #[test]
+ fn occurrence_false_removes_default_workspace_bold_and_agent_dim() {
+ let config: crate::config::Config = toml::from_str(
+ r##"
+[ui.sidebar.agents]
+rows = [[{ token = "workspace", bold = false }, { token = "agent", dim = false }]]
+"##,
+ )
+ .unwrap();
+ let mut app = crate::app::state::AppState::test_new();
+ app.sidebar_agents = config.ui.sidebar.agents;
+ let workspace = Workspace::test_new("one");
+ let pane_id = workspace.tabs[0].root_pane;
+ app.workspaces = vec![workspace];
+ app.ensure_test_terminals();
+ app.active = Some(0);
+ let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi);
+
+ let area = Rect::new(0, 0, 26, 20);
+ let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
+ terminal
+ .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+ let body = agent_panel_body_rect(agent_area, false);
+ let buffer = terminal.backend().buffer();
+ let workspace = buffer[(find_symbol_x(buffer, body.y, body.width, "o"), body.y)].style();
+ let agent = buffer[(find_symbol_x(buffer, body.y, body.width, "p"), body.y)].style();
+
+ assert_eq!(workspace.fg, Some(app.palette.text));
+ assert!(!workspace.add_modifier.contains(Modifier::BOLD));
+ assert_eq!(agent.fg, Some(app.palette.overlay0));
+ assert!(!agent.add_modifier.contains(Modifier::DIM));
+ }
+
+ #[test]
+ fn default_space_workspace_style_tracks_active_state() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ app.active = Some(0);
+ app.mode = Mode::Terminal;
+ let area = Rect::new(0, 0, 26, 20);
+ app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
+ let first_row = app.view.workspace_card_areas[0].rect.y;
+ let second_row = app.view.workspace_card_areas[1].rect.y;
+ let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
+ terminal
+ .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+
+ let active = buffer[(find_symbol_x(buffer, first_row, 25, "o"), first_row)].style();
+ assert_eq!(active.fg, Some(app.palette.text));
+ assert!(active.add_modifier.contains(Modifier::BOLD));
+ assert!(!active.add_modifier.contains(Modifier::DIM));
+ assert_eq!(active.bg, Some(app.palette.surface_dim));
+
+ let inactive = buffer[(find_symbol_x(buffer, second_row, 25, "t"), second_row)].style();
+ assert_eq!(inactive.fg, Some(app.palette.subtext0));
+ assert!(!inactive
+ .add_modifier
+ .intersects(Modifier::BOLD | Modifier::DIM));
+ assert_eq!(inactive.bg, Some(ratatui::style::Color::Reset));
+ }
+
+ #[test]
+ fn space_occurrence_style_applies_without_styling_separator() {
+ let config: crate::config::Config = toml::from_str(
+ r##"
+[ui.sidebar.spaces]
+rows = [[{ token = "$hype", fg = "#abcdef", bold = true, dim = false }, "workspace"]]
+"##,
+ )
+ .unwrap();
+ let mut app = crate::app::state::AppState::test_new();
+ app.sidebar_spaces = config.ui.sidebar.spaces;
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.mode = Mode::Terminal;
+ app.workspaces[0].metadata_tokens.patch(
+ std::collections::HashMap::from([("hype".into(), Some("HI".into()))]),
+ None,
+ std::time::Instant::now(),
+ );
+
+ let area = Rect::new(0, 0, 26, 20);
+ app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
+ let row = app.view.workspace_card_areas[0].rect.y;
+ let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap();
+ terminal
+ .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+ let h = buffer[(find_symbol_x(buffer, row, 25, "H"), row)].style();
+ let i = buffer[(find_symbol_x(buffer, row, 25, "I"), row)].style();
+ let separator = buffer[(find_symbol_x(buffer, row, 25, "·"), row)].style();
+
+ for style in [h, i] {
+ assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(0xab, 0xcd, 0xef)));
+ assert!(style.add_modifier.contains(Modifier::BOLD));
+ assert!(!style.add_modifier.contains(Modifier::DIM));
+ assert_eq!(style.bg, Some(app.palette.surface_dim));
+ }
+ assert_eq!(separator.fg, Some(app.palette.overlay0));
+ assert!(separator.add_modifier.contains(Modifier::DIM));
+ assert!(!separator.add_modifier.contains(Modifier::BOLD));
+ assert_eq!(separator.bg, Some(app.palette.surface_dim));
+ }
+
+ #[test]
+ fn occurrence_foreground_flattens_composite_git_status_colors() {
+ let config: crate::config::Config = toml::from_str(
+ r##"[ui.sidebar.spaces]
+rows = [[{ token = "git_status", fg = "#123456" }]]
+"##,
+ )
+ .unwrap();
+ let spans = resolved_token_spans(
+ &[ResolvedToken {
+ kind: ResolvedTokenKind::GitStatus {
+ ahead: 2,
+ behind: 1,
+ },
+ style: config.ui.sidebar.spaces.rows[0][0].parts().1,
+ }],
+ ("", Style::default()),
+ Style::default(),
+ Style::default(),
+ Style::default(),
+ Style::default(),
+ &crate::app::state::AppState::test_new().palette,
+ 20,
+ );
+
+ assert_eq!(
+ spans
+ .iter()
+ .map(|span| span.content.as_ref())
+ .collect::<String>(),
+ "↑2 ↓1"
+ );
+ assert!(spans
+ .iter()
+ .all(|span| { span.style.fg == Some(ratatui::style::Color::Rgb(0x12, 0x34, 0x56)) }));
}
#[test]
@@ -1547,7 +1746,9 @@ mod tests {
assert!(rendered.contains('修') && rendered.contains('复'));
let spans = resolved_token_spans(
- &[ResolvedToken::TerminalTitle("修复🙂标题很长".into())],
+ &[ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle(
+ "修复🙂标题很长".into(),
+ ))],
("", Style::default()),
Style::default(),
Style::default(),
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
index 5c003617..22e13e86 100644
--- a/src/ui/sidebar/tokens.rs
+++ b/src/ui/sidebar/tokens.rs
@@ -1,11 +1,17 @@
+use super::AgentPanelEntry;
use crate::config::{
- AgentSidebarToken, AgentsSidebarConfig, SpaceSidebarToken, SpacesSidebarConfig,
+ AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken,
+ SpacesSidebarConfig,
};
-use super::AgentPanelEntry;
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct ResolvedToken {
+ pub kind: ResolvedTokenKind,
+ pub style: SidebarTokenStyle,
+}
#[derive(Debug, Clone, PartialEq, Eq)]
-pub(super) enum ResolvedToken {
+pub(super) enum ResolvedTokenKind {
StateIcon,
StateText(String),
Workspace(String),
@@ -18,6 +24,17 @@ pub(super) enum ResolvedToken {
Custom(String),
}
+impl ResolvedToken {
+ fn new(kind: ResolvedTokenKind, style: SidebarTokenStyle) -> Self {
+ Self { kind, style }
+ }
+
+ #[cfg(test)]
+ pub(super) fn unstyled(kind: ResolvedTokenKind) -> Self {
+ Self::new(kind, SidebarTokenStyle::default())
+ }
+}
+
pub(super) fn agent_rows(
config: &AgentsSidebarConfig,
entry: &AgentPanelEntry,
@@ -29,30 +46,41 @@ pub(super) fn agent_rows(
.filter_map(|row| {
let resolved = row
.iter()
- .filter_map(|token| match token {
- AgentSidebarToken::StateIcon => Some(ResolvedToken::StateIcon),
- AgentSidebarToken::StateText => {
- Some(ResolvedToken::StateText(state_text.to_string()))
- }
- AgentSidebarToken::Workspace => {
- Some(ResolvedToken::Workspace(entry.primary_label.clone()))
- }
- AgentSidebarToken::Tab => {
- entry.primary_tab_label.clone().map(ResolvedToken::Tab)
- }
- AgentSidebarToken::Pane => entry.pane_label.clone().map(ResolvedToken::Pane),
- AgentSidebarToken::Agent => entry.agent_label.clone().map(ResolvedToken::Agent),
- AgentSidebarToken::TerminalTitle => entry
- .terminal_title
- .clone()
- .map(ResolvedToken::TerminalTitle),
- AgentSidebarToken::TerminalTitleStripped => entry
- .terminal_title_stripped
- .clone()
- .map(ResolvedToken::TerminalTitle),
- AgentSidebarToken::Custom(name) => {
- entry.tokens.get(name).cloned().map(ResolvedToken::Custom)
- }
+ .filter_map(|configured| {
+ let (token, style) = configured.parts();
+ let kind = match token {
+ AgentSidebarToken::StateIcon => Some(ResolvedTokenKind::StateIcon),
+ AgentSidebarToken::StateText => {
+ Some(ResolvedTokenKind::StateText(state_text.to_string()))
+ }
+ AgentSidebarToken::Workspace => {
+ Some(ResolvedTokenKind::Workspace(entry.primary_label.clone()))
+ }
+ AgentSidebarToken::Tab => {
+ entry.primary_tab_label.clone().map(ResolvedTokenKind::Tab)
+ }
+ AgentSidebarToken::Pane => {
+ entry.pane_label.clone().map(ResolvedTokenKind::Pane)
+ }
+ AgentSidebarToken::Agent => {
+ entry.agent_label.clone().map(ResolvedTokenKind::Agent)
+ }
+ AgentSidebarToken::TerminalTitle => entry
+ .terminal_title
+ .clone()
+ .map(ResolvedTokenKind::TerminalTitle),
+ AgentSidebarToken::TerminalTitleStripped => entry
+ .terminal_title_stripped
+ .clone()
+ .map(ResolvedTokenKind::TerminalTitle),
+ AgentSidebarToken::Custom(name) => entry
+ .tokens
+ .get(name)
+ .cloned()
+ .map(ResolvedTokenKind::Custom),
+ AgentSidebarToken::Styled { .. } => None,
+ }?;
+ Some(ResolvedToken::new(kind, style))
})
.collect::<Vec<_>>();
(!resolved.is_empty()).then_some(resolved)
@@ -79,26 +107,33 @@ pub(super) fn space_rows(
.filter_map(|row| {
let resolved = row
.iter()
- .filter_map(|token| match token {
- SpaceSidebarToken::StateIcon => Some(ResolvedToken::StateIcon),
- SpaceSidebarToken::StateText => {
- Some(ResolvedToken::StateText(context.state_text.to_string()))
- }
- SpaceSidebarToken::Workspace => {
- Some(ResolvedToken::Workspace(context.workspace.to_string()))
- }
- SpaceSidebarToken::Branch if !context.suppress_git_details => context
- .branch
- .map(|branch| ResolvedToken::Branch(branch.to_string())),
- SpaceSidebarToken::Branch => None,
- SpaceSidebarToken::GitStatus if !context.suppress_git_details => context
- .ahead_behind
- .filter(|(ahead, behind)| *ahead > 0 || *behind > 0)
- .map(|(ahead, behind)| ResolvedToken::GitStatus { ahead, behind }),
- SpaceSidebarToken::GitStatus => None,
- SpaceSidebarToken::Custom(name) => {
- context.tokens.get(name).cloned().map(ResolvedToken::Custom)
- }
+ .filter_map(|configured| {
+ let (token, style) = configured.parts();
+ let kind = match token {
+ SpaceSidebarToken::StateIcon => Some(ResolvedTokenKind::StateIcon),
+ SpaceSidebarToken::StateText => {
+ Some(ResolvedTokenKind::StateText(context.state_text.to_string()))
+ }
+ SpaceSidebarToken::Workspace => {
+ Some(ResolvedTokenKind::Workspace(context.workspace.to_string()))
+ }
+ SpaceSidebarToken::Branch if !context.suppress_git_details => context
+ .branch
+ .map(|branch| ResolvedTokenKind::Branch(branch.to_string())),
+ SpaceSidebarToken::Branch => None,
+ SpaceSidebarToken::GitStatus if !context.suppress_git_details => context
+ .ahead_behind
+ .filter(|(ahead, behind)| *ahead > 0 || *behind > 0)
+ .map(|(ahead, behind)| ResolvedTokenKind::GitStatus { ahead, behind }),
+ SpaceSidebarToken::GitStatus => None,
+ SpaceSidebarToken::Custom(name) => context
+ .tokens
+ .get(name)
+ .cloned()
+ .map(ResolvedTokenKind::Custom),
+ SpaceSidebarToken::Styled { .. } => None,
+ }?;
+ Some(ResolvedToken::new(kind, style))
})
.collect::<Vec<_>>();
(!resolved.is_empty()).then_some(resolved)
@@ -107,8 +142,8 @@ pub(super) fn space_rows(
}
pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str {
- if matches!(previous, ResolvedToken::StateIcon)
- || matches!(current, ResolvedToken::GitStatus { .. })
+ if matches!(previous.kind, ResolvedTokenKind::StateIcon)
+ || matches!(current.kind, ResolvedTokenKind::GitStatus { .. })
{
" "
} else {
@@ -119,6 +154,7 @@ pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'
#[cfg(test)]
mod tests {
use super::*;
+ use crate::config::{AgentSidebarToken, SpaceSidebarToken};
use crate::detect::AgentState;
fn entry() -> AgentPanelEntry {
@@ -159,8 +195,16 @@ mod tests {
let rows = agent_rows(&config, &entry, "working");
assert_eq!(rows.len(), 2);
- assert_eq!(rows[0], vec![ResolvedToken::StateIcon]);
- assert_eq!(rows[1], vec![ResolvedToken::Agent("pi".into())]);
+ assert_eq!(
+ rows[0],
+ vec![ResolvedToken::unstyled(ResolvedTokenKind::StateIcon)]
+ );
+ assert_eq!(
+ rows[1],
+ vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent(
+ "pi".into()
+ ))]
+ );
}
#[test]
@@ -180,8 +224,8 @@ mod tests {
assert_eq!(
agent_rows(&config, &entry, "deep in the mines"),
vec![vec![
- ResolvedToken::StateText("deep in the mines".into()),
- ResolvedToken::Custom("reviewing auth".into()),
+ ResolvedToken::unstyled(ResolvedTokenKind::StateText("deep in the mines".into())),
+ ResolvedToken::unstyled(ResolvedTokenKind::Custom("reviewing auth".into())),
]]
);
}
@@ -206,9 +250,9 @@ mod tests {
assert_eq!(
agent_rows(&config, &entry, "working"),
vec![vec![
- ResolvedToken::TerminalTitle("⠋ raw title".into()),
- ResolvedToken::TerminalTitle("raw title".into()),
- ResolvedToken::Custom("custom title".into()),
+ ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("⠋ raw title".into())),
+ ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("raw title".into())),
+ ResolvedToken::unstyled(ResolvedTokenKind::Custom("custom title".into())),
]]
);
}
@@ -227,13 +271,17 @@ mod tests {
assert_eq!(
agent_rows(&config, &pi, "working"),
- vec![vec![ResolvedToken::Agent("renamed pi".into())]]
+ vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent(
+ "renamed pi".into()
+ ))]]
);
pi.agent = None;
assert_eq!(
agent_rows(&config, &pi, "working"),
- vec![vec![ResolvedToken::Workspace("repo".into())]]
+ vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace(
+ "repo".into()
+ ))]]
);
}
@@ -254,8 +302,8 @@ mod tests {
},
),
vec![vec![
- ResolvedToken::StateIcon,
- ResolvedToken::Workspace("feature".into()),
+ ResolvedToken::unstyled(ResolvedTokenKind::StateIcon),
+ ResolvedToken::unstyled(ResolvedTokenKind::Workspace("feature".into())),
]]
);
}
@@ -280,7 +328,9 @@ mod tests {
suppress_git_details: false,
},
),
- vec![vec![ResolvedToken::Custom("2 changes".into())]]
+ vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Custom(
+ "2 changes".into()
+ ))]]
);
}
}