Skip to main content
mod 可以在 Claude Code 中绘制自己的界面,并更改 Claude Code 已经绘制的界面部分。mod 可以绘制的每个位置称为渲染站点,例如窗格、提示符上方的条带或加载指示器。Claude Code 在即将绘制渲染站点时会触发 ui.render 事件,你的该事件钩子返回在那里绘制的内容。 此地图显示 mod 可以在终端会话中的绘制位置: Claude Code 终端会话的地图。mod 可以在右侧添加窗格作为侧边栏,在记录的右上角添加 toast,在记录中添加日志行,在提示符上方添加条带,以及在提示符下方添加状态行。mod 可以重绘消息、工具调用行和加载指示器。提示符是 Claude Code 自己的。 Claude Code 终端会话的地图。mod 可以在右侧添加窗格作为侧边栏,在记录的右上角添加 toast,在记录中添加日志行,在提示符上方添加条带,以及在提示符下方添加状态行。mod 可以重绘消息、工具调用行和加载指示器。提示符是 Claude Code 自己的。 在较窄的终端中,窗格位于提示符上方而不是记录旁边。 在开始之前,请构建你的第一个 mod。从工作示例开始,该示例构建一个具有两个选项卡和计数器的窗格,然后阅读你想要更改的每个部分的部分。
要查找一个属性或限制,请参阅参考。

构建带有选项卡的窗格

在本部分中,你将构建一个 mod,该 mod 添加 /hello-tabs 命令,该命令打开一个窗格。窗格是在宽全屏终端中记录旁边的侧边栏,或在其他情况下是提示符上方的框架区域。此窗格显示两个选项卡,第二个选项卡有一个按钮,可以将计数器加一。重新启动 Claude Code 后,计数仍然存在。 完成的 mod 看起来像这样。录制打开窗格,切换到第二个选项卡,按几次按钮,然后返回到第一个选项卡:
Claude Code 没有内置的 tabs 元素,所以选项卡是一行中的两个按钮。mod 跟踪哪一个是活动的,并在该行下方绘制该选项卡的内容。
1

创建插件

mod 是一个具有清单、指向你的代码的 hooks.json 和代码文件的插件。创建 mod 解释了每一个。创建一个名为 hello-tabs 的目录,其中包含 .claude-plugin 和 hooks 目录,然后保存前两个文件。将清单保存为 hello-tabs/.claude-plugin/plugin.json:
hello-tabs/.claude-plugin/plugin.json
在 hello-tabs/hooks/hooks.json 中命名你的入口点:
hello-tabs/hooks/hooks.json
2

编写代码

代码执行三个任务,每个钩子一个:
  • 添加 /hello-tabs 命令
  • 运行该命令时打开窗格
  • 绘制窗格的内容:选项卡行和打开的选项卡的主体
两个模块级变量 tab 和 count 保存窗格的状态。将其保存为 hello-tabs/hooks/register.js:
hello-tabs/hooks/register.js
每个钩子也做代码没有明确说明的事情:
  • session.start 也从 $.store 读取保存的计数,这是一个在会话之间持久化的键值存储。
  • command.run 只告诉 Claude Code 窗格存在。打开窗格本身不绘制任何内容:Claude Code 然后触发 ui.render 来询问在其中放入什么。
  • ui.render 返回元素树,一个 Box,它保存其他框、文本和按钮,并从 tab 和 count 每次运行时重新构建它。
按下按钮会运行其 onPress 回调,该回调更改变量并调用 redraw。Claude Code 然后再次运行 ui.render 钩子,该钩子从新值构建新树。每个交互式绘制都使用该渲染周期:回调更改状态,钩子从新状态重新渲染。
3

打开窗格

在你的 shell 中,使用 claude --plugin-dir ./hello-tabs 启动 Claude Code。在 Claude Code 提示符处,运行 /hello-tabs。一个窗格打开,顶部显示 1: One 和 2: Two。按 2,然后按 a,Add one 的快捷键,几次。计数上升。
4

检查计数是否已保存

按 Esc 关闭窗格,然后退出会话。在你的 shell 中,使用相同的 claude --plugin-dir ./hello-tabs 命令再次启动 Claude Code,在 Claude Code 提示符处运行 /hello-tabs。计数在你离开的地方。要清除计数,让 mod 调用 $.store.delete('count')。保持状态 涵盖每种值持续多长时间。

