ui.render 事件,你的该事件钩子返回在那里绘制的内容。
此地图显示 mod 可以在终端会话中的绘制位置:
要查找一个属性或限制,请参阅参考。
构建带有选项卡的窗格
在本部分中,你将构建一个 mod,该 mod 添加/hello-tabs 命令,该命令打开一个窗格。窗格是在宽全屏终端中记录旁边的侧边栏,或在其他情况下是提示符上方的框架区域。此窗格显示两个选项卡,第二个选项卡有一个按钮,可以将计数器加一。重新启动 Claude Code 后,计数仍然存在。
完成的 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 填充它们,窗格和条带。选择一个选项卡以查看每个是什么以及如何在其中绘制:
- Pane
- Band above the prompt
窗格是在宽全屏终端中记录旁边的侧边栏,或在其他情况下是提示符上方的框架区域。打开多个窗格时,每个窗格都会获得一个显示其标题的选项卡。当你的 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 中所示。
- Change a detail
- Replace the drawing
- Leave it alone
要保留 Claude Code 的绘制并更改其一部分,请将 加载指示器保留其动画和单词,你的文本跟在单词后面:
next 传递给更改了 props 的事件副本。此钩子更改加载指示器单词后的文本: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
- Box
- Input
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
- 用户点击它
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 行添加到它:
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之间,它仍然会丢失。
后续步骤
- 对事件做出反应:从工具调用和轮次提供你的绘制
- 使用 mod API:从计时器和模型调用提供你的绘制
- 测试绘制:从测试按下你的按钮,在多个表面上
- 渲染站点和元素:每个站点的属性和每个元素的属性