第 24 章:CLI 交互设计与 Skin Engine
核心问题:Rich + prompt_toolkit 如何构建交互式 CLI?Skin Engine 的主题系统如何工作?
24.1 HermesCLI 架构
当你在终端键入 hermes 并开始对话,你看到的一切——金色的 ASCII art banner、闪烁的 spinner、自动补全的斜杠命令、带语法高亮的代码块——都来自两个互补的库的精密协作:Rich 负责富文本输出,prompt_toolkit 负责交互式输入。理解这两个库如何被编织在一起,是理解整个 CLI 层的关键。
HermesCLI 不是一个简单的 input() 替代品,而是一个完整的 TUI(Terminal User Interface)应用。它从 prompt_toolkit 中精选了十几个组件:用 HSplit 将终端垂直分割为输出区和输入区,TextArea 提供多行编辑和历史回溯,CompletionsMenu 在输入 / 时弹出命令菜单,KeyBindings 捕获 Ctrl+C/Ctrl+D 等快捷键。
这个架构带来一个棘手的问题:当 prompt_toolkit 控制终端输出时,其他线程(比如 spinner 动画线程、工具执行线程)不能直接写 sys.stdout。patch_stdout 上下文管理器解决了这个冲突——它用一个 StdoutProxy 替换 sys.stdout,将所有输出写入队列,由 prompt_toolkit 的事件循环在安全的时机刷新到屏幕上。
这个代理的存在直接影响了 KawaiiSpinner 的设计(我们在 24.4 节详述)。Spinner 必须检测自己是否在 StdoutProxy 下运行,如果是,就跳过基于 \r 的覆写动画——因为 StdoutProxy 会在每次 flush 时注入换行符,导致 spinner 的每一帧都出现在新的一行上。
cli.py 的配置加载函数 load_cli_config() 建立了一个四层优先级链:用户配置(~/.hermes/config.yaml)→ 项目配置(./cli-config.yaml)→ 硬编码默认值 → 环境变量覆盖。这个叠加模型贯穿整个 Hermes,我们在第 25 章会做完整的分析。
CLI 的启动还有一个值得注意的细节——在任何 Rich 或 prompt_toolkit 代码执行之前,cli.py 的模块级代码就设置了 os.environ["HERMES_QUIET"] = "1"(第 34 行),抑制所有后续模块导入时的启动消息,确保用户看到的第一个输出是精心排版的 banner,而不是一堆 logging 噪声。
24.2 Slash 命令系统
当你在 CLI 中输入 /help,你触发了一个设计精良的命令分发系统。它的核心是一个数据驱动的注册表——COMMAND_REGISTRY,定义在 hermes_cli/commands.py 中。
每个命令是一个 frozen dataclass CommandDef,包含 name、description、category、aliases、args_hint、subcommands 等字段,以及 cli_only/gateway_only 的可见性控制。frozen=True 是深思熟虑的选择——命令定义在模块加载后不可变。注册表包含约 40 个命令,覆盖 Session、Configuration、Tools & Skills、Info 和 Exit 五个类别。几个典型的命令定义:
# hermes_cli/commands.py (sampled)COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("new", "Start a new session", "Session", aliases=("reset",)), CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", subcommands=("none", "minimal", "low", "medium", "high", ...)), CommandDef("quit", "Exit the CLI", "Exit", cli_only=True, aliases=("exit", "q")), # ... ~40 commands total]这个注册表的巧妙之处在于它是所有消费者的唯一数据源。CLI 的 /help 输出、Gateway 的帮助文本、Telegram 的 BotCommands 菜单、Slack 的子命令映射、自动补全——全部从同一个 COMMAND_REGISTRY 派生。当插件注册新命令后,rebuild_lookups() 重建所有派生字典,确保插件命令出现在帮助、自动补全、Gateway 分发等所有表面上。
自动补全器 SlashCommandCompleter 支持三种上下文感知补全模式:输入 @ 触发 Claude Code 风格的上下文引用补全(@diff、@staged、@file:path);输入路径片段触发文件路径补全;输入 /model 后触发模型别名补全。SlashCommandAutoSuggest 则提供”幽灵文本”——输入 /upd 时后面以灰色显示 ate,按右箭头接受。
Gateway 端的命令路由有一个特殊机制——gateway_config_gate。某些命令(如 /verbose)在设计上是 cli_only=True 的,但可以通过 config.yaml 中的特定 dotpath 解锁 Gateway 可用性。_is_gateway_available() 先检查 cli_only,再检查是否有 config gate 覆盖。Telegram 菜单注册还需要名称清洗——Bot API 只允许小写字母、数字和下划线,_sanitize_telegram_name() 处理转换和截断后的去重。
24.3 Skin Engine
Hermes 的主题系统是一个数据驱动的皮肤引擎,它让用户不修改一行代码就能完全改变 CLI 的视觉外观——从颜色方案到 spinner 动画到品牌文案。
引擎的核心数据结构是 SkinConfig dataclass,包含 colors(15 个色彩槽位,hex 色值)、spinner(等待动画的表情、动词、装饰翼)、branding(agent 名称、欢迎语、提示符号)、tool_emojis、banner_logo/banner_hero(Rich markup ASCII art)等字段。
系统内置了 7 个主题,每个都有完整的”人格”:default(经典金色 kawaii)、ares(战神——深红与青铜)、mono(灰度极简)、slate(冷蓝开发者)、poseidon(海神——深蓝与海沫)、sisyphus(苦行灰度)、charizard(火山——橙红与余烬)。每个主题不仅有配色方案,还有专属的 spinner 文案和 Unicode braille 图腾——ares 的 spinner 说 “forging” 和 “tempering steel”,poseidon 说 “charting currents”,sisyphus 说 “resetting the boulder”。
主题加载遵循”用户优先,默认继承”原则。_build_skin_config() 总是先复制 default 主题的全部字段(colors、spinner、branding),再用传入数据逐层 .update() 覆盖。无论是内置主题还是用户自定义 YAML,都先以 default 主题为基础,然后覆盖指定的字段。用户只需要在 ~/.hermes/skins/mytheme.yaml 中指定想改变的部分,其余自动继承默认值。这个继承策略和第 25 章的配置合并逻辑是同一个模式——提供完善的默认值,只要求用户指定差异。
运行时的主题管理采用模块级全局单例模式,惰性初始化——_active_skin 初始为 None,get_active_skin() 首次被调用时才执行 load_skin()。set_active_skin() 在用户执行 /skin ares 时被调用,立即更新全局状态。但单纯切换数据还不够——prompt_toolkit 的 TUI 需要实时刷新样式。get_prompt_toolkit_style_overrides() 解决了这个问题:它从当前主题派生出 20 多个 prompt_toolkit 样式覆盖,包括输入区域颜色、补全菜单背景、审批框边框、sudo 提示颜色等。切换主题后,CLI 立即将这些覆盖应用到 TUI Application 上,用户无需重启就能看到新主题。
display.py 中的 diff 渲染也是主题感知的。_diff_ansi() 函数从当前主题解析颜色,将 hex 值转换为 ANSI 24-bit 颜色转义码,并缓存结果。当主题切换时,reset_diff_colors() 清除缓存,确保下一次 diff 渲染使用新主题的颜色。
24.4 KawaiiSpinner 与工具预览
KawaiiSpinner 是 CLI 在等待 API 响应或工具执行时的视觉反馈组件。它的设计看似简单——一个旋转的 Unicode 动画——但实际上要处理多种输出环境的适配。
Spinner 内置了 5 套动画帧集(dots/bounce/grow/star/brain),以及两组颜文字库——KAWAII_WAITING(如 (。◕‿◕。))和 KAWAII_THINKING(如 (。•́︿•̀。)),搭配 THINKING_VERBS(“pondering”、“contemplating”、“musing” 等)。这些元素按主题配置组合,构成了每个主题独特的等待体验。
Spinner 运行在一个独立的 daemon 线程上,每 120 毫秒更新一帧。构造函数中有一个关键细节——它在创建时捕获 sys.stdout 的引用:
# agent/display.py:623-624self._out = sys.stdout # Capture stdout NOW, before any redirect这是因为子 agent 后来可能用 redirect_stdout(devnull) 替换 sys.stdout,如果 spinner 在那之后才读 sys.stdout,它会写到一个黑洞里。提前捕获确保 spinner 总能写到真实的终端。
_animate() 方法包含三个不同的代码路径:
路径一:非 TTY(Docker、systemd、管道)——跳过全部动画,只打印一行 [tool] message,避免日志噪声。
路径二:StdoutProxy 下(prompt_toolkit 的 patch_stdout 激活时)——完全静默,因为 CLI 有专用的 TUI widget 来显示 spinner 状态。
路径三:真实 TTY——使用 \r 回车符覆写当前行,从当前主题获取装饰翼(skin.get_spinner_wings()),将动画帧、消息文本、装饰翼和已用时间组合成一行,如 ⟪⚔ ⠹ forging ⚔⟫ (2.3s),每 120ms 覆写一次。在 ares 主题下,spinner 会显示剑翼和锻造动词,而在 poseidon 主题下是波浪翼和航海动词。
工具预览系统是 spinner 的补充。build_tool_preview() 为每个工具提取”主要参数”作为预览文本——terminal 提取 command,web_search 提取 query,read_file/write_file/patch 提取 path,browser_navigate 提取 url,delegate_task 提取 goal,共覆盖 12 种工具。这个硬编码映射确保用户在等待时就能看到 spinner 上方显示的操作对象。
当工具完成执行时,get_cute_tool_message() 生成格式化的完成信息,替换掉 spinner。每个工具都有专属的 emoji 和动词——🔍 search、💻 $、📖 read、✍️ write、🔧 patch。路径使用尾部截断(...path/to/file),文本使用头部截断(text...)。失败的工具调用获得信息后缀(如 [exit 1] 或 [error]),通过 _detect_tool_failure() 检测。
内联 diff 预览是另一个精巧的显示功能。当 write_file 或 patch 成功执行后,系统自动显示彩色 unified diff。这通过 LocalEditSnapshot 实现——在工具执行前通过 capture_local_edit_snapshot() 捕获文件内容快照,执行后与当前内容比较。_summarize_rendered_diff_sections() 还实现了智能截断——最多显示 6 个文件、80 行 diff,超出部分显示 … omitted N diff line(s) across M additional file(s)。
24.5 CLI 子命令系统
hermes_cli/main.py 是整个 CLI 的入口分发器——它将 hermes chat、hermes gateway start、hermes setup、hermes doctor 等子命令路由到各自的处理模块。
但在任何 argparse 处理之前,有一个必须优先执行的步骤——Profile 覆盖。_apply_profile_override() 手动解析 sys.argv 查找 --profile/-p 参数,如果没找到则检查 ~/.hermes/active_profile 粘滞文件。一旦确定了非默认 Profile,它立即设置 HERMES_HOME 环境变量指向 ~/.hermes/profiles/<name>/。
这个函数必须在任何 Hermes 模块被 import 之前运行,因为很多模块在模块级别就缓存了 HERMES_HOME 的值(我们在第 25 章详细分析 Profile 系统)。如果先 import 模块再设置 HERMES_HOME,那些模块就会使用错误的路径。
子命令涵盖了 Hermes 的整个操作面——chat(默认,启动交互式 CLI)、gateway(管理 Gateway 服务)、setup(交互式设置向导)、config(配置管理)、model(模型选择 TUI)、tools(工具管理 TUI)、doctor(诊断检查)、profile(Profile 管理)、sessions(会话浏览器)、skills(Skill Hub)、cron(定时任务管理)等。
某些交互式命令有一个 TTY 检查守卫——_require_tty() 在命令分发前检查 sys.stdin.isatty(),如果是管道或非交互式环境则打印错误并 sys.exit(1)。这防止了需要 curses TUI 的命令在管道中被调用——它们会因为没有 TTY 而 100% CPU 空转。
main.py 还支持 NixOS 容器模式。当 ~/.hermes/.container-mode 文件存在时,宿主机上的 hermes 命令会透明地 exec 进入 Docker/Podman 容器内部运行。这在第 25 章的 managed mode 讨论中有更详细的分析。
速查表
| 文件 | 角色 | 关键组件 |
|---|---|---|
cli.py | HermesCLI 主逻辑 | prompt_toolkit Application, HSplit 布局, patch_stdout 桥接 |
hermes_cli/commands.py | Slash 命令注册表 | COMMAND_REGISTRY, CommandDef, SlashCommandCompleter, SlashCommandAutoSuggest |
hermes_cli/skin_engine.py | 主题引擎 | SkinConfig, 7 个内置主题, YAML 用户主题, 默认继承, get_prompt_toolkit_style_overrides |
agent/display.py | 显示组件 | KawaiiSpinner (3 路径适配), build_tool_preview, get_cute_tool_message, LocalEditSnapshot diff |
hermes_cli/main.py | CLI 入口与子命令 | _apply_profile_override (pre-import), argparse 子命令, _require_tty 守卫, 容器模式透传 |