选择绘制位置

ui.render 钩子为每个渲染站点运行,除非你将其缩小到你想要绘制的站点。要选择渲染站点,请将称为匹配器的过滤器作为第二个参数传递给 on。{ component: 'Pane' } 仅为窗格运行钩子。在钩子中,e.component 命名站点,e.surface 说明哪个应用在绘制,e.props 保存站点自己的数据。对于窗格,e.requestId 是你用来打开它的 id。 两个站点是空的,直到 mod 填充它们,窗格和条带。选择一个选项卡以查看每个是什么以及如何在其中绘制:
窗格是在宽全屏终端中记录旁边的侧边栏,或在其他情况下是提示符上方的框架区域。打开多个窗格时,每个窗格都会获得一个显示其标题的选项卡。当你的 mod 使用你选择的 id 调用 $.ui.open 时,窗格出现,如 $.ui.open({ id: 'hello-tabs' })。在正确的时间打开窗格 涵盖其他字段以及窗格何时等待更宽的终端。要在你的窗格中绘制,请过滤 { component: 'Pane' } 并检查 e.requestId 是否是你的 id。

更改 Claude Code 已经绘制的内容

Claude Code 自己绘制大部分界面:消息、工具调用行、加载指示器等。这些部分中的每一个也是一个渲染站点,所以 mod 可以重新设置样式或替换它。要更改一个,请在你的 ui.render 钩子上过滤此表中的其名称: 在 Claude Code 已经绘制的站点,你的钩子有三个选择:更改详细信息、替换绘制或不理它。选择一个选项卡以查看每一个应用于加载指示器。示例读取另一个钩子计数的 calls 变量,如教程 mod 中所示。
要保留 Claude Code 的绘制并更改其一部分,请将 next 传递给更改了 props 的事件副本。此钩子更改加载指示器单词后的文本:
加载指示器保留其动画和单词,你的文本跟在单词后面:
权限提示不是渲染站点,所以 mod 无法更改它显示的内容。问题对话框 AskUserQuestion 是一个,所以 mod 可以更改它。 终端和桌面应用不会触发所有相同的站点。Pane、AbovePrompt、Spinner 和记录站点在两者中都有效。其他一些状态行仅在终端中触发。渲染站点表 列出了每个站点在哪里触发。

在正确的时间打开窗格

窗格仅在你的 mod 打开它时出现。你如何以及何时打开它决定了它是否获得键盘焦点、它要求多少空间,以及它是否在狭窄的终端中显示。 要打开窗格,请使用你选择的 id 调用 $.ui.open。id 是窗格的名称:你的 ui.render 钩子检查它,你再次传递它来关闭窗格。
要关闭窗格,请使用你打开它的 id 调用 $.ui.close:
除了 id,$.ui.open 还接受这些可选字段: 要让命令在 Claude 工作时打开窗格,请在注册命令时添加 immediate: true。没有它,在轮次期间键入的命令会等待轮次结束。

当窗格等待更宽的终端时

你的 mod 打开的窗格而不被要求不会在狭窄的终端中出现,所以它无法接管小屏幕。它是否出现取决于打开它的内容:
  • 由用户做的事情打开,例如他们运行的命令或他们按下的按钮,窗格在任何宽度出现
  • 由你的 mod 自己打开,例如从计时器或 turn.start 钩子,窗格仅在至少 144 列宽的终端中出现。用户自己打开该窗格一次后,110 列就足够了。
当窗格出现时,$.ui.open 解析为 { isPlaced: true }。当窗格在等待时,isPlaced 是 false,reason 是一个说明原因的字符串。等待的窗格在用户打开它或拓宽终端时出现。要说某些内容可用而不打开窗格,请调用 $.ui.toast('Your message'),它显示一个在几秒后消失的小通知。

从元素构建树

