diff options
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." + } + ] + } + ] +} @@ -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." + } + ] + } + ] +} |
