From b16465afbbf8e4e959076fceb7e84377f2ecc379 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 16 Jul 2026 03:19:52 +0300 Subject: [PATCH] feat: add configurable sidebar token styles --- docs/next/CHANGELOG.md | 2 +- .../src/content/docs/configuration.mdx | 16 +- .../src/content/docs/ja/configuration.mdx | 16 +- .../src/content/docs/zh-cn/configuration.mdx | 16 +- .../website/src/data/config-reference.json | 4 +- src/app/terminal_titles.rs | 2 +- src/config.rs | 4 +- src/config/sidebar.rs | 290 ++++++++++++++++-- src/main.rs | 3 + src/ui/sidebar.rs | 267 ++++++++++++++-- src/ui/sidebar/tokens.rs | 172 +++++++---- 11 files changed, 662 insertions(+), 130 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index dec6d8b47..0c734471a 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -13,7 +13,7 @@ ### Added - Added session-modal popup floating terminal panes for `type = "popup"` custom command keybindings and plugin panes, with optional cell or percentage sizing and no changes to the tiled tab layout. (#1125) - Added `ui.copy_on_select` to disable automatic clipboard copying after mouse selection while keeping the selection visible. -- Added configurable row layouts for expanded Space and Agent sidebar entries, including built-in display tokens, per-agent overrides, custom metadata tokens, and pane/workspace metadata reporting through the CLI and socket API. +- Added configurable row layouts and per-occurrence foreground, bold, and dim styles for expanded Space and Agent sidebar entries, including built-in display tokens, per-agent overrides, custom metadata tokens, and pane/workspace metadata reporting through the CLI and socket API. - Added independent `row_gap` settings for expanded Space and Agent sidebar entries. - Copy mode now supports literal smart-case search with `/` and `?`, repeating with `n` and `N`, match highlighting, and tmux-style cross-line `w`/`b`/`e` word motions. (#1230) - Added Maki agent support. (#1301, #1302, thanks @tontinton) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 6743e3baa..a5591880e 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -299,6 +299,18 @@ Space rows accept these built-in tokens: Tokens render in their configured order. Herdr normally separates adjacent values with ` · ` and uses a single space after `state_icon`. Missing values and their separators disappear; a row disappears when none of its tokens have a value. Each layout may contain at most 16 rows, with at most 16 tokens in each row. +A token entry can also be an inline style table: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", { token = "workspace", bold = false }, "tab"], + [{ token = "$summary", fg = "#89b4fa", bold = true, dim = false }], +] +``` + +`fg` accepts strict `#RGB` or `#RRGGBB`. `bold` and `dim` accept booleans. Omitted fields preserve the token's contextual style; explicit `false` removes that modifier. Styling applies to one occurrence, so the same token may look different in another row or agent override. A foreground override replaces all semantic foregrounds inside that occurrence: for example, styled `git_status` ahead and behind counts use one color instead of their default green and red. Token styles never change separators or row backgrounds. + `row_gap` controls the blank terminal rows between entries, independently for the Agent and Space panels. It defaults to `0`, which packs entries together; set it to `1` to restore the previous spacing. It does not add spacing between the content lines declared in `rows`. Consecutive indented worktree children remain packed as one Space group. Override the complete Agent layout for a known agent under `rows_by_agent`: @@ -338,7 +350,9 @@ herdr pane report-metadata \ --token summary="reviewing authentication" ``` -Use `herdr workspace report-metadata` in the same way for custom Space tokens. Unreported custom tokens simply disappear. Metadata reporters provide values only; they cannot choose rows or styling. See [CLI reference: report metadata](/docs/cli-reference/#panes) for clearing, sequencing, and expiring values. +Use `herdr workspace report-metadata` in the same way for custom Space tokens. Unreported custom tokens simply disappear. + +Metadata reporters provide values only; styling stays in the local sidebar configuration. See [CLI reference: report metadata](/docs/cli-reference/#panes) for limits, clearing, sequencing, and expiry. Sidebar row settings affect only the expanded desktop sidebar. Collapsed and mobile views keep their compact layouts. diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index 4a875220a..0eba298d7 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -295,6 +295,18 @@ Space の行では、次の組み込みトークンを使えます: トークンは設定された順序で描画されます。Herdr は通常、隣接する値を ` · ` で区切り、`state_icon` の後には空白を 1 つ入れます。値がない場合、その値と区切りは表示されません。すべてのトークンに値がない行は表示されません。各レイアウトは最大 16 行、各行は最大 16 トークンです。 +トークンエントリにはインラインのスタイルテーブルも使えます: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", { token = "workspace", bold = false }, "tab"], + [{ token = "$summary", fg = "#89b4fa", bold = true, dim = false }], +] +``` + +`fg` は厳密な `#RGB` または `#RRGGBB`、`bold` と `dim` は真偽値です。省略したフィールドはコンテキストのスタイルを保持し、明示的な `false` はその修飾を解除します。スタイルは 1 つの出現箇所だけに適用されます。`fg` を指定した `git_status` では、既定の緑/赤ではなく ahead と behind の両方が同じ色になります。区切りと行の背景は変更されません。 + `row_gap` は、Agent パネルと Space パネルごとに、エントリ間の空白行数を指定します。デフォルトは `0` で、エントリを詰めて表示します。以前の間隔に戻すには `1` に設定します。`rows` で定義したコンテンツ行の間隔には影響しません。連続するインデントされた worktree の子は、1 つの Space グループとして詰めて表示されます。 既知のエージェントについて Agent の完全なレイアウトを上書きするには、`rows_by_agent` を使います: @@ -334,7 +346,9 @@ herdr pane report-metadata \ --token summary="reviewing authentication" ``` -カスタム Space トークンには、同じ方法で `herdr workspace report-metadata` を使います。報告されていないカスタムトークンは表示されません。メタデータの報告元が提供できるのは値だけで、行やスタイルは選べません。値のクリア、シーケンス、有効期限については、[CLI リファレンス: report metadata](/docs/cli-reference/#panes)を参照してください。 +カスタム Space トークンには、同じ方法で `herdr workspace report-metadata` を使います。報告されていないカスタムトークンは表示されません。 + +メタデータの報告元が提供できるのは値だけで、スタイルはローカルのサイドバー設定で管理します。上限、クリア、シーケンス、有効期限については、[CLI リファレンス: report metadata](/docs/cli-reference/#panes)を参照してください。 サイドバーの行設定は、展開されたデスクトップサイドバーにだけ適用されます。折りたたみ表示とモバイル表示はコンパクトなレイアウトを保ちます。 diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 055ca3ad7..bb04da076 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -295,6 +295,18 @@ Space 行支持以下内置 token: token 会按配置顺序渲染。Herdr 通常使用 ` · ` 分隔相邻值,并在 `state_icon` 后使用一个空格。缺失值及其分隔符会消失;当一行中的所有 token 都没有值时,该行会消失。每个布局最多可包含 16 行,每行最多可包含 16 个 token。 +也可以用内联样式表定义 token: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", { token = "workspace", bold = false }, "tab"], + [{ token = "$summary", fg = "#89b4fa", bold = true, dim = false }], +] +``` + +`fg` 只接受严格的 `#RGB` 或 `#RRGGBB`,`bold` 和 `dim` 接受布尔值。省略字段会保留上下文样式;显式 `false` 会移除对应修饰。样式只作用于当前出现位置。为 `git_status` 设置 `fg` 后,ahead 和 behind 会使用同一种颜色,而不是默认的绿色和红色。token 样式不会改变分隔符或行背景。 + `row_gap` 分别控制 Agent 和 Space 面板中条目之间的空白终端行数。默认值为 `0`,会紧密排列条目;设为 `1` 可恢复之前的间距。它不会在 `rows` 声明的内容行之间添加间距。连续缩进的 worktree 子项仍会作为一个 Space 组紧密排列。 在 `rows_by_agent` 下为已知智能体覆盖完整的 Agent 布局: @@ -334,7 +346,9 @@ herdr pane report-metadata \ --token summary="reviewing authentication" ``` -自定义 Space token 可用同样的方式通过 `herdr workspace report-metadata` 报告。未报告的自定义 token 会直接消失。元数据报告方只提供值,不能选择行或样式。有关清除、顺序和过期值的信息,请参阅 [CLI 参考:报告元数据](/docs/cli-reference/#panes)。 +自定义 Space token 可用同样的方式通过 `herdr workspace report-metadata` 报告。未报告的自定义 token 会直接消失。 + +元数据上报方只提供值;样式由本地侧边栏配置控制。有关限制、清除、顺序和过期值的信息,请参阅 [CLI 参考:报告元数据](/docs/cli-reference/#panes)。 侧边栏行设置仅影响展开的桌面侧边栏。折叠视图和移动视图仍使用紧凑布局。 diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index c54e6dfb0..4c4bbd00d 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -678,7 +678,7 @@ "key": "ui.sidebar.agents.rows", "type": "list of token rows", "default": "[[\"state_icon\", \"workspace\", \"tab\"], [\"agent\"]]", - "description": "Default expanded Agent sidebar layout. Supports built-in tokens and $name metadata tokens; at most 16 rows and 16 tokens per row." + "description": "Default expanded Agent sidebar layout. Entries may be token strings or inline { token, fg, bold, dim } style tables. Supports built-in and $name metadata tokens; at most 16 rows and 16 tokens per row." }, { "key": "ui.sidebar.agents.rows_by_agent", @@ -696,7 +696,7 @@ "key": "ui.sidebar.spaces.rows", "type": "list of token rows", "default": "[[\"state_icon\", \"workspace\"], [\"branch\", \"git_status\"]]", - "description": "Expanded Space sidebar layout. Supports built-in tokens and $name metadata tokens; at most 16 rows and 16 tokens per row." + "description": "Expanded Space sidebar layout. Entries may be token strings or inline { token, fg, bold, dim } style tables. Supports built-in and $name metadata tokens; at most 16 rows and 16 tokens per row." }, { "key": "ui.accent", diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 3c7d97110..a371d2dcb 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 b9a476e8b..f07f66cee 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 e583f5afb..f7201134f 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -35,6 +35,71 @@ fn validate_sidebar_rows(rows: &[Vec]) -> 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(&self, serializer: S) -> Result + 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(deserializer: D) -> Result + 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, + pub bold: Option, + pub dim: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum AgentSidebarToken { StateIcon, @@ -46,6 +111,10 @@ pub enum AgentSidebarToken { TerminalTitle, TerminalTitleStripped, Custom(String), + Styled { + token: Box, + style: SidebarTokenStyle, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,21 +125,76 @@ pub enum SpaceSidebarToken { Branch, GitStatus, Custom(String), + Styled { + token: Box, + style: SidebarTokenStyle, + }, } -fn parse_sidebar_token<'de, D, T>(deserializer: D, builtins: &[(&str, T)]) -> Result +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, + #[serde(default)] + bold: Option, + #[serde(default)] + dim: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RawSidebarToken { + Plain(String), + Styled(RawStyledSidebarToken), +} + +impl RawSidebarToken { + fn parts(self) -> (String, Option) { + 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(value: String, builtins: &[(&str, T)]) -> Result where - D: serde::Deserializer<'de>, T: Clone + From, { - 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( + name: String, + style: SidebarTokenStyle, + serializer: S, +) -> Result +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(&self, serializer: S) -> Result 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, + })) } } @@ -336,6 +513,65 @@ row_gap = 3 assert_eq!(config.ui.sidebar.spaces.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::(&input).is_err(), + "accepted {entry}" + ); + } + } + #[test] fn rejects_unknown_bare_and_malformed_custom_tokens() { for token in ["summary", "$", "$bad.name"] { diff --git a/src/main.rs b/src/main.rs index 0649da9e4..e1d9d87fc 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 ebe7d0a17..f89b6e6d3 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> { 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::>(); 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::>(); @@ -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::(), + "↑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 5c0036175..22e13e86f 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::>(); (!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::>(); (!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() + ))]] ); } }