ui.render 钩子返回的是一个元素树:对要绘制的内容的描述,由相互嵌套的框、文本和控件组成。你描述绘制,Claude Code 在终端或桌面应用中呈现它。 要获取元素,请在你的钩子中调用 $.ui.resolve(e),如 const { Box, Text, Button } = $.ui.resolve(e)。每个元素都是一个函数。你传递它属性,你把在其中的元素和字符串放在 children 中。 大多数绘制使用四个元素。选择一个选项卡以查看每一个以及终端如何绘制它:
Text 绘制一个字符串,带有可选的样式,如 bold 和 color:
此表列出了每个元素: 如果你的模块是 .tsx 或 .jsx 文件,你可以将树写成 JSX。首先从 $.ui.resolve(e) 解构元素,因为钩子模块没有元素全局。 如果树使用应用没有的元素、元素不接受的属性或没有子项的位置,Claude Code 绘制其自己的站点版本。 在使用 --plugin-dir 启动的会话中,记录行说明这一点,例如 ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own。调试日志 将其记录为 ui.render (Pane): a hook returned a tree that does not validate 并带有相同的原因。会话中没有其他内容出现,所以当绘制不显示时,检查该行或日志。

绘制彩色单元格网格

对于热力图、迷你图或终端中的游戏板,绘制一个 Raster 而不是每个单元格的 Box。Raster 接受 key、其大小(以 columns 和 rows 为单位)和 cells,它将每个单元格打包到一个字符串中。每个单元格是三个数字:字符的代码点、其颜色和其背景颜色。颜色是十六进制数字,红、绿、蓝各两位,例如 0xc62828 表示红色,或 0x01000000 表示终端的默认值。 桌面应用没有 Raster,所以检查 e.surface 并在那里绘制文本。此窗格主体绘制一个三乘二的热力图:
在终端中,窗格显示网格: 终端中的一个窗格,包含一个小的彩色块网格,两行三列。顶行是绿色、琥珀色和红色。底行是绿色、绿色和琥珀色。 rows 数组是你要更改的部分,cellsOf 将其转换为打包的字符串。钩子仅在 id 为 heat 的窗格中绘制,所以从命令中使用 $.ui.open({ id: 'heat' }) 打开一个,如 hello-tabs 示例 打开其窗格。 每个字符必须是一个单元格宽。要动画化已经在屏幕上的 Raster,请使用窗格的 id 作为 requestId、Raster 的 key、相同的大小和新单元格调用 $.ui.blit。对于此示例,这是 $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) })。它重新绘制该一个元素而不再次运行你的 ui.render 钩子。

响应按键和输入

