aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/website/src/content/docs/config-reference.mdx21
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx2
-rw-r--r--docs/next/website/src/content/docs/ja/config-reference.mdx27
-rw-r--r--docs/next/website/src/content/docs/ja/configuration.mdx2
-rw-r--r--docs/next/website/src/content/docs/zh-cn/config-reference.mdx26
-rw-r--r--docs/next/website/src/content/docs/zh-cn/configuration.mdx2
-rw-r--r--docs/next/website/src/data/config-reference.json1079
-rw-r--r--justfile19
-rw-r--r--scripts/config_reference_check.py319
-rw-r--r--scripts/test_config_reference_check.py245
-rw-r--r--scripts/test_preview.py5
-rw-r--r--website/.gitignore1
-rw-r--r--website/astro.config.mjs19
-rw-r--r--website/scripts/prepare-docs.mjs15
-rw-r--r--website/src/components/ConfigReference.astro195
-rw-r--r--website/src/content/docs/config-reference.mdx21
-rw-r--r--website/src/content/docs/configuration.mdx2
-rw-r--r--website/src/content/docs/ja/config-reference.mdx27
-rw-r--r--website/src/content/docs/ja/configuration.mdx2
-rw-r--r--website/src/content/docs/zh-cn/config-reference.mdx26
-rw-r--r--website/src/content/docs/zh-cn/configuration.mdx2
-rw-r--r--website/src/data/config-reference.json1062
22 files changed, 3103 insertions, 16 deletions
diff --git a/docs/next/website/src/content/docs/config-reference.mdx b/docs/next/website/src/content/docs/config-reference.mdx
new file mode 100644
index 00000000..499ee390
--- /dev/null
+++ b/docs/next/website/src/content/docs/config-reference.mdx
@@ -0,0 +1,21 @@
+---
+title: Config reference
+description: Every canonical config.toml key, with types, defaults, and allowed values.
+---
+
+import ConfigReference from '../../components/ConfigReference.astro';
+
+Every canonical key Herdr reads from `config.toml`, flat and filterable. For guided setup
+and the reasoning behind these options, see [Configuration](/docs/configuration/).
+
+Print the full commented default config at any time:
+
+```bash
+herdr --default-config
+```
+
+Custom command bindings (`[[keys.command]]`) are user-defined tables and are not
+listed per key here; see
+[Custom command keybindings](/docs/configuration/#custom-command-keybindings).
+
+<ConfigReference />
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 0f9a7096..d6c1cc2e 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -5,6 +5,8 @@ description: Configure Herdr keybindings, themes, sidebar behavior, notification
Herdr works without a config file. Add one when you want custom keys, themes, sidebar settings, notifications, or advanced behavior.
+Looking for a specific key? The [Config reference](/docs/config-reference/) lists every key with its type, default, and allowed values.
+
## Config file
Herdr reads config from:
diff --git a/docs/next/website/src/content/docs/ja/config-reference.mdx b/docs/next/website/src/content/docs/ja/config-reference.mdx
new file mode 100644
index 00000000..7bbb60aa
--- /dev/null
+++ b/docs/next/website/src/content/docs/ja/config-reference.mdx
@@ -0,0 +1,27 @@
+---
+title: 設定リファレンス
+description: Herdr の正規の config.toml キーを、型・デフォルト値・許容値つきで一覧します。
+---
+
+import ConfigReference from '../../../components/ConfigReference.astro';
+
+Herdr が `config.toml` から読み取る正規キーを、フラットに一覧して絞り込めます。
+ガイド付きの設定手順や各オプションの背景は[設定](/ja/docs/configuration/)を参照してください。
+
+コメント付きのデフォルト設定全体はいつでも出力できます:
+
+```bash
+herdr --default-config
+```
+
+カスタムコマンドバインディング(`[[keys.command]]`)はユーザー定義のテーブルのため、
+ここではキーごとに掲載していません。
+[カスタムコマンドキーバインド](/ja/docs/configuration/#カスタムコマンドキーバインド)を参照してください。
+
+各キーの説明は現時点では英語です。
+
+<ConfigReference
+ placeholder="キーを絞り込み(例: sidebar, prefix, sound…)"
+ noMatches="一致するキーはありません。"
+ keyCountLabel="キー"
+/>
diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx
index a28ad650..b91d5bd6 100644
--- a/docs/next/website/src/content/docs/ja/configuration.mdx
+++ b/docs/next/website/src/content/docs/ja/configuration.mdx
@@ -5,6 +5,8 @@ description: Herdr のキーバインド、テーマ、サイドバーの挙動ã
Herdr は設定ファイルなしで動作します。カスタムキー、テーマ、サイドバー設定、通知、高度な挙動が欲しくなったら追加してください。
+特定のキーを探していますか?[設定リファレンス](/ja/docs/config-reference/)に、すべてのキーの型・デフォルト値・許容値が一覧されています。
+
## 設定ファイル
Herdr は次の場所から設定を読み込みます:
diff --git a/docs/next/website/src/content/docs/zh-cn/config-reference.mdx b/docs/next/website/src/content/docs/zh-cn/config-reference.mdx
new file mode 100644
index 00000000..7f70afa2
--- /dev/null
+++ b/docs/next/website/src/content/docs/zh-cn/config-reference.mdx
@@ -0,0 +1,26 @@
+---
+title: 配置参考
+description: Herdr 的所有规范 config.toml 键,包含类型、默认值与允许的取值。
+---
+
+import ConfigReference from '../../../components/ConfigReference.astro';
+
+Herdr 从 `config.toml` 读取的所有规范键都在这里平铺列出,并支持筛选。
+配置的分步指南与各选项的背景说明见[配置](/zh-cn/docs/configuration/)。
+
+随时可以打印带注释的完整默认配置:
+
+```bash
+herdr --default-config
+```
+
+自定义命令绑定(`[[keys.command]]`)是用户自定义的表,因此不在此处逐键列出,
+见[自定义命令按键绑定](/zh-cn/docs/configuration/#自定义命令按键绑定)。
+
+各键的描述目前为英文。
+
+<ConfigReference
+ placeholder="筛选键(例如 sidebar、prefix、sound…)"
+ noMatches="没有匹配的键。"
+ keyCountLabel="个键"
+/>
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 44d2ccfc..6eb429fa 100644
--- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx
+++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx
@@ -5,6 +5,8 @@ description: 配置 Herdr 的按键绑定、主题、侧边栏行为、通知和
Herdr 无需配置文件即可工作。当你想要自定义按键、主题、侧边栏设置、通知或高级行为时再添加。
+在找某个具体的键?[配置参考](/zh-cn/docs/config-reference/)列出了所有键的类型、默认值与允许的取值。
+
## 配置文件
Herdr 从这里读取配置:
diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json
new file mode 100644
index 00000000..4c6d70e4
--- /dev/null
+++ b/docs/next/website/src/data/config-reference.json
@@ -0,0 +1,1079 @@
+{
+ "sections": [
+ {
+ "id": "general",
+ "title": "General",
+ "keys": [
+ {
+ "key": "onboarding",
+ "type": "boolean",
+ "default": "unset",
+ "description": "Show first-run setup on startup. Missing or true shows onboarding; continuing from onboarding writes onboarding = false."
+ }
+ ]
+ },
+ {
+ "id": "theme",
+ "title": "Theme",
+ "keys": [
+ {
+ "key": "theme.name",
+ "type": "string",
+ "default": "\"catppuccin\"",
+ "description": "Built-in theme name."
+ },
+ {
+ "key": "theme.auto_switch",
+ "type": "boolean",
+ "default": "false",
+ "description": "Follow host terminal light/dark appearance and switch between theme names."
+ },
+ {
+ "key": "theme.dark_name",
+ "type": "string",
+ "default": "unset",
+ "description": "Theme name used when `auto_switch` selects a dark appearance."
+ },
+ {
+ "key": "theme.light_name",
+ "type": "string",
+ "default": "unset",
+ "description": "Theme name used when `auto_switch` selects a light appearance."
+ },
+ {
+ "key": "theme.custom.accent",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the accent color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.panel_bg",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the panel_bg color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.surface0",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the surface0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.surface1",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the surface1 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.surface_dim",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the surface_dim color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.overlay0",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the overlay0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.overlay1",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the overlay1 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.text",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the text color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.subtext0",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the subtext0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.mauve",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the mauve color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.green",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the green color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.yellow",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the yellow color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.red",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the red color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.blue",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the blue color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.teal",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the teal color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.peach",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the peach color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ }
+ ]
+ },
+ {
+ "id": "terminal",
+ "title": "Terminal",
+ "keys": [
+ {
+ "key": "terminal.default_shell",
+ "type": "string",
+ "default": "\"\"",
+ "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh."
+ },
+ {
+ "key": "terminal.shell_mode",
+ "type": "enum",
+ "default": "\"auto\"",
+ "description": "Startup mode for new interactive pane shells.",
+ "values": [
+ "auto",
+ "login",
+ "non_login"
+ ]
+ },
+ {
+ "key": "terminal.new_cwd",
+ "type": "enum",
+ "default": "\"follow\"",
+ "description": "CWD policy for new interactive panes, tabs, and workspaces.",
+ "values": [
+ "follow",
+ "home",
+ "current",
+ "path"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "update",
+ "title": "Updates",
+ "keys": [
+ {
+ "key": "update.channel",
+ "type": "enum",
+ "default": "\"stable\" (\"preview\" on Windows)",
+ "description": "Update channel used by background version checks and herdr update. Homebrew, mise, and Nix installs ignore the preview channel.",
+ "values": [
+ "stable",
+ "preview"
+ ]
+ },
+ {
+ "key": "update.version_check",
+ "type": "boolean",
+ "default": "true",
+ "description": "Check herdr.dev for new Herdr versions in the background."
+ },
+ {
+ "key": "update.manifest_check",
+ "type": "boolean",
+ "default": "true",
+ "description": "Check herdr.dev for remote agent-detection manifest updates in the background. Bundled manifests and local overrides still apply."
+ }
+ ]
+ },
+ {
+ "id": "keys",
+ "title": "Keybindings",
+ "keys": [
+ {
+ "key": "keys.prefix",
+ "type": "string",
+ "default": "\"ctrl+b\"",
+ "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")."
+ },
+ {
+ "key": "keys.help",
+ "type": "keybinding",
+ "default": "\"prefix+?\"",
+ "description": "Open keybinding help."
+ },
+ {
+ "key": "keys.settings",
+ "type": "keybinding",
+ "default": "\"prefix+s\"",
+ "description": "Open settings."
+ },
+ {
+ "key": "keys.new_workspace",
+ "type": "keybinding",
+ "default": "\"prefix+shift+n\"",
+ "description": "Create a new workspace."
+ },
+ {
+ "key": "keys.new_worktree",
+ "type": "keybinding",
+ "default": "\"prefix+shift+g\"",
+ "description": "Create a Git worktree from the selected workspace."
+ },
+ {
+ "key": "keys.open_worktree",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Open an existing Git worktree from the selected workspace. Unset by default."
+ },
+ {
+ "key": "keys.remove_worktree",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Delete the selected managed worktree checkout after confirmation. Unset by default."
+ },
+ {
+ "key": "keys.rename_workspace",
+ "type": "keybinding",
+ "default": "\"prefix+shift+w\"",
+ "description": "Rename the selected workspace."
+ },
+ {
+ "key": "keys.close_workspace",
+ "type": "keybinding",
+ "default": "\"prefix+shift+d\"",
+ "description": "Close the selected workspace."
+ },
+ {
+ "key": "keys.workspace_picker",
+ "type": "keybinding",
+ "default": "\"prefix+w\"",
+ "description": "Open the workspace navigation surface."
+ },
+ {
+ "key": "keys.goto",
+ "type": "keybinding",
+ "default": "\"prefix+g\"",
+ "description": "Open the session navigator."
+ },
+ {
+ "key": "keys.navigate_workspace_up",
+ "type": "keybinding",
+ "default": "\"up\"",
+ "description": "Move workspace selection up in navigate mode."
+ },
+ {
+ "key": "keys.navigate_workspace_down",
+ "type": "keybinding",
+ "default": "\"down\"",
+ "description": "Move workspace selection down in navigate mode."
+ },
+ {
+ "key": "keys.navigate_pane_left",
+ "type": "keybinding",
+ "default": "\"h\"",
+ "description": "Focus the pane to the left in navigate mode. Left arrow is always an alias."
+ },
+ {
+ "key": "keys.navigate_pane_down",
+ "type": "keybinding",
+ "default": "\"j\"",
+ "description": "Focus the pane below in navigate mode."
+ },
+ {
+ "key": "keys.navigate_pane_up",
+ "type": "keybinding",
+ "default": "\"k\"",
+ "description": "Focus the pane above in navigate mode."
+ },
+ {
+ "key": "keys.navigate_pane_right",
+ "type": "keybinding",
+ "default": "\"l\"",
+ "description": "Focus the pane to the right in navigate mode. Right arrow is always an alias."
+ },
+ {
+ "key": "keys.detach",
+ "type": "keybinding",
+ "default": "\"prefix+q\"",
+ "description": "Detach from server/client mode, or exit --no-session mode."
+ },
+ {
+ "key": "keys.reload_config",
+ "type": "keybinding",
+ "default": "\"prefix+shift+r\"",
+ "description": "Reload config.toml in the running app/server."
+ },
+ {
+ "key": "keys.open_notification_target",
+ "type": "keybinding",
+ "default": "\"prefix+o\"",
+ "description": "Focus the currently visible notification target."
+ },
+ {
+ "key": "keys.previous_workspace",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Select the previous workspace. Unset by default."
+ },
+ {
+ "key": "keys.next_workspace",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Select the next workspace. Unset by default."
+ },
+ {
+ "key": "keys.previous_agent",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus the previous agent shown in the agent panel. Unset by default."
+ },
+ {
+ "key": "keys.next_agent",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus the next agent shown in the agent panel. Unset by default."
+ },
+ {
+ "key": "keys.focus_agent",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus an agent by index 1-9. Unset by default."
+ },
+ {
+ "key": "keys.remote_image_paste",
+ "type": "string",
+ "default": "\"ctrl+v\"",
+ "description": "Local-client shortcut that sends a clipboard image to a remote Herdr session."
+ },
+ {
+ "key": "keys.new_tab",
+ "type": "keybinding",
+ "default": "\"prefix+c\"",
+ "description": "Create a new tab in the active workspace."
+ },
+ {
+ "key": "keys.rename_tab",
+ "type": "keybinding",
+ "default": "\"prefix+shift+t\"",
+ "description": "Rename the active tab."
+ },
+ {
+ "key": "keys.previous_tab",
+ "type": "keybinding",
+ "default": "\"prefix+p\"",
+ "description": "Select the previous tab."
+ },
+ {
+ "key": "keys.next_tab",
+ "type": "keybinding",
+ "default": "\"prefix+n\"",
+ "description": "Select the next tab."
+ },
+ {
+ "key": "keys.switch_tab",
+ "type": "keybinding",
+ "default": "\"prefix+1..9\"",
+ "description": "Switch to tab 1-9."
+ },
+ {
+ "key": "keys.switch_workspace",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Switch to workspace 1-9 from prefix mode. Unset by default."
+ },
+ {
+ "key": "keys.close_tab",
+ "type": "keybinding",
+ "default": "\"prefix+shift+x\"",
+ "description": "Close the active tab."
+ },
+ {
+ "key": "keys.rename_pane",
+ "type": "keybinding",
+ "default": "\"prefix+shift+p\"",
+ "description": "Rename the focused pane."
+ },
+ {
+ "key": "keys.edit_scrollback",
+ "type": "keybinding",
+ "default": "\"prefix+e\"",
+ "description": "Open the focused pane scrollback in $EDITOR."
+ },
+ {
+ "key": "keys.copy_mode",
+ "type": "keybinding",
+ "default": "\"prefix+[\"",
+ "description": "Enter keyboard copy mode for the focused pane."
+ },
+ {
+ "key": "keys.focus_pane_left",
+ "type": "keybinding",
+ "default": "\"prefix+h\"",
+ "description": "Focus the pane to the left."
+ },
+ {
+ "key": "keys.focus_pane_down",
+ "type": "keybinding",
+ "default": "\"prefix+j\"",
+ "description": "Focus the pane below."
+ },
+ {
+ "key": "keys.focus_pane_up",
+ "type": "keybinding",
+ "default": "\"prefix+k\"",
+ "description": "Focus the pane above."
+ },
+ {
+ "key": "keys.focus_pane_right",
+ "type": "keybinding",
+ "default": "\"prefix+l\"",
+ "description": "Focus the pane to the right."
+ },
+ {
+ "key": "keys.swap_pane_left",
+ "type": "keybinding",
+ "default": "\"prefix+shift+h\"",
+ "description": "Swap the focused pane with the pane to the left."
+ },
+ {
+ "key": "keys.swap_pane_down",
+ "type": "keybinding",
+ "default": "\"prefix+shift+j\"",
+ "description": "Swap the focused pane with the pane below."
+ },
+ {
+ "key": "keys.swap_pane_up",
+ "type": "keybinding",
+ "default": "\"prefix+shift+k\"",
+ "description": "Swap the focused pane with the pane above."
+ },
+ {
+ "key": "keys.swap_pane_right",
+ "type": "keybinding",
+ "default": "\"prefix+shift+l\"",
+ "description": "Swap the focused pane with the pane to the right."
+ },
+ {
+ "key": "keys.cycle_pane_next",
+ "type": "keybinding",
+ "default": "\"prefix+tab\"",
+ "description": "Cycle to the next pane."
+ },
+ {
+ "key": "keys.cycle_pane_previous",
+ "type": "keybinding",
+ "default": "\"prefix+shift+tab\"",
+ "description": "Cycle to the previous pane."
+ },
+ {
+ "key": "keys.last_pane",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus the last focused pane across workspaces and tabs. Unset by default."
+ },
+ {
+ "key": "keys.split_vertical",
+ "type": "keybinding",
+ "default": "\"prefix+v\"",
+ "description": "Split pane vertically (side by side)."
+ },
+ {
+ "key": "keys.split_horizontal",
+ "type": "keybinding",
+ "default": "\"prefix+minus\"",
+ "description": "Split pane horizontally (stacked)."
+ },
+ {
+ "key": "keys.close_pane",
+ "type": "keybinding",
+ "default": "\"prefix+x\"",
+ "description": "Close the focused pane."
+ },
+ {
+ "key": "keys.zoom",
+ "type": "keybinding",
+ "default": "\"prefix+z\"",
+ "description": "Toggle zoom for the focused pane. The legacy key name `fullscreen` is accepted as an alias."
+ },
+ {
+ "key": "keys.resize_mode",
+ "type": "keybinding",
+ "default": "\"prefix+r\"",
+ "description": "Enter resize mode."
+ },
+ {
+ "key": "keys.toggle_sidebar",
+ "type": "keybinding",
+ "default": "\"prefix+b\"",
+ "description": "Toggle sidebar collapse."
+ },
+ {
+ "key": "keys.indexed.tabs",
+ "type": "string",
+ "default": "unset",
+ "description": "Modifier combo for tab shortcuts 1-9. Unset by default."
+ },
+ {
+ "key": "keys.indexed.workspaces",
+ "type": "string",
+ "default": "unset",
+ "description": "Modifier combo for workspace shortcuts 1-9. Unset by default."
+ },
+ {
+ "key": "keys.indexed.agents",
+ "type": "string",
+ "default": "unset",
+ "description": "Modifier combo for agent shortcuts 1-9. Unset by default."
+ }
+ ]
+ },
+ {
+ "id": "ui",
+ "title": "UI and sidebar",
+ "keys": [
+ {
+ "key": "ui.sidebar_width",
+ "type": "integer",
+ "default": "26",
+ "description": "Default expanded sidebar width in columns. Auto-scales based on workspace names."
+ },
+ {
+ "key": "ui.sidebar_min_width",
+ "type": "integer",
+ "default": "18",
+ "description": "Minimum sidebar width (columns) when expanded."
+ },
+ {
+ "key": "ui.sidebar_max_width",
+ "type": "integer",
+ "default": "36",
+ "description": "Maximum sidebar width (columns) when expanded."
+ },
+ {
+ "key": "ui.sidebar_collapsed_mode",
+ "type": "enum",
+ "default": "compact",
+ "description": "Collapsed sidebar presentation.",
+ "values": [
+ "compact",
+ "hidden"
+ ]
+ },
+ {
+ "key": "ui.mobile_width_threshold",
+ "type": "integer",
+ "default": "64",
+ "description": "Terminal width at or below which Herdr uses the mobile single-column layout."
+ },
+ {
+ "key": "ui.mouse_capture",
+ "type": "boolean",
+ "default": "true",
+ "description": "Capture mouse input for Herdr's mouse UI."
+ },
+ {
+ "key": "ui.copy_on_select",
+ "type": "boolean",
+ "default": "true",
+ "description": "Copy text selected with the mouse."
+ },
+ {
+ "key": "ui.host_cursor",
+ "type": "enum",
+ "default": "auto",
+ "description": "Host cursor policy.",
+ "values": [
+ "auto",
+ "native",
+ "drawn"
+ ]
+ },
+ {
+ "key": "ui.right_click_passthrough_modifier",
+ "type": "string",
+ "default": "\"\"",
+ "description": "Modifier that lets right-click gestures pass through to pane apps. Empty disables it. Accepts ctrl, alt, cmd, super, meta, hyper, or a + separated combination; shift is rejected because many terminals reserve Shift+mouse."
+ },
+ {
+ "key": "ui.redraw_on_focus_gained",
+ "type": "boolean",
+ "default": "true",
+ "description": "Force a full host-terminal redraw when the outer terminal regains focus."
+ },
+ {
+ "key": "ui.mouse_scroll_lines",
+ "type": "integer",
+ "default": "3",
+ "description": "Lines to scroll per mouse wheel notch."
+ },
+ {
+ "key": "ui.confirm_close",
+ "type": "boolean",
+ "default": "true",
+ "description": "Ask for confirmation before closing a workspace."
+ },
+ {
+ "key": "ui.prompt_new_tab_name",
+ "type": "boolean",
+ "default": "true",
+ "description": "Ask for a tab name before creating a new tab."
+ },
+ {
+ "key": "ui.pane_borders",
+ "type": "boolean",
+ "default": "true",
+ "description": "Draw borders around split panes."
+ },
+ {
+ "key": "ui.pane_gaps",
+ "type": "boolean",
+ "default": "true",
+ "description": "Keep split panes visually separated instead of sharing divider borders."
+ },
+ {
+ "key": "ui.show_agent_labels_on_pane_borders",
+ "type": "boolean",
+ "default": "false",
+ "description": "Show agent labels in split pane borders when no manual pane label is set."
+ },
+ {
+ "key": "ui.hide_tab_bar_when_single_tab",
+ "type": "boolean",
+ "default": "false",
+ "description": "Hide the tab row when the workspace has one tab."
+ },
+ {
+ "key": "ui.agent_panel_sort",
+ "type": "enum",
+ "default": "\"spaces\"",
+ "description": "Agent sidebar ordering. Saved values are \"spaces\" or \"priority\"; \"workspaces\" is accepted as an alias for \"spaces\".",
+ "values": [
+ "spaces",
+ "priority"
+ ]
+ },
+ {
+ "key": "ui.accent",
+ "type": "color",
+ "default": "\"cyan\"",
+ "description": "Accent color for highlights, borders, and navigation UI. Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250))."
+ }
+ ]
+ },
+ {
+ "id": "notifications",
+ "title": "Notifications",
+ "keys": [
+ {
+ "key": "ui.toast.delivery",
+ "type": "enum",
+ "default": "\"off\"",
+ "description": "Popup notification delivery. off disables popups, herdr shows in-app toasts, terminal asks the outer terminal for a desktop notification, system asks the OS notification service directly.",
+ "values": [
+ "off",
+ "herdr",
+ "terminal",
+ "system"
+ ]
+ },
+ {
+ "key": "ui.toast.delay_seconds",
+ "type": "integer",
+ "default": "1",
+ "description": "Seconds to wait before sending finished or needs-input agent notifications. Herdr notifies only if the pane is still in the same state when the delay expires. 0 is instant; valid values are 0 through 3600."
+ },
+ {
+ "key": "ui.toast.herdr.position",
+ "type": "enum",
+ "default": "\"bottom-right\"",
+ "description": "In-app toast position, relative to the full Herdr frame.",
+ "values": [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right"
+ ]
+ },
+ {
+ "key": "ui.toast.clipboard.enabled",
+ "type": "boolean",
+ "default": "true",
+ "description": "Show the copied-to-clipboard popup after a mouse copy."
+ },
+ {
+ "key": "ui.toast.clipboard.position",
+ "type": "enum",
+ "default": "\"bottom-center\"",
+ "description": "Copied-to-clipboard popup position.",
+ "values": [
+ "top-left",
+ "top-center",
+ "top-right",
+ "bottom-left",
+ "bottom-center",
+ "bottom-right"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "sound",
+ "title": "Sound",
+ "keys": [
+ {
+ "key": "ui.sound.enabled",
+ "type": "boolean",
+ "default": "true",
+ "description": "Play sounds when agents change state in background workspaces."
+ },
+ {
+ "key": "ui.sound.path",
+ "type": "path",
+ "default": "unset",
+ "description": "Optional mp3 file path used for all notification sounds. Relative paths are resolved from the config file's directory."
+ },
+ {
+ "key": "ui.sound.done_path",
+ "type": "path",
+ "default": "unset",
+ "description": "Optional mp3 file path for \"done\" notifications. Relative paths are resolved from the config file's directory."
+ },
+ {
+ "key": "ui.sound.request_path",
+ "type": "path",
+ "default": "unset",
+ "description": "Optional mp3 file path for \"request\" notifications. Relative paths are resolved from the config file's directory."
+ },
+ {
+ "key": "ui.sound.agents.pi",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Pi agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.claude",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Claude Code agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.codex",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Codex agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.gemini",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Gemini CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.cursor",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Cursor Agent CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.devin",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Devin agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.agy",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Agy agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.cline",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Cline agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.open_code",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected OpenCode agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.github_copilot",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected GitHub Copilot CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.kimi",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Kimi Code CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.kiro",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Kiro agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.droid",
+ "type": "enum",
+ "default": "\"off\"",
+ "description": "Sound override for detected Droid agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.amp",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Amp agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.grok",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Grok CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.hermes",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Hermes Agent agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.kilo",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Kilo Code CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.qodercli",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Qoder CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.maki",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected MastraCode agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "session",
+ "title": "Session",
+ "keys": [
+ {
+ "key": "session.resume_agents_on_restore",
+ "type": "boolean",
+ "default": "true",
+ "description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session."
+ }
+ ]
+ },
+ {
+ "id": "worktrees",
+ "title": "Worktrees",
+ "keys": [
+ {
+ "key": "worktrees.directory",
+ "type": "string",
+ "default": "\"~/.herdr/worktrees\"",
+ "description": "Root directory under which Herdr creates <repo>/<branch-slug> checkouts."
+ }
+ ]
+ },
+ {
+ "id": "remote",
+ "title": "Remote",
+ "keys": [
+ {
+ "key": "remote.manage_ssh_config",
+ "type": "boolean",
+ "default": "true",
+ "description": "Add keepalive fallbacks and private connection reuse for `herdr --remote`. Set false to run plain ssh unchanged."
+ }
+ ]
+ },
+ {
+ "id": "advanced",
+ "title": "Advanced",
+ "keys": [
+ {
+ "key": "advanced.scrollback_limit_bytes",
+ "type": "integer",
+ "default": "10000000",
+ "description": "Maximum scrollback buffer size in bytes retained per pane terminal. The legacy key name `scrollback_lines` is accepted as an alias."
+ }
+ ]
+ },
+ {
+ "id": "experimental",
+ "title": "Experimental",
+ "keys": [
+ {
+ "key": "experimental.allow_nested",
+ "type": "boolean",
+ "default": "false",
+ "description": "Allow launching herdr inside an existing herdr pane."
+ },
+ {
+ "key": "experimental.kitty_graphics",
+ "type": "boolean",
+ "default": "false",
+ "description": "Experimental local Kitty graphics rendering for attached clients."
+ },
+ {
+ "key": "experimental.pane_history",
+ "type": "boolean",
+ "default": "false",
+ "description": "Persist pane screen history to session-history.json."
+ },
+ {
+ "key": "experimental.reveal_hidden_cursor_for_cjk_ime",
+ "type": "boolean",
+ "default": "false",
+ "description": "Expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, so macOS native input methods keep tracking the candidate window when TUIs paint their own cursor (Claude Code, pi, codex, etc.). Default: false. When the pane reports no cursor position, falls back to the pane's top-left so a stable IME anchor is always available. Trade-off when enabled: an extra hardware cursor will be visible in the outer terminal for apps that hide the cursor without painting a replacement (vim normal mode, etc.). See #149."
+ },
+ {
+ "key": "experimental.cjk_ime_agents",
+ "type": "list of strings",
+ "default": "[]",
+ "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, maki."
+ },
+ {
+ "key": "experimental.cjk_ime_cursor_shape",
+ "type": "enum",
+ "default": "\"steady_block\"",
+ "description": "Cursor shape rendered for the IME anchor when `reveal_hidden_cursor_for_cjk_ime` is enabled.",
+ "values": [
+ "block",
+ "steady_block",
+ "underline",
+ "steady_underline",
+ "bar",
+ "steady_bar"
+ ]
+ },
+ {
+ "key": "experimental.switch_ascii_input_source_in_prefix",
+ "type": "boolean",
+ "default": "false",
+ "description": "While prefix mode is active, temporarily switch the macOS host input source to an ASCII-capable keyboard layout so prefix commands are read as ASCII even when a CJK IME is active, then restore the previous input source when prefix mode exits. macOS only; a no-op elsewhere and a best-effort no-op if the switch fails."
+ }
+ ]
+ }
+ ]
+}
diff --git a/justfile b/justfile
index d35e3d5d..a032309f 100644
--- a/justfile
+++ b/justfile
@@ -3,7 +3,7 @@
# Run tests
test:
cargo nextest run --locked --status-level fail --final-status-level fail --failure-output final --success-output never
- python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_docs_translation_parity scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty
+ python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty
just integration-assets-test
just plugin-marketplace-test
@@ -29,7 +29,7 @@ windows-lint:
# Check formatting + run unit tests + Windows target lint + maintenance script tests
check: ci windows-lint
- python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_docs_translation_parity scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty
+ python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty
@echo "docs reminder: if this changes user-facing behavior, make sure the relevant release docs are updated or called out before release."
# Install repo-local git hooks
@@ -62,6 +62,11 @@ build-libghostty-vt:
# Check that release docs and changelog have been finalized from docs/next before release
release-docs-check:
python3 scripts/agent_detection_manifest_check.py --require-website
+ python3 scripts/config_reference_check.py
+ @if ! diff -u website/src/data/config-reference.json docs/next/website/src/data/config-reference.json; then \
+ echo "error: stable config reference differs from docs/next; finalize it before releasing"; \
+ exit 1; \
+ fi
@for file in README.md CHANGELOG.md; do \
if ! diff -u "$file" "docs/next/$file"; then \
echo "error: $file differs from docs/next/$file; finalize release docs before releasing"; \
@@ -75,8 +80,9 @@ release-docs-check:
fi; \
done
@test -d docs/next/website/src/content/docs
- @for file in website/src/content/docs/*.mdx; do \
- staged="docs/next/website/src/content/docs/$(basename "$file")"; \
+ @for file in $(find website/src/content/docs -path '*/preview' -prune -o -type f -name '*.mdx' -print); do \
+ relative="${file#website/src/content/docs/}"; \
+ staged="docs/next/website/src/content/docs/$relative"; \
if [ ! -f "$staged" ]; then \
echo "error: $staged is missing; docs/next/website/src/content/docs must mirror website/src/content/docs"; \
exit 1; \
@@ -86,8 +92,9 @@ release-docs-check:
exit 1; \
fi; \
done
- @for file in docs/next/website/src/content/docs/*.mdx; do \
- released="website/src/content/docs/$(basename "$file")"; \
+ @for file in $(find docs/next/website/src/content/docs -type f -name '*.mdx' -print); do \
+ relative="${file#docs/next/website/src/content/docs/}"; \
+ released="website/src/content/docs/$relative"; \
if [ ! -f "$released" ]; then \
echo "error: $file has no matching released website doc"; \
exit 1; \
diff --git a/scripts/config_reference_check.py b/scripts/config_reference_check.py
new file mode 100644
index 00000000..f9aa5e72
--- /dev/null
+++ b/scripts/config_reference_check.py
@@ -0,0 +1,319 @@
+"""Check the website config reference against the Rust config model.
+
+Walks the serde structs in src/config/*.rs starting from the root `Config`
+struct, builds the set of canonical dotted TOML key paths, and compares it
+against the keys documented in the preview config reference. Compatibility
+aliases are intentionally documented on their canonical entries rather than
+as separate rows.
+
+The comparison checks key names and serde-derived enum values, so failures
+name exact missing, stale, duplicated, or value-drifted entries.
+
+Open-ended surfaces (arrays of tables such as [[keys.command]]) are not
+enumerable per-key and are skipped; they are listed in SKIPPED_SUBTREES so
+the skip stays explicit.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+
+DEFAULT_MODEL_ROOT = Path("src/config")
+DEFAULT_REFERENCE = Path("docs/next/website/src/data/config-reference.json")
+ROOT_STRUCT = "Config"
+
+# Dotted key prefixes that are open-ended (user-defined tables/arrays) and
+# therefore not enumerable in a flat reference table.
+SKIPPED_SUBTREES = ("keys.command",)
+
+FIELD_RE = re.compile(r"^\s*pub ([a-z_][a-z0-9_]*):\s*(.+?),?\s*$")
+STRUCT_RE = re.compile(r"^\s*pub(?:\(crate\))? struct ([A-Za-z0-9_]+)\s*\{\s*$")
+ENUM_RE = re.compile(r"^\s*pub(?:\(crate\))? enum ([A-Za-z0-9_]+)\s*\{\s*$")
+VARIANT_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\))?\s*,?\s*$")
+RENAME_ALL_RE = re.compile(r'rename_all\s*=\s*"([^"]+)"')
+RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"')
+
+
+@dataclass
+class StructField:
+ name: str
+ rust_type: str
+ doc: str
+
+
+@dataclass
+class Model:
+ structs: dict[str, list[StructField]] = field(default_factory=dict)
+ enums: dict[str, list[str]] = field(default_factory=dict)
+
+
+def apply_rename_all(name: str, style: str | None) -> str:
+ if style is None:
+ return name
+ if style == "lowercase":
+ return name.lower()
+ if style == "snake_case":
+ return re.sub(r"(?<!^)([A-Z])", r"_\1", name).lower()
+ if style == "kebab-case":
+ return re.sub(r"(?<!^)([A-Z])", r"-\1", name).lower()
+ raise ValueError(f"unsupported serde rename_all style: {style}")
+
+
+def parse_model(paths: list[Path]) -> Model:
+ model = Model()
+ for path in paths:
+ parse_file(path.read_text(encoding="utf-8"), model)
+ return model
+
+
+def parse_file(text: str, model: Model) -> None:
+ lines = text.splitlines()
+ index = 0
+ pending_attrs: list[str] = []
+
+ while index < len(lines):
+ line = lines[index]
+ stripped = line.strip()
+
+ if stripped.startswith("#["):
+ pending_attrs.append(stripped)
+ index += 1
+ continue
+
+ struct_match = STRUCT_RE.match(line)
+ enum_match = ENUM_RE.match(line)
+ if struct_match:
+ rename_all = find_rename_all(pending_attrs)
+ index = parse_struct_body(lines, index + 1, struct_match.group(1), rename_all, model)
+ pending_attrs = []
+ continue
+ if enum_match:
+ rename_all = find_rename_all(pending_attrs)
+ untagged = any("untagged" in attr for attr in pending_attrs if attr.startswith("#[serde"))
+ index = parse_enum_body(lines, index + 1, enum_match.group(1), rename_all, model)
+ if untagged:
+ model.enums.pop(enum_match.group(1), None)
+ pending_attrs = []
+ continue
+
+ if not stripped.startswith("///"):
+ pending_attrs = []
+ index += 1
+
+
+def find_rename_all(attrs: list[str]) -> str | None:
+ for attr in attrs:
+ if not attr.startswith("#[serde"):
+ continue
+ match = RENAME_ALL_RE.search(attr)
+ if match:
+ return match.group(1)
+ return None
+
+
+def parse_struct_body(
+ lines: list[str], start: int, name: str, rename_all: str | None, model: Model
+) -> int:
+ fields: list[StructField] = []
+ doc_lines: list[str] = []
+ field_attrs: list[str] = []
+ index = start
+
+ while index < len(lines):
+ stripped = lines[index].strip()
+ if stripped == "}":
+ index += 1
+ break
+
+ if stripped.startswith("///"):
+ doc_lines.append(stripped.lstrip("/").strip())
+ index += 1
+ continue
+ if stripped.startswith("#["):
+ field_attrs.append(stripped)
+ index += 1
+ continue
+
+ match = FIELD_RE.match(lines[index])
+ if match:
+ field_name = serde_field_name(match.group(1), field_attrs, rename_all)
+ if not is_skipped_field(field_attrs):
+ fields.append(
+ StructField(
+ name=field_name,
+ rust_type=match.group(2).strip(),
+ doc=" ".join(doc_lines),
+ )
+ )
+ doc_lines = []
+ field_attrs = []
+ index += 1
+
+ model.structs[name] = fields
+ return index
+
+
+def serde_field_name(name: str, attrs: list[str], rename_all: str | None) -> str:
+ for attr in attrs:
+ if attr.startswith("#[serde"):
+ match = RENAME_RE.search(attr)
+ if match and "rename_all" not in attr:
+ return match.group(1)
+ return apply_rename_all(name, rename_all) if rename_all else name
+
+
+def is_skipped_field(attrs: list[str]) -> bool:
+ for attr in attrs:
+ if not attr.startswith("#[serde"):
+ continue
+ if re.search(r"\bskip\b|\bskip_deserializing\b", attr):
+ return True
+ return False
+
+
+def parse_enum_body(
+ lines: list[str], start: int, name: str, rename_all: str | None, model: Model
+) -> int:
+ variants: list[str] = []
+ index = start
+ depth = 0
+
+ while index < len(lines):
+ stripped = lines[index].strip()
+ if depth == 0 and stripped == "}":
+ index += 1
+ break
+
+ depth += stripped.count("{") - stripped.count("}")
+ if depth == 0 and not stripped.startswith(("#[", "///")):
+ match = VARIANT_RE.match(stripped)
+ if match:
+ variants.append(apply_rename_all(match.group(1), rename_all or "lowercase"))
+ index += 1
+
+ model.enums[name] = variants
+ return index
+
+
+def strip_wrappers(rust_type: str) -> tuple[str, bool]:
+ """Return the innermost type name and whether it was wrapped in Vec."""
+ current = rust_type
+ is_vec = False
+ while True:
+ match = re.fullmatch(r"(Option|Vec|Box)<(.+)>", current)
+ if not match:
+ break
+ if match.group(1) == "Vec":
+ is_vec = True
+ current = match.group(2)
+ # std::collections::BTreeSet<...> and friends stay as-is; they are leaves.
+ return current.strip(), is_vec
+
+
+def collect_keys(model: Model) -> set[str]:
+ return {entry["key"] for entry in collect_entries(model)}
+
+
+def collect_entries(model: Model, struct_name: str = ROOT_STRUCT, prefix: str = "") -> list[dict]:
+ """Dotted keys with type/doc details; --emit prints these for bootstrapping."""
+ if struct_name not in model.structs:
+ raise KeyError(f"struct {struct_name} not found in config model")
+
+ entries: list[dict] = []
+ for struct_field in model.structs[struct_name]:
+ dotted = f"{prefix}{struct_field.name}"
+ inner, is_vec = strip_wrappers(struct_field.rust_type)
+ if inner in model.structs:
+ if dotted in SKIPPED_SUBTREES:
+ continue
+ if is_vec:
+ raise ValueError(
+ f"{dotted} is an open-ended array of tables; add it to "
+ "SKIPPED_SUBTREES and document it in prose"
+ )
+ entries.extend(collect_entries(model, inner, f"{dotted}."))
+ else:
+ entry = {"key": dotted, "rust_type": struct_field.rust_type, "doc": struct_field.doc}
+ if inner in model.enums:
+ entry["values"] = model.enums[inner]
+ entries.append(entry)
+ return entries
+
+
+def reference_entries(reference_path: Path) -> tuple[dict[str, dict], list[str]]:
+ data = json.loads(reference_path.read_text(encoding="utf-8"))
+ entries: dict[str, dict] = {}
+ errors: list[str] = []
+ for section in data["sections"]:
+ for entry in section["keys"]:
+ key = entry["key"]
+ if key in entries:
+ errors.append(f"{key}: duplicated in {reference_path}")
+ continue
+ entries[key] = entry
+ return entries, errors
+
+
+def check(model_root: Path, reference_path: Path) -> list[str]:
+ model = parse_model(sorted(model_root.glob("*.rs")))
+ code_entries = {entry["key"]: entry for entry in collect_entries(model)}
+ doc_entries, errors = reference_entries(reference_path)
+ code_keys = set(code_entries)
+ doc_keys = set(doc_entries)
+
+ for missing in sorted(code_keys - doc_keys):
+ errors.append(f"{missing}: in src/config but missing from {reference_path}")
+ for stale in sorted(doc_keys - code_keys):
+ errors.append(f"{stale}: in {reference_path} but not in src/config")
+ for key in sorted(code_keys & doc_keys):
+ expected_values = code_entries[key].get("values")
+ if expected_values is None:
+ continue
+ documented_values = doc_entries[key].get("values")
+ if documented_values != expected_values:
+ errors.append(
+ f"{key}: allowed values in {reference_path} are "
+ f"{documented_values!r}; expected {expected_values!r} from src/config"
+ )
+ return errors
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Check website config reference keys against src/config structs."
+ )
+ parser.add_argument("--model-root", default=DEFAULT_MODEL_ROOT, type=Path)
+ parser.add_argument("--reference", default=DEFAULT_REFERENCE, type=Path)
+ parser.add_argument(
+ "--emit",
+ action="store_true",
+ help="Print extracted keys with types and doc comments as JSON and exit.",
+ )
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(sys.argv[1:] if argv is None else argv)
+
+ if args.emit:
+ model = parse_model(sorted(args.model_root.glob("*.rs")))
+ print(json.dumps(collect_entries(model), indent=2))
+ return 0
+
+ errors = check(args.model_root, args.reference)
+ if errors:
+ print("error: config reference is out of sync with src/config", file=sys.stderr)
+ for error in errors:
+ print(f"- {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_config_reference_check.py b/scripts/test_config_reference_check.py
new file mode 100644
index 00000000..317aae8a
--- /dev/null
+++ b/scripts/test_config_reference_check.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from scripts.config_reference_check import (
+ Model,
+ StructField,
+ check,
+ collect_entries,
+ collect_keys,
+ parse_file,
+ parse_model,
+)
+
+
+SAMPLE_MODEL = """
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct Config {
+ pub onboarding: Option<bool>,
+ pub ui: UiConfig,
+ pub keys: KeysConfig,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct UiConfig {
+ /// Sidebar width in columns. Default: 26.
+ pub sidebar_width: u16,
+ /// Host cursor policy. Default: auto.
+ pub host_cursor: HostCursorModeConfig,
+ #[serde(rename = "accent_color")]
+ pub accent: String,
+ #[serde(skip)]
+ pub internal_cache: usize,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct KeysConfig {
+ /// Prefix key. Default: "ctrl+b".
+ pub prefix: String,
+ pub zoom: BindingConfig,
+ /// Prefix-mode custom command bindings.
+ pub command: Vec<CommandKeybindConfig>,
+ pub(crate) user_fields: BTreeSet<&'static str>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CommandKeybindConfig {
+ pub key: String,
+ pub command: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum HostCursorModeConfig {
+ Auto,
+ NativeCursor,
+ Drawn,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+pub enum BindingConfig {
+ One(String),
+ Many(Vec<String>),
+}
+"""
+
+
+def sample_model() -> Model:
+ model = Model()
+ parse_file(SAMPLE_MODEL, model)
+ return model
+
+
+class CollectKeysTests(unittest.TestCase):
+ def test_walks_nested_structs_into_dotted_keys(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertIn("onboarding", keys)
+ self.assertIn("ui.sidebar_width", keys)
+ self.assertIn("keys.prefix", keys)
+ self.assertIn("keys.zoom", keys)
+
+ def test_serde_rename_wins_over_field_name(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertIn("ui.accent_color", keys)
+ self.assertNotIn("ui.accent", keys)
+
+ def test_skips_serde_skip_and_private_fields(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertNotIn("ui.internal_cache", keys)
+ self.assertNotIn("keys.user_fields", keys)
+
+ def test_skips_listed_vec_of_struct_subtrees(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertNotIn("keys.command", keys)
+ self.assertNotIn("keys.command.key", keys)
+
+ def test_unlisted_vec_of_struct_subtree_is_an_error(self) -> None:
+ model = sample_model()
+ parse_file(
+ "pub struct ExtraConfig {\n"
+ " pub items: Vec<CommandKeybindConfig>,\n"
+ "}\n",
+ model,
+ )
+ model.structs["Config"].append(
+ StructField(name="extra", rust_type="ExtraConfig", doc="")
+ )
+
+ with self.assertRaises(ValueError) as raised:
+ collect_keys(model)
+
+ self.assertIn("extra.items", str(raised.exception))
+ self.assertIn("SKIPPED_SUBTREES", str(raised.exception))
+
+ def test_enum_values_respect_rename_all_and_untagged_enums_have_none(self) -> None:
+ entries = {entry["key"]: entry for entry in collect_entries(sample_model())}
+
+ self.assertEqual(
+ entries["ui.host_cursor"]["values"], ["auto", "native-cursor", "drawn"]
+ )
+ self.assertNotIn("values", entries["keys.zoom"])
+
+
+class CheckTests(unittest.TestCase):
+ def run_check(
+ self,
+ documented_keys: list[str],
+ *,
+ value_overrides: dict[str, list[str]] | None = None,
+ ) -> list[str]:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ model_root = root / "config"
+ model_root.mkdir()
+ (model_root / "model.rs").write_text(SAMPLE_MODEL, encoding="utf-8")
+
+ model_entries = {entry["key"]: entry for entry in collect_entries(sample_model())}
+ entries = []
+ for key in documented_keys:
+ entry = {"key": key}
+ if key in model_entries and "values" in model_entries[key]:
+ entry["values"] = model_entries[key]["values"]
+ if value_overrides and key in value_overrides:
+ entry["values"] = value_overrides[key]
+ entries.append(entry)
+
+ reference = root / "config-reference.json"
+ reference.write_text(
+ json.dumps(
+ {
+ "sections": [
+ {
+ "id": "all",
+ "title": "All",
+ "keys": entries,
+ }
+ ]
+ }
+ ),
+ encoding="utf-8",
+ )
+ return check(model_root, reference)
+
+ def all_keys(self) -> list[str]:
+ return sorted(collect_keys(sample_model()))
+
+ def test_in_sync_reference_passes(self) -> None:
+ self.assertEqual(self.run_check(self.all_keys()), [])
+
+ def test_missing_key_is_named(self) -> None:
+ documented = [key for key in self.all_keys() if key != "ui.sidebar_width"]
+
+ errors = self.run_check(documented)
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("ui.sidebar_width", errors[0])
+ self.assertIn("missing", errors[0])
+
+ def test_stale_key_is_named(self) -> None:
+ errors = self.run_check(self.all_keys() + ["ui.removed_option"])
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("ui.removed_option", errors[0])
+ self.assertIn("not in src/config", errors[0])
+
+ def test_swapped_key_fails_despite_equal_count(self) -> None:
+ documented = [
+ "ui.renamed_option" if key == "ui.sidebar_width" else key
+ for key in self.all_keys()
+ ]
+
+ errors = self.run_check(documented)
+
+ self.assertEqual(len(errors), 2)
+
+ def test_duplicate_key_is_rejected(self) -> None:
+ errors = self.run_check(self.all_keys() + ["ui.sidebar_width"])
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("duplicated", errors[0])
+
+ def test_changed_enum_values_are_rejected(self) -> None:
+ errors = self.run_check(
+ self.all_keys(),
+ value_overrides={"ui.host_cursor": ["auto", "native"]},
+ )
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("ui.host_cursor", errors[0])
+ self.assertIn("allowed values", errors[0])
+
+
+class RealModelTests(unittest.TestCase):
+ def test_real_config_model_parses_and_yields_keys(self) -> None:
+ model = parse_model(sorted(Path("src/config").glob("*.rs")))
+ keys = collect_keys(model)
+
+ self.assertGreater(len(keys), 100)
+ self.assertIn("keys.prefix", keys)
+ self.assertIn("ui.sound.agents.claude", keys)
+ self.assertNotIn("keys.command", keys)
+
+ def test_preview_reference_matches_real_config_model(self) -> None:
+ self.assertEqual(
+ check(
+ Path("src/config"),
+ Path("docs/next/website/src/data/config-reference.json"),
+ ),
+ [],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
index db80b1a7..87bd52eb 100644
--- a/scripts/test_preview.py
+++ b/scripts/test_preview.py
@@ -141,6 +141,9 @@ class PreviewNotesTests(unittest.TestCase):
title: Install Herdr
---
+import ConfigReference from '../../components/ConfigReference.astro';
+import LocaleWidget from '../../../components/LocaleWidget.astro';
+
[Install](/docs/install/)
file: ../../../public/assets/logo.svg
"""
@@ -151,6 +154,8 @@ file: ../../../public/assets/logo.svg
)
self.assertIn("[Install](/docs/preview/install/)", output)
self.assertIn("file: ../../../../public/assets/logo.svg", output)
+ self.assertIn("from '../../../components/ConfigReference.astro'", output)
+ self.assertIn("from '../../../../components/LocaleWidget.astro'", output)
self.assertIn("Preview docs describe unreleased preview builds", output)
diff --git a/website/.gitignore b/website/.gitignore
index 2d6ddb63..db0605ca 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -3,3 +3,4 @@ public/
dist/
.astro/
src/content/docs/preview/
+src/data/config-reference-preview.json
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index 532c33e5..85f123de 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -149,24 +149,24 @@ export default defineConfig({
{ label: 'Quick start', translations: { ja: 'クイックスタート', 'zh-CN': '快速开始' }, slug: 'docs/quick-start' },
{ label: 'Concepts', translations: { ja: 'コンセプト', 'zh-CN': '核心概念' }, slug: 'docs/concepts' },
{ label: 'Keyboard', translations: { ja: 'キーボード', 'zh-CN': '键盘' }, slug: 'docs/keyboard' },
- { label: 'How to work with Herdr', translations: { ja: 'Herdr での作業の進め方', 'zh-CN': '使用 Herdr 的工作方式' }, slug: 'docs/how-to-work' },
- { label: 'Troubleshooting', translations: { ja: 'トラブルシューティング', 'zh-CN': '故障排除' }, slug: 'docs/troubleshooting' },
],
},
{
- label: 'Core guides',
- translations: { ja: 'コアガイド', 'zh-CN': '核心指南' },
+ label: 'Using Herdr',
+ translations: { ja: 'Herdr を使う', 'zh-CN': '使用 Herdr' },
items: [
+ { label: 'How to work with Herdr', translations: { ja: 'Herdr での作業の進め方', 'zh-CN': '使用 Herdr 的工作方式' }, slug: 'docs/how-to-work' },
{ label: 'Agents', translations: { ja: 'エージェント', 'zh-CN': '智能体' }, slug: 'docs/agents' },
{ label: 'Session state and restore', translations: { ja: 'セッション状態と復元', 'zh-CN': '会话状态与恢复' }, slug: 'docs/session-state' },
{ label: 'Persistence and remote access', translations: { ja: '永続化とリモートアクセス', 'zh-CN': '持久化与远程访问' }, slug: 'docs/persistence-remote' },
- { label: 'Configuration', translations: { ja: '設定', 'zh-CN': '配置' }, slug: 'docs/configuration' },
],
},
{
- label: 'Plugins',
- translations: { ja: 'プラグイン', 'zh-CN': '插件' },
+ label: 'Configure',
+ translations: { ja: '設定する', 'zh-CN': '配置' },
items: [
+ { label: 'Configuration', translations: { ja: '設定', 'zh-CN': '配置指南' }, slug: 'docs/configuration' },
+ { label: 'Config reference', translations: { ja: '設定リファレンス', 'zh-CN': '配置参考' }, slug: 'docs/config-reference' },
{ label: 'Plugins', translations: { ja: 'プラグイン', 'zh-CN': '插件' }, slug: 'docs/plugins' },
{ label: 'Marketplace', translations: { ja: 'マーケットプレイス', 'zh-CN': '插件市场' }, slug: 'docs/marketplace' },
],
@@ -183,9 +183,10 @@ export default defineConfig({
],
},
{
- label: 'Updates',
- translations: { ja: '更新情報', 'zh-CN': '更新' },
+ label: 'Help',
+ translations: { ja: 'ヘルプ', 'zh-CN': '帮助' },
items: [
+ { label: 'Troubleshooting', translations: { ja: 'トラブルシューティング', 'zh-CN': '故障排除' }, slug: 'docs/troubleshooting' },
{ label: 'Preview docs', translations: { ja: 'プレビュー版ドキュメント', 'zh-CN': '预览版文档' }, slug: 'docs/preview' },
],
},
diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs
index 9c912a38..2c6fd5f4 100644
--- a/website/scripts/prepare-docs.mjs
+++ b/website/scripts/prepare-docs.mjs
@@ -9,6 +9,14 @@ const publicDir = resolve(repoRoot, 'website/public');
const stableDocsDir = resolve(repoRoot, 'website/src/content/docs');
const previewDocsSourceDir = resolve(repoRoot, 'docs/next/website/src/content/docs');
const previewDocsDir = resolve(stableDocsDir, 'preview');
+const previewConfigReferenceSource = resolve(
+ repoRoot,
+ 'docs/next/website/src/data/config-reference.json',
+);
+const previewConfigReferenceDestination = resolve(
+ repoRoot,
+ 'website/src/data/config-reference-preview.json',
+);
if (process.argv[2] === '--rewrite-preview-doc-fixture') {
const chunks = [];
@@ -51,6 +59,7 @@ async function preparePublicAssets() {
async function preparePreviewDocs() {
await rm(previewDocsDir, { recursive: true, force: true });
await copyPreviewDocs(previewDocsSourceDir, previewDocsDir);
+ await cp(previewConfigReferenceSource, previewConfigReferenceDestination);
}
async function copyPreviewDocs(sourceDir, destinationDir) {
@@ -73,7 +82,11 @@ async function copyPreviewDocs(sourceDir, destinationDir) {
export function rewritePreviewDocContent(content, relativePath = '') {
const rewritten = content
.replaceAll('/docs/', '/docs/preview/')
- .replaceAll('../../../public/', '../../../../public/');
+ .replaceAll('../../../public/', '../../../../public/')
+ // Preview docs live one directory deeper than stable docs, so component
+ // imports need one more parent segment regardless of locale depth. Only
+ // MDX import lines are rewritten; prose mentioning relative paths is not.
+ .replace(/^(import .*from\s+['"])(?=(?:\.\.\/)+components\/)/gm, '$1../');
return insertPreviewNotice(rewritten, relativePath);
}
diff --git a/website/src/components/ConfigReference.astro b/website/src/components/ConfigReference.astro
new file mode 100644
index 00000000..d951f1cc
--- /dev/null
+++ b/website/src/components/ConfigReference.astro
@@ -0,0 +1,195 @@
+---
+import stableReference from '../data/config-reference.json';
+import previewReference from '../data/config-reference-preview.json';
+
+interface Props {
+ placeholder?: string;
+ noMatches?: string;
+ keyCountLabel?: string;
+}
+
+const {
+ placeholder = 'filter keys, e.g. sidebar, prefix, sound…',
+ noMatches = 'No keys match this filter.',
+ keyCountLabel = 'keys',
+} = Astro.props;
+
+const reference = Astro.url.pathname.includes('/docs/preview/')
+ ? previewReference
+ : stableReference;
+const totalKeys = reference.sections.reduce((sum, section) => sum + section.keys.length, 0);
+---
+
+<div class="config-reference" data-count-label={keyCountLabel}>
+ <div class="config-reference-controls">
+ <input
+ type="search"
+ class="config-reference-filter"
+ placeholder={placeholder}
+ autocomplete="off"
+ spellcheck="false"
+ aria-label={placeholder}
+ />
+ <p class="config-reference-meta" aria-live="polite">{totalKeys} {keyCountLabel}</p>
+ </div>
+
+ {reference.sections.map((section) => (
+ <section class="config-reference-section">
+ <h2 class="config-reference-title" id={`ref-${section.id}`}>{section.title}</h2>
+ <ul class="config-reference-list">
+ {section.keys.map((entry) => (
+ <li
+ class="config-reference-row"
+ data-search={`${entry.key} ${entry.description} ${(entry.values ?? []).join(' ')}`.toLowerCase()}
+ >
+ <div class="config-reference-head">
+ <code class="config-reference-key">{entry.key}</code>
+ <span class="config-reference-type">{entry.type}</span>
+ {entry.default && (
+ <span class="config-reference-default">
+ default <code>{entry.default}</code>
+ </span>
+ )}
+ </div>
+ <p class="config-reference-desc">
+ {entry.description}
+ {entry.values && (
+ <span class="config-reference-values">
+ {entry.values.map((value) => (
+ <code>{value}</code>
+ ))}
+ </span>
+ )}
+ </p>
+ </li>
+ ))}
+ </ul>
+ </section>
+ ))}
+
+ <p class="config-reference-empty" hidden>{noMatches}</p>
+</div>
+
+<style>
+ .config-reference-controls {
+ display: flex;
+ align-items: baseline;
+ gap: 1rem;
+ margin-top: 1rem;
+ }
+
+ .config-reference-filter {
+ flex: 1;
+ font: inherit;
+ font-family: var(--sl-font-mono);
+ font-size: var(--sl-text-sm);
+ color: var(--sl-color-white);
+ background: var(--sl-color-bg-inline-code);
+ border: 1px solid var(--sl-color-hairline-light);
+ border-radius: 0.4rem;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .config-reference-filter:focus {
+ outline: 2px solid var(--sl-color-accent);
+ outline-offset: -1px;
+ }
+
+ .config-reference-meta {
+ margin: 0;
+ font-size: var(--sl-text-xs);
+ color: var(--sl-color-gray-3);
+ white-space: nowrap;
+ }
+
+ .config-reference-title {
+ margin-top: 2rem;
+ }
+
+ .config-reference-list {
+ list-style: none;
+ padding: 0;
+ margin: 0.75rem 0 0;
+ }
+
+ .config-reference-row {
+ padding: 0.6rem 0;
+ border-bottom: 1px solid var(--sl-color-hairline);
+ }
+
+ .config-reference-row[hidden] {
+ display: none;
+ }
+
+ .config-reference-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.6rem;
+ }
+
+ .config-reference-key {
+ font-weight: 600;
+ }
+
+ .config-reference-type {
+ font-size: var(--sl-text-xs);
+ color: var(--sl-color-gray-3);
+ border: 1px solid var(--sl-color-hairline-light);
+ border-radius: 0.3rem;
+ padding: 0 0.35rem;
+ }
+
+ .config-reference-default {
+ font-size: var(--sl-text-xs);
+ color: var(--sl-color-gray-3);
+ }
+
+ .config-reference-desc {
+ margin: 0.25rem 0 0;
+ font-size: var(--sl-text-sm);
+ color: var(--sl-color-gray-2);
+ }
+
+ .config-reference-values {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin-inline-start: 0.4rem;
+ }
+
+ .config-reference-empty {
+ margin-top: 1.5rem;
+ color: var(--sl-color-gray-3);
+ }
+</style>
+
+<script>
+ for (const root of document.querySelectorAll('.config-reference')) {
+ const filter = root.querySelector('.config-reference-filter');
+ const meta = root.querySelector('.config-reference-meta');
+ const empty = root.querySelector('.config-reference-empty');
+ const sections = [...root.querySelectorAll('.config-reference-section')];
+ const rows = [...root.querySelectorAll('.config-reference-row')];
+ const countLabel = root.dataset.countLabel;
+
+ filter?.addEventListener('input', () => {
+ const terms = filter.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
+ let visible = 0;
+
+ for (const row of rows) {
+ const haystack = row.dataset.search ?? '';
+ const match = terms.every((term) => haystack.includes(term));
+ row.hidden = !match;
+ if (match) visible += 1;
+ }
+
+ for (const section of sections) {
+ section.hidden = !section.querySelector('.config-reference-row:not([hidden])');
+ }
+
+ if (meta) meta.textContent = `${visible} ${countLabel}`;
+ if (empty) empty.hidden = visible > 0;
+ });
+ }
+</script>
diff --git a/website/src/content/docs/config-reference.mdx b/website/src/content/docs/config-reference.mdx
new file mode 100644
index 00000000..499ee390
--- /dev/null
+++ b/website/src/content/docs/config-reference.mdx
@@ -0,0 +1,21 @@
+---
+title: Config reference
+description: Every canonical config.toml key, with types, defaults, and allowed values.
+---
+
+import ConfigReference from '../../components/ConfigReference.astro';
+
+Every canonical key Herdr reads from `config.toml`, flat and filterable. For guided setup
+and the reasoning behind these options, see [Configuration](/docs/configuration/).
+
+Print the full commented default config at any time:
+
+```bash
+herdr --default-config
+```
+
+Custom command bindings (`[[keys.command]]`) are user-defined tables and are not
+listed per key here; see
+[Custom command keybindings](/docs/configuration/#custom-command-keybindings).
+
+<ConfigReference />
diff --git a/website/src/content/docs/configuration.mdx b/website/src/content/docs/configuration.mdx
index 95e3447f..6cb73239 100644
--- a/website/src/content/docs/configuration.mdx
+++ b/website/src/content/docs/configuration.mdx
@@ -5,6 +5,8 @@ description: Configure Herdr keybindings, themes, sidebar behavior, notification
Herdr works without a config file. Add one when you want custom keys, themes, sidebar settings, notifications, or advanced behavior.
+Looking for a specific key? The [Config reference](/docs/config-reference/) lists every key with its type, default, and allowed values.
+
## Config file
Herdr reads config from:
diff --git a/website/src/content/docs/ja/config-reference.mdx b/website/src/content/docs/ja/config-reference.mdx
new file mode 100644
index 00000000..7bbb60aa
--- /dev/null
+++ b/website/src/content/docs/ja/config-reference.mdx
@@ -0,0 +1,27 @@
+---
+title: 設定リファレンス
+description: Herdr の正規の config.toml キーを、型・デフォルト値・許容値つきで一覧します。
+---
+
+import ConfigReference from '../../../components/ConfigReference.astro';
+
+Herdr が `config.toml` から読み取る正規キーを、フラットに一覧して絞り込めます。
+ガイド付きの設定手順や各オプションの背景は[設定](/ja/docs/configuration/)を参照してください。
+
+コメント付きのデフォルト設定全体はいつでも出力できます:
+
+```bash
+herdr --default-config
+```
+
+カスタムコマンドバインディング(`[[keys.command]]`)はユーザー定義のテーブルのため、
+ここではキーごとに掲載していません。
+[カスタムコマンドキーバインド](/ja/docs/configuration/#カスタムコマンドキーバインド)を参照してください。
+
+各キーの説明は現時点では英語です。
+
+<ConfigReference
+ placeholder="キーを絞り込み(例: sidebar, prefix, sound…)"
+ noMatches="一致するキーはありません。"
+ keyCountLabel="キー"
+/>
diff --git a/website/src/content/docs/ja/configuration.mdx b/website/src/content/docs/ja/configuration.mdx
index 37dadfc9..9c78aad3 100644
--- a/website/src/content/docs/ja/configuration.mdx
+++ b/website/src/content/docs/ja/configuration.mdx
@@ -5,6 +5,8 @@ description: Herdr のキーバインド、テーマ、サイドバーの挙動ã
Herdr は設定ファイルなしで動作します。カスタムキー、テーマ、サイドバー設定、通知、高度な挙動が欲しくなったら追加してください。
+特定のキーを探していますか?[設定リファレンス](/ja/docs/config-reference/)に、すべてのキーの型・デフォルト値・許容値が一覧されています。
+
## 設定ファイル
Herdr は次の場所から設定を読み込みます:
diff --git a/website/src/content/docs/zh-cn/config-reference.mdx b/website/src/content/docs/zh-cn/config-reference.mdx
new file mode 100644
index 00000000..7f70afa2
--- /dev/null
+++ b/website/src/content/docs/zh-cn/config-reference.mdx
@@ -0,0 +1,26 @@
+---
+title: 配置参考
+description: Herdr 的所有规范 config.toml 键,包含类型、默认值与允许的取值。
+---
+
+import ConfigReference from '../../../components/ConfigReference.astro';
+
+Herdr 从 `config.toml` 读取的所有规范键都在这里平铺列出,并支持筛选。
+配置的分步指南与各选项的背景说明见[配置](/zh-cn/docs/configuration/)。
+
+随时可以打印带注释的完整默认配置:
+
+```bash
+herdr --default-config
+```
+
+自定义命令绑定(`[[keys.command]]`)是用户自定义的表,因此不在此处逐键列出,
+见[自定义命令按键绑定](/zh-cn/docs/configuration/#自定义命令按键绑定)。
+
+各键的描述目前为英文。
+
+<ConfigReference
+ placeholder="筛选键(例如 sidebar、prefix、sound…)"
+ noMatches="没有匹配的键。"
+ keyCountLabel="个键"
+/>
diff --git a/website/src/content/docs/zh-cn/configuration.mdx b/website/src/content/docs/zh-cn/configuration.mdx
index 3b64df6f..ac9bc73c 100644
--- a/website/src/content/docs/zh-cn/configuration.mdx
+++ b/website/src/content/docs/zh-cn/configuration.mdx
@@ -5,6 +5,8 @@ description: 配置 Herdr 的按键绑定、主题、侧边栏行为、通知和
Herdr 无需配置文件即可工作。当你想要自定义按键、主题、侧边栏设置、通知或高级行为时再添加。
+在找某个具体的键?[配置参考](/zh-cn/docs/config-reference/)列出了所有键的类型、默认值与允许的取值。
+
## 配置文件
Herdr 从这里读取配置:
diff --git a/website/src/data/config-reference.json b/website/src/data/config-reference.json
new file mode 100644
index 00000000..dd7d5774
--- /dev/null
+++ b/website/src/data/config-reference.json
@@ -0,0 +1,1062 @@
+{
+ "sections": [
+ {
+ "id": "general",
+ "title": "General",
+ "keys": [
+ {
+ "key": "onboarding",
+ "type": "boolean",
+ "default": "unset",
+ "description": "Show first-run setup on startup. Missing or true shows onboarding; continuing from onboarding writes onboarding = false."
+ }
+ ]
+ },
+ {
+ "id": "theme",
+ "title": "Theme",
+ "keys": [
+ {
+ "key": "theme.name",
+ "type": "string",
+ "default": "\"catppuccin\"",
+ "description": "Built-in theme name."
+ },
+ {
+ "key": "theme.auto_switch",
+ "type": "boolean",
+ "default": "false",
+ "description": "Follow host terminal light/dark appearance and switch between theme names."
+ },
+ {
+ "key": "theme.dark_name",
+ "type": "string",
+ "default": "unset",
+ "description": "Theme name used when `auto_switch` selects a dark appearance."
+ },
+ {
+ "key": "theme.light_name",
+ "type": "string",
+ "default": "unset",
+ "description": "Theme name used when `auto_switch` selects a light appearance."
+ },
+ {
+ "key": "theme.custom.accent",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the accent color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.panel_bg",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the panel_bg color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.surface0",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the surface0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.surface1",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the surface1 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.surface_dim",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the surface_dim color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.overlay0",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the overlay0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.overlay1",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the overlay1 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.text",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the text color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.subtext0",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the subtext0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.mauve",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the mauve color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.green",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the green color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.yellow",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the yellow color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.red",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the red color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.blue",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the blue color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.teal",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the teal color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ },
+ {
+ "key": "theme.custom.peach",
+ "type": "color",
+ "default": "unset",
+ "description": "Override the peach color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases."
+ }
+ ]
+ },
+ {
+ "id": "terminal",
+ "title": "Terminal",
+ "keys": [
+ {
+ "key": "terminal.default_shell",
+ "type": "string",
+ "default": "\"\"",
+ "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh."
+ },
+ {
+ "key": "terminal.shell_mode",
+ "type": "enum",
+ "default": "\"auto\"",
+ "description": "Startup mode for new interactive pane shells.",
+ "values": [
+ "auto",
+ "login",
+ "non_login"
+ ]
+ },
+ {
+ "key": "terminal.new_cwd",
+ "type": "enum",
+ "default": "\"follow\"",
+ "description": "CWD policy for new interactive panes, tabs, and workspaces.",
+ "values": [
+ "follow",
+ "home",
+ "current",
+ "path"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "update",
+ "title": "Updates",
+ "keys": [
+ {
+ "key": "update.channel",
+ "type": "enum",
+ "default": "\"stable\" (\"preview\" on Windows)",
+ "description": "Update channel used by background version checks and herdr update. Homebrew, mise, and Nix installs ignore the preview channel.",
+ "values": [
+ "stable",
+ "preview"
+ ]
+ },
+ {
+ "key": "update.version_check",
+ "type": "boolean",
+ "default": "true",
+ "description": "Check herdr.dev for new Herdr versions in the background."
+ },
+ {
+ "key": "update.manifest_check",
+ "type": "boolean",
+ "default": "true",
+ "description": "Check herdr.dev for remote agent-detection manifest updates in the background. Bundled manifests and local overrides still apply."
+ }
+ ]
+ },
+ {
+ "id": "keys",
+ "title": "Keybindings",
+ "keys": [
+ {
+ "key": "keys.prefix",
+ "type": "string",
+ "default": "\"ctrl+b\"",
+ "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")."
+ },
+ {
+ "key": "keys.help",
+ "type": "keybinding",
+ "default": "\"prefix+?\"",
+ "description": "Open keybinding help."
+ },
+ {
+ "key": "keys.settings",
+ "type": "keybinding",
+ "default": "\"prefix+s\"",
+ "description": "Open settings."
+ },
+ {
+ "key": "keys.new_workspace",
+ "type": "keybinding",
+ "default": "\"prefix+shift+n\"",
+ "description": "Create a new workspace."
+ },
+ {
+ "key": "keys.new_worktree",
+ "type": "keybinding",
+ "default": "\"prefix+shift+g\"",
+ "description": "Create a Git worktree from the selected workspace."
+ },
+ {
+ "key": "keys.open_worktree",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Open an existing Git worktree from the selected workspace. Unset by default."
+ },
+ {
+ "key": "keys.remove_worktree",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Delete the selected managed worktree checkout after confirmation. Unset by default."
+ },
+ {
+ "key": "keys.rename_workspace",
+ "type": "keybinding",
+ "default": "\"prefix+shift+w\"",
+ "description": "Rename the selected workspace."
+ },
+ {
+ "key": "keys.close_workspace",
+ "type": "keybinding",
+ "default": "\"prefix+shift+d\"",
+ "description": "Close the selected workspace."
+ },
+ {
+ "key": "keys.workspace_picker",
+ "type": "keybinding",
+ "default": "\"prefix+w\"",
+ "description": "Open the workspace navigation surface."
+ },
+ {
+ "key": "keys.goto",
+ "type": "keybinding",
+ "default": "\"prefix+g\"",
+ "description": "Open the session navigator."
+ },
+ {
+ "key": "keys.navigate_workspace_up",
+ "type": "keybinding",
+ "default": "\"up\"",
+ "description": "Move workspace selection up in navigate mode."
+ },
+ {
+ "key": "keys.navigate_workspace_down",
+ "type": "keybinding",
+ "default": "\"down\"",
+ "description": "Move workspace selection down in navigate mode."
+ },
+ {
+ "key": "keys.navigate_pane_left",
+ "type": "keybinding",
+ "default": "\"h\"",
+ "description": "Focus the pane to the left in navigate mode. Left arrow is always an alias."
+ },
+ {
+ "key": "keys.navigate_pane_down",
+ "type": "keybinding",
+ "default": "\"j\"",
+ "description": "Focus the pane below in navigate mode."
+ },
+ {
+ "key": "keys.navigate_pane_up",
+ "type": "keybinding",
+ "default": "\"k\"",
+ "description": "Focus the pane above in navigate mode."
+ },
+ {
+ "key": "keys.navigate_pane_right",
+ "type": "keybinding",
+ "default": "\"l\"",
+ "description": "Focus the pane to the right in navigate mode. Right arrow is always an alias."
+ },
+ {
+ "key": "keys.detach",
+ "type": "keybinding",
+ "default": "\"prefix+q\"",
+ "description": "Detach from server/client mode, or exit --no-session mode."
+ },
+ {
+ "key": "keys.reload_config",
+ "type": "keybinding",
+ "default": "\"prefix+shift+r\"",
+ "description": "Reload config.toml in the running app/server."
+ },
+ {
+ "key": "keys.open_notification_target",
+ "type": "keybinding",
+ "default": "\"prefix+o\"",
+ "description": "Focus the currently visible notification target."
+ },
+ {
+ "key": "keys.previous_workspace",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Select the previous workspace. Unset by default."
+ },
+ {
+ "key": "keys.next_workspace",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Select the next workspace. Unset by default."
+ },
+ {
+ "key": "keys.previous_agent",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus the previous agent shown in the agent panel. Unset by default."
+ },
+ {
+ "key": "keys.next_agent",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus the next agent shown in the agent panel. Unset by default."
+ },
+ {
+ "key": "keys.focus_agent",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus an agent by index 1-9. Unset by default."
+ },
+ {
+ "key": "keys.remote_image_paste",
+ "type": "string",
+ "default": "\"ctrl+v\"",
+ "description": "Local-client shortcut that sends a clipboard image to a remote Herdr session."
+ },
+ {
+ "key": "keys.new_tab",
+ "type": "keybinding",
+ "default": "\"prefix+c\"",
+ "description": "Create a new tab in the active workspace."
+ },
+ {
+ "key": "keys.rename_tab",
+ "type": "keybinding",
+ "default": "\"prefix+shift+t\"",
+ "description": "Rename the active tab."
+ },
+ {
+ "key": "keys.previous_tab",
+ "type": "keybinding",
+ "default": "\"prefix+p\"",
+ "description": "Select the previous tab."
+ },
+ {
+ "key": "keys.next_tab",
+ "type": "keybinding",
+ "default": "\"prefix+n\"",
+ "description": "Select the next tab."
+ },
+ {
+ "key": "keys.switch_tab",
+ "type": "keybinding",
+ "default": "\"prefix+1..9\"",
+ "description": "Switch to tab 1-9."
+ },
+ {
+ "key": "keys.switch_workspace",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Switch to workspace 1-9 from prefix mode. Unset by default."
+ },
+ {
+ "key": "keys.close_tab",
+ "type": "keybinding",
+ "default": "\"prefix+shift+x\"",
+ "description": "Close the active tab."
+ },
+ {
+ "key": "keys.rename_pane",
+ "type": "keybinding",
+ "default": "\"prefix+shift+p\"",
+ "description": "Rename the focused pane."
+ },
+ {
+ "key": "keys.edit_scrollback",
+ "type": "keybinding",
+ "default": "\"prefix+e\"",
+ "description": "Open the focused pane scrollback in $EDITOR."
+ },
+ {
+ "key": "keys.copy_mode",
+ "type": "keybinding",
+ "default": "\"prefix+[\"",
+ "description": "Enter keyboard copy mode for the focused pane."
+ },
+ {
+ "key": "keys.focus_pane_left",
+ "type": "keybinding",
+ "default": "\"prefix+h\"",
+ "description": "Focus the pane to the left."
+ },
+ {
+ "key": "keys.focus_pane_down",
+ "type": "keybinding",
+ "default": "\"prefix+j\"",
+ "description": "Focus the pane below."
+ },
+ {
+ "key": "keys.focus_pane_up",
+ "type": "keybinding",
+ "default": "\"prefix+k\"",
+ "description": "Focus the pane above."
+ },
+ {
+ "key": "keys.focus_pane_right",
+ "type": "keybinding",
+ "default": "\"prefix+l\"",
+ "description": "Focus the pane to the right."
+ },
+ {
+ "key": "keys.swap_pane_left",
+ "type": "keybinding",
+ "default": "\"prefix+shift+h\"",
+ "description": "Swap the focused pane with the pane to the left."
+ },
+ {
+ "key": "keys.swap_pane_down",
+ "type": "keybinding",
+ "default": "\"prefix+shift+j\"",
+ "description": "Swap the focused pane with the pane below."
+ },
+ {
+ "key": "keys.swap_pane_up",
+ "type": "keybinding",
+ "default": "\"prefix+shift+k\"",
+ "description": "Swap the focused pane with the pane above."
+ },
+ {
+ "key": "keys.swap_pane_right",
+ "type": "keybinding",
+ "default": "\"prefix+shift+l\"",
+ "description": "Swap the focused pane with the pane to the right."
+ },
+ {
+ "key": "keys.cycle_pane_next",
+ "type": "keybinding",
+ "default": "\"prefix+tab\"",
+ "description": "Cycle to the next pane."
+ },
+ {
+ "key": "keys.cycle_pane_previous",
+ "type": "keybinding",
+ "default": "\"prefix+shift+tab\"",
+ "description": "Cycle to the previous pane."
+ },
+ {
+ "key": "keys.last_pane",
+ "type": "keybinding",
+ "default": "unset",
+ "description": "Focus the last focused pane across workspaces and tabs. Unset by default."
+ },
+ {
+ "key": "keys.split_vertical",
+ "type": "keybinding",
+ "default": "\"prefix+v\"",
+ "description": "Split pane vertically (side by side)."
+ },
+ {
+ "key": "keys.split_horizontal",
+ "type": "keybinding",
+ "default": "\"prefix+minus\"",
+ "description": "Split pane horizontally (stacked)."
+ },
+ {
+ "key": "keys.close_pane",
+ "type": "keybinding",
+ "default": "\"prefix+x\"",
+ "description": "Close the focused pane."
+ },
+ {
+ "key": "keys.zoom",
+ "type": "keybinding",
+ "default": "\"prefix+z\"",
+ "description": "Toggle zoom for the focused pane. The legacy key name `fullscreen` is accepted as an alias."
+ },
+ {
+ "key": "keys.resize_mode",
+ "type": "keybinding",
+ "default": "\"prefix+r\"",
+ "description": "Enter resize mode."
+ },
+ {
+ "key": "keys.toggle_sidebar",
+ "type": "keybinding",
+ "default": "\"prefix+b\"",
+ "description": "Toggle sidebar collapse."
+ },
+ {
+ "key": "keys.indexed.tabs",
+ "type": "string",
+ "default": "unset",
+ "description": "Modifier combo for tab shortcuts 1-9. Unset by default."
+ },
+ {
+ "key": "keys.indexed.workspaces",
+ "type": "string",
+ "default": "unset",
+ "description": "Modifier combo for workspace shortcuts 1-9. Unset by default."
+ },
+ {
+ "key": "keys.indexed.agents",
+ "type": "string",
+ "default": "unset",
+ "description": "Modifier combo for agent shortcuts 1-9. Unset by default."
+ }
+ ]
+ },
+ {
+ "id": "ui",
+ "title": "UI and sidebar",
+ "keys": [
+ {
+ "key": "ui.sidebar_width",
+ "type": "integer",
+ "default": "26",
+ "description": "Default expanded sidebar width in columns. Auto-scales based on workspace names."
+ },
+ {
+ "key": "ui.sidebar_min_width",
+ "type": "integer",
+ "default": "18",
+ "description": "Minimum sidebar width (columns) when expanded."
+ },
+ {
+ "key": "ui.sidebar_max_width",
+ "type": "integer",
+ "default": "36",
+ "description": "Maximum sidebar width (columns) when expanded."
+ },
+ {
+ "key": "ui.sidebar_collapsed_mode",
+ "type": "enum",
+ "default": "compact",
+ "description": "Collapsed sidebar presentation.",
+ "values": [
+ "compact",
+ "hidden"
+ ]
+ },
+ {
+ "key": "ui.mobile_width_threshold",
+ "type": "integer",
+ "default": "64",
+ "description": "Terminal width at or below which Herdr uses the mobile single-column layout."
+ },
+ {
+ "key": "ui.mouse_capture",
+ "type": "boolean",
+ "default": "true",
+ "description": "Capture mouse input for Herdr's mouse UI."
+ },
+ {
+ "key": "ui.host_cursor",
+ "type": "enum",
+ "default": "auto",
+ "description": "Host cursor policy.",
+ "values": [
+ "auto",
+ "native",
+ "drawn"
+ ]
+ },
+ {
+ "key": "ui.right_click_passthrough_modifier",
+ "type": "string",
+ "default": "\"\"",
+ "description": "Modifier that lets right-click gestures pass through to pane apps. Empty disables it. Accepts ctrl, alt, cmd, super, meta, hyper, or a + separated combination; shift is rejected because many terminals reserve Shift+mouse."
+ },
+ {
+ "key": "ui.redraw_on_focus_gained",
+ "type": "boolean",
+ "default": "true",
+ "description": "Force a full host-terminal redraw when the outer terminal regains focus."
+ },
+ {
+ "key": "ui.mouse_scroll_lines",
+ "type": "integer",
+ "default": "3",
+ "description": "Lines to scroll per mouse wheel notch."
+ },
+ {
+ "key": "ui.confirm_close",
+ "type": "boolean",
+ "default": "true",
+ "description": "Ask for confirmation before closing a workspace."
+ },
+ {
+ "key": "ui.prompt_new_tab_name",
+ "type": "boolean",
+ "default": "true",
+ "description": "Ask for a tab name before creating a new tab."
+ },
+ {
+ "key": "ui.pane_borders",
+ "type": "boolean",
+ "default": "true",
+ "description": "Draw borders around split panes."
+ },
+ {
+ "key": "ui.pane_gaps",
+ "type": "boolean",
+ "default": "true",
+ "description": "Keep split panes visually separated instead of sharing divider borders."
+ },
+ {
+ "key": "ui.show_agent_labels_on_pane_borders",
+ "type": "boolean",
+ "default": "false",
+ "description": "Show agent labels in split pane borders when no manual pane label is set."
+ },
+ {
+ "key": "ui.hide_tab_bar_when_single_tab",
+ "type": "boolean",
+ "default": "false",
+ "description": "Hide the tab row when the workspace has one tab."
+ },
+ {
+ "key": "ui.agent_panel_sort",
+ "type": "enum",
+ "default": "\"spaces\"",
+ "description": "Agent sidebar ordering. Saved values are \"spaces\" or \"priority\"; \"workspaces\" is accepted as an alias for \"spaces\".",
+ "values": [
+ "spaces",
+ "priority"
+ ]
+ },
+ {
+ "key": "ui.accent",
+ "type": "color",
+ "default": "\"cyan\"",
+ "description": "Accent color for highlights, borders, and navigation UI. Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250))."
+ }
+ ]
+ },
+ {
+ "id": "notifications",
+ "title": "Notifications",
+ "keys": [
+ {
+ "key": "ui.toast.delivery",
+ "type": "enum",
+ "default": "\"off\"",
+ "description": "Popup notification delivery. off disables popups, herdr shows in-app toasts, terminal asks the outer terminal for a desktop notification, system asks the OS notification service directly.",
+ "values": [
+ "off",
+ "herdr",
+ "terminal",
+ "system"
+ ]
+ },
+ {
+ "key": "ui.toast.delay_seconds",
+ "type": "integer",
+ "default": "1",
+ "description": "Seconds to wait before sending finished or needs-input agent notifications. Herdr notifies only if the pane is still in the same state when the delay expires. 0 is instant; valid values are 0 through 3600."
+ },
+ {
+ "key": "ui.toast.herdr.position",
+ "type": "enum",
+ "default": "\"bottom-right\"",
+ "description": "In-app toast position, relative to the full Herdr frame.",
+ "values": [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right"
+ ]
+ },
+ {
+ "key": "ui.toast.clipboard.enabled",
+ "type": "boolean",
+ "default": "true",
+ "description": "Show the copied-to-clipboard popup after a mouse copy."
+ },
+ {
+ "key": "ui.toast.clipboard.position",
+ "type": "enum",
+ "default": "\"bottom-center\"",
+ "description": "Copied-to-clipboard popup position.",
+ "values": [
+ "top-left",
+ "top-center",
+ "top-right",
+ "bottom-left",
+ "bottom-center",
+ "bottom-right"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "sound",
+ "title": "Sound",
+ "keys": [
+ {
+ "key": "ui.sound.enabled",
+ "type": "boolean",
+ "default": "true",
+ "description": "Play sounds when agents change state in background workspaces."
+ },
+ {
+ "key": "ui.sound.path",
+ "type": "path",
+ "default": "unset",
+ "description": "Optional mp3 file path used for all notification sounds. Relative paths are resolved from the config file's directory."
+ },
+ {
+ "key": "ui.sound.done_path",
+ "type": "path",
+ "default": "unset",
+ "description": "Optional mp3 file path for \"done\" notifications. Relative paths are resolved from the config file's directory."
+ },
+ {
+ "key": "ui.sound.request_path",
+ "type": "path",
+ "default": "unset",
+ "description": "Optional mp3 file path for \"request\" notifications. Relative paths are resolved from the config file's directory."
+ },
+ {
+ "key": "ui.sound.agents.pi",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Pi agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.claude",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Claude Code agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.codex",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Codex agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.gemini",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Gemini CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.cursor",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Cursor Agent CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.devin",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Devin agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.agy",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Agy agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.cline",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Cline agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.open_code",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected OpenCode agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.github_copilot",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected GitHub Copilot CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.kimi",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Kimi Code CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.kiro",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Kiro agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.droid",
+ "type": "enum",
+ "default": "\"off\"",
+ "description": "Sound override for detected Droid agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.amp",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Amp agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.grok",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Grok CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.hermes",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Hermes Agent agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.kilo",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Kilo Code CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ },
+ {
+ "key": "ui.sound.agents.qodercli",
+ "type": "enum",
+ "default": "\"default\"",
+ "description": "Sound override for detected Qoder CLI agents.",
+ "values": [
+ "default",
+ "on",
+ "off"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "session",
+ "title": "Session",
+ "keys": [
+ {
+ "key": "session.resume_agents_on_restore",
+ "type": "boolean",
+ "default": "true",
+ "description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session."
+ }
+ ]
+ },
+ {
+ "id": "worktrees",
+ "title": "Worktrees",
+ "keys": [
+ {
+ "key": "worktrees.directory",
+ "type": "string",
+ "default": "\"~/.herdr/worktrees\"",
+ "description": "Root directory under which Herdr creates <repo>/<branch-slug> checkouts."
+ }
+ ]
+ },
+ {
+ "id": "remote",
+ "title": "Remote",
+ "keys": [
+ {
+ "key": "remote.manage_ssh_config",
+ "type": "boolean",
+ "default": "true",
+ "description": "Add keepalive fallbacks and private connection reuse for `herdr --remote`. Set false to run plain ssh unchanged."
+ }
+ ]
+ },
+ {
+ "id": "advanced",
+ "title": "Advanced",
+ "keys": [
+ {
+ "key": "advanced.scrollback_limit_bytes",
+ "type": "integer",
+ "default": "10000000",
+ "description": "Maximum scrollback buffer size in bytes retained per pane terminal. The legacy key name `scrollback_lines` is accepted as an alias."
+ }
+ ]
+ },
+ {
+ "id": "experimental",
+ "title": "Experimental",
+ "keys": [
+ {
+ "key": "experimental.allow_nested",
+ "type": "boolean",
+ "default": "false",
+ "description": "Allow launching herdr inside an existing herdr pane."
+ },
+ {
+ "key": "experimental.kitty_graphics",
+ "type": "boolean",
+ "default": "false",
+ "description": "Experimental local Kitty graphics rendering for attached clients."
+ },
+ {
+ "key": "experimental.pane_history",
+ "type": "boolean",
+ "default": "false",
+ "description": "Persist pane screen history to session-history.json."
+ },
+ {
+ "key": "experimental.reveal_hidden_cursor_for_cjk_ime",
+ "type": "boolean",
+ "default": "false",
+ "description": "Expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, so macOS native input methods keep tracking the candidate window when TUIs paint their own cursor (Claude Code, pi, codex, etc.). Default: false. When the pane reports no cursor position, falls back to the pane's top-left so a stable IME anchor is always available. Trade-off when enabled: an extra hardware cursor will be visible in the outer terminal for apps that hide the cursor without painting a replacement (vim normal mode, etc.). See #149."
+ },
+ {
+ "key": "experimental.cjk_ime_agents",
+ "type": "list of strings",
+ "default": "[]",
+ "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder."
+ },
+ {
+ "key": "experimental.cjk_ime_cursor_shape",
+ "type": "enum",
+ "default": "\"steady_block\"",
+ "description": "Cursor shape rendered for the IME anchor when `reveal_hidden_cursor_for_cjk_ime` is enabled.",
+ "values": [
+ "block",
+ "steady_block",
+ "underline",
+ "steady_underline",
+ "bar",
+ "steady_bar"
+ ]
+ },
+ {
+ "key": "experimental.switch_ascii_input_source_in_prefix",
+ "type": "boolean",
+ "default": "false",
+ "description": "While prefix mode is active, temporarily switch the macOS host input source to an ASCII-capable keyboard layout so prefix commands are read as ASCII even when a CJK IME is active, then restore the previous input source when prefix mode exits. macOS only; a no-op elsewhere and a best-effort no-op if the switch fails."
+ }
+ ]
+ }
+ ]
+}