当用户按下你绘制的按钮、输入字段或从列表中选择时,Claude Code 调用你给该控件的函数,它在你的模块中运行。每个控件接受其自己的回调:
  • Button:接受 onPress(e),其中 e.surface 是按键来自的应用
  • Input:接受 onSubmit(value) 和 onInput(value)
  • Select:接受 onSelect(value),其选择在 options 中,至少一个选择的列表,具有唯一值,例如 [{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]
测试通过其 key 按下或输入到控件中,所以给每个控件一个。控件的每次使用也会触发 ui.press、ui.input 或 ui.select,其中 key 在 e.element 中,另一个 mod 可以钩住这些事件。其钩子在你的回调之前运行,所以它看到用户输入到你的 Input 中的内容,可以更改它或代替你的回调回答。mod API 没有按下另一个 mod 的按钮的方法。

键盘焦点和快捷键

你的 mod 永远不会自己读取键盘。用户按下一个键,Claude Code 决定它是为你的哪个控件,该控件的回调运行。除了条带上的数字快捷键,这仅在你的窗格或条带有键盘焦点时发生。其余时间,键进入提示符。

窗格如何获得键盘焦点

窗格通过以下三种方式之一获得键盘焦点:
  • 你的 mod 从命令或按键使用 focus: true 打开它
  • 用户按 Ctrl+X 然后 Tab
  • 用户点击它
Claude Code 仅在提示符为空且没有其他内容有键盘焦点时授予 focus: true。在用户输入时打开的窗格不会获取他们的按键。

每个键做什么

此表列出了当你的窗格或条带有键盘焦点时每个键做什么: mod 无法将 Tab 或箭头键绑定到其他任何东西,所以游戏用 w、a、s 和 d 操舵。

设置快捷键和第一个焦点

控件上的两个属性决定了键盘如何到达它:
  • hotkey:要让用户用一个键按下 Button,给它一个 hotkey,一个数字或一个小写字母,如 hotkey: 'a'
  • autoFocus:要选择窗格打开时哪个控件有焦点,向它添加 autoFocus: true。在其他上省略属性,因为 Claude Code 拒绝 autoFocus: false。
快捷键的显示方式取决于按钮和应用: 在终端中,在括号按钮的标签中命名键,或使用 plain: true,所以用户可以看到要按什么。元素参考 有其他 Button 规则:action、条带上的数字快捷键和一个快捷键上的两个按钮。

获取输入的文本并为每个项目绘制一行

许多窗格是一个文本字段,下面有一个列表。本部分中的示例是一个笔记窗格:你输入一个笔记并按 Enter 添加它,每个笔记都有一个删除它的 x 按钮。添加两个笔记后,终端这样绘制窗格:
示例使用两种技术:
  • 获取输入的文本:当用户按 Enter 时,Input 使用字段的文本调用 onSubmit(value),在每次更改时调用 onInput(value)
  • 绘制列表:将你的数据映射到每个一行,并给每行的按钮其自己的 key
此钩子绘制窗格的内容:
要尝试窗格:
  • 添加笔记:输入一行并按 Enter。该行显示为新行,字段清空。
  • 删除笔记:按 Tab 直到笔记的 x 按钮有焦点,然后按 Enter。x 是按钮的标签,不是快捷键,所以输入字母不会按下它。
每个更改遵循与 hello-tabs 相同的渲染周期:回调更改 notes,调用 redraw,并将列表保存到 $.store。 字段在每次提交后清空,因为其 value 属性。value 是绘制字段时保存的文本,用户的输入替换它,直到你的钩子再次绘制字段。示例总是用 '' 绘制字段。 示例保存笔记而不加载它们。要在下一个会话中将它们带回,请在 session.start 钩子中读取它们,就像 hello-tabs 读取 count 的方式一样。 三个属性组成字段的行,Note: Type a note and press Enter ⏎ add: 提交 Input 不会启动轮次,除非你的回调调用 $.prompt.submit。

重绘站点

绘制是一个快照:它显示你的 ui.render 钩子上次运行时返回的内容。要显示新内容,钩子必须再次运行。Claude Code 为某些更改再次运行它,你的 mod 要求其余的。

当 Claude Code 在不被要求时重绘

当站点的属性更改或终端的宽度更改时,Claude Code 再次运行你的 ui.render 钩子。它不在计时器上运行钩子,也无法判断你的模块中的变量何时更改。

当你的数据更改时重绘

要在你自己的数据更改后再次绘制你的站点,请调用 $.ui.invalidate('ui.render')。此窗格计数按键。按钮的回调更改 count,然后要求重绘:
每次按键都会提高窗格中的数字。hello-tabs 示例 将相同的调用包装在其 redraw 函数中。 你在 $.state 中保存的值不需要调用,因为写入值会重绘读取它的站点。

在计时器上重绘

要保持时钟、倒计时或来自会话外部的值最新,请按计划重绘。在模块的 session.start 钩子中启动计时器。如果模块已经有一个,如 hello-tabs 所做的,请将 $.clock.every 行添加到它:
Claude Code 现在每秒运行你的 ui.render 钩子一次。当模块重新加载时计时器停止,新副本启动其自己的。

站点可以重绘的频率

Claude Code 限制重绘的频率,所以你的 mod 可以在其数据更改时调用 $.ui.invalidate。可见窗格和条带的限制比其他站点更高,限制表 中有具体数字。 比限制更快的调用被合并为一次重绘。该重绘运行你的钩子一次,钩子读取你的数据,因为它在那一刻的样子,所以最新值显示,中间的值不显示。动画无法比限制运行得更快。

保持状态

mod 有三个地方可以保存值,它们在值持续多长时间方面有所不同:直到模块重新加载、直到会话结束或从一个会话到下一个会话。根据值必须持续多长时间选择: $.store.get(key) 解析为值或 undefined,$.store.set(key, value) 接受任何 JSON 值。

在 $.state 中保存值

$.state 为会话的长度保存值,并为你重绘。它是反应式状态:读取值的 ui.render 钩子订阅它,所以 Claude Code 每次你写入值时重绘该站点,你不调用 $.ui.invalidate。$.state 中的值也在模块重新加载后存活,变量不会。 要设置它,声明你的值,将你的清单指向声明,然后定义和使用每个值。示例将 count 从 hello-tabs 移到 $.state。

声明值

在类型文件中声明值。外键是你的插件的名称,其下的每个条目是一个值及其类型。将其保存为 hello-tabs/types/index.d.ts:
hello-tabs/types/index.d.ts

将清单指向声明

要让 claude plugin validate 根据该文件检查你的代码,请向清单添加 types 字段及其路径:
hello-tabs/.claude-plugin/plugin.json

定义、读取和写入值

在你的模块中,定义每个值及其默认值,在绘制时读取它,并从回调中写入它。atom 命名值及其默认值,read 返回它,update 写入它。三个帮助程序为你调用 $.state.get 和 $.state.set:
因为 ui.render 钩子读取了 count,Claude Code 每次按钮写入它时再次运行钩子。 三个规则适用于代码:
  • 将 plugin 和 key 写成字面字符串:claude plugin validate 从你的源代码中读取它们
  • 在类型文件中声明每个值:否则验证失败,出现 hello-tabs.count is not declared
  • 从回调或另一个事件的钩子中写入:ui.render 钩子可以读取状态,不能写入它,所以从 onPress、onSubmit 或另一个事件的钩子中写入

更改 hello-tabs 以使用 $.state

要将 hello-tabs 中的 count 移到 $.state,请更改使用它的每一行:
  • 在模块顶部:添加 import 行,并用 atom 行替换 let count = 0
  • 在 ui.render 钩子中:在 tabButton 之前添加 read 行,并在 Text 中绘制 'Count: ' + n
  • 在 Add one 按钮中:用从多个会话保存中的按钮替换 onPress,它保存计数以及写入它
  • 在 session.start 钩子中:用在 /clear 后再次加载保存的值中的 loadCount 调用替换读取 saved 的两行
为选项卡按钮保留 redraw,因为 tab 仍然是一个变量。

在 /clear 后再次加载保存的值

如果你的 mod 在 session.start 时将保存的值从 $.store 复制到 $.state,它必须在 /clear、/resume 或 /branch 后再次复制。这些命令将每个 $.state 值放回其默认值,session.start 不再触发。classic.SessionStart 在每个之后触发,e.source 设置为 clear、resume 或 fork,所以在其上的钩子中再次复制值。否则你的绘制显示默认值,保存 $.state 值的回调将默认值写入你存储的内容。 此代码从两个钩子加载 count。它基于 hello-tabs 的 $.state 版本,其中 count 是原子,update 被导入。将 loadCount 放在 register 上方,并将 loadCount 调用添加到你已经拥有的 session.start 钩子。classic.SessionStart 也在启动和压缩后触发,这不会重置 $.state,所以对 source 的过滤将钩子保留到三个重置:
两个钩子就位后,窗格在 /clear 后显示保存的计数,而不是 0,Add one 的下一次按键添加到保存的计数。 loadCount 将存储的值写入 $.state 中的值,session.start 每次模块重新加载时再次触发。要保持存储不落后,请在每次更改时保存,如 Add one 按钮所做的。 要在不会话的情况下检查重新加载,请在 /clear 后测试绘制。

从多个会话保存

你的机器上运行你的 mod 的每个会话共享一个 $.store。get 后跟 set 不是原子的。当两个会话各自读取值、更改它并写回时,它们竞争,第二次写入替换第一次。 两个选择使这种情况不太可能:
  • 给每个项目其自己的键:set 仅更改其自己的键,所以写入不同键的会话不会相互覆盖
  • 在写入前再次读取:对于多个会话更改的值,在回调中 get 键,并从该值构建新值,而不是从你在 session.start 加载的副本。如果另一个会话的写入落在你的 get 和 set 之间,它仍然会丢失。
此按钮将一个添加到存储现在保存的任何内容,然后更新绘制:
如果第二个会话自此会话启动以来按下了其自己的按钮三次,此按键显示并保存包括这三个的计数。

后续步骤