Claude Code 插件开发:自定义界面绘制与状态管理
Draw in the interface with a mod
Claude Code 插件开发的一手实操指南,完整展示了从配置到 UI 渲染、状态持久化的可复用代码流程,开发者可直接照做。
Draw panes, a band above the prompt, buttons, and text fields from a Claude Code mod, handle presses and input, and keep state between redraws and sessions.
从 Claude Code 模块中绘制窗格、提示符上方的栏、按钮和文本字段,处理按键和输入,并在重绘和会话之间保持状态。
A mod can draw its own interface in Claude Code and change parts of the interface Claude Code already draws. Each place a mod can draw is called a render site, such as a pane, the band above the prompt, or the spinner. Claude Code raises the `ui.render` event each time it's about to draw a render site, and your hook for that event returns what to draw there.
模块可以在 Claude Code 中绘制自己的界面,并更改 Claude Code 已绘制的界面部分。模块可以绘制的每个位置称为渲染站点(render site),例如窗格、提示符上方的栏或旋转加载指示器。Claude Code 在即将绘制某个渲染站点时会触发 `ui.render` 事件,你为该事件注册的钩子函数返回该处应绘制的内容。
This map shows where a mod can draw in a terminal session:
此图展示了模块在终端会话中可以绘制的位置:
In a narrower terminal, the pane sits above the prompt instead of beside the transcript.
在较窄的终端窗口中,窗格位于提示符上方,而不是位于对话记录旁边。
Build your first mod before you start here. Begin with the worked example, which builds a pane with two tabs and a counter, then read the section for each piece you want to change.
在此开始之前,请先构建你的第一个模块。从示例教程开始,该教程构建了一个包含两个选项卡和一个计数器的窗格,然后阅读你想修改的各部分的章节。
To look up one prop or limit, see the reference.
要查找某个属性或限制,请参阅参考文档。
Build a pane with tabs
构建带选项卡的窗格
In this section you build a mod that adds a `/hello-tabs` command, and the command opens a pane. A pane is a sidebar beside the transcript in a wide fullscreen terminal, or a framed region above the prompt otherwise. This pane shows two tabs, and the second tab has a button that adds one to a counter. The count is still there after you restart Claude Code.
在本节中,你将构建一个添加 `/hello-tabs` 命令的模块,该命令会打开一个窗格。在宽屏全屏终端中,窗格是位于对话记录旁边的侧边栏;在其他情况下,它是位于提示符上方的带边框区域。此窗格显示两个选项卡,第二个选项卡包含一个将计数器加一的按钮。重启 Claude Code 后,计数值仍然存在。
The finished mod looks like this. The recording opens the pane, switches to the second tab, presses the button a few times, and returns to the first tab:
完成的模块如下所示。录制视频展示了打开窗格、切换到第二个选项卡、多次按下按钮,然后返回第一个选项卡的过程:
Claude Code has no built-in tabs element, so the tabs are two buttons in a row. The mod keeps track of which one is active and draws that tab's content under the row.
Claude Code 没有内置的选项卡元素,因此选项卡由一排两个按钮组成。模块会跟踪哪个选项卡处于活动状态,并在该行下方绘制该选项卡的内容。
A mod is a plugin with a manifest, a `hooks.json` that points to your code, and the code file. Create a mod explains each one. Create a directory named `hello-tabs` with `.claude-plugin` and `hooks` directories inside it, then save the first two files.
模块是一个带有清单(manifest)、指向你代码的 `hooks.json` 以及代码文件的插件。创建模块一文解释了每个部分。创建一个名为 `hello-tabs` 的目录,在其中包含 `.claude-plugin` 和 `hooks` 目录,然后保存前两个文件。
Save the manifest as `hello-tabs/.claude-plugin/plugin.json`:
将清单保存为 `hello-tabs/.claude-plugin/plugin.json`:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" }
}Name your entry point in `hello-tabs/hooks/hooks.json`:
在 `hello-tabs/hooks/hooks.json` 中指定入口点:
{
"modules": ["./register.js"]
}The code does three jobs, one in each hook:
代码执行三项任务,每项任务对应一个钩子:
* Adds the `/hello-tabs` command * Opens the pane when you run that command * Draws the pane's content: the row of tabs and the open tab's body
* 添加 `/hello-tabs` 命令 * 运行该命令时打开窗格 * 绘制窗格内容:选项卡行和活动选项卡的内容体
Two module-level variables, `tab` and `count`, hold the pane's state.
两个模块级变量 `tab` 和 `count` 用于保存窗格的状态。
Save this as `hello-tabs/hooks/register.js`:
将此文件保存为 `hello-tabs/hooks/register.js`:
// The pane's id, used to open the pane and to recognize it when drawing
const PANE = 'hello-tabs'
// What the pane shows: which tab is open, and the counter's value
let tab = 'one'
let count = 0
export function register(on) {
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Load the count an earlier session saved, if there is one
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Runs when you type /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Open the pane, give it the keyboard, and let Esc close it
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Print nothing in the transcript
return {}
})
// Runs each time Claude Code draws a pane
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Leave other mods' panes alone
if (e.requestId !== PANE) return next(e)
// Get the elements this app can draw
const { Box, Text, Button } = $.ui.resolve(e)
// Ask Claude Code to run this hook again
const redraw = () => $.ui.invalidate('ui.render')
// One tab: a button that switches to its tab when pressed
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Dim the tab that isn't open
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// What goes under the tabs, depending on which one is open
const body =
tab === 'one'
? [Text({ children: ['This is the first tab.'] })]
: [
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
hotkey: 'a',
onPress: async () => {
count += 1
redraw()
// Save the count so it's there after a restart
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// The whole pane: the row of tabs, a blank line, then the body
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}Each hook also does something the code doesn't make plain:
每个钩子还执行了一些代码未明确说明的操作:
* **`session.start`** also reads the saved count from `$.store`, a key-value store that persists between sessions. * **`command.run`** only tells Claude Code the pane exists. Opening a pane draws nothing by itself: Claude Code then raises `ui.render` to ask what goes in it. * **`ui.render`** returns the element tree, a `Box` that holds other boxes, text, and buttons, and builds it again from `tab` and `count` each time it runs.
* **`session.start`** 还会从 `$.store`(一个在会话间持久化的键值存储)中读取保存的计数。 * **`command.run`** 仅告知 Claude Code 该窗格存在。打开窗格本身不会绘制任何内容:Claude Code 随后会触发 `ui.render`,询问其中应放置什么内容。 * **`ui.render`** 返回元素树,这是一个包含其他框、文本和按钮的 `Box`,并在每次运行时根据 `tab` 和 `count` 重新构建它。
Pressing a button runs its `onPress` callback, which changes a variable and calls `redraw`. Claude Code then runs the `ui.render` hook again, and the hook builds a new tree from the new values. Every interactive drawing uses that render cycle: a callback changes state, and the hook renders again from the new state.
按下按钮会运行其 `onPress` 回调,这会修改变量并调用 `redraw`。随后 Claude Code 再次运行 `ui.render` 钩子,钩子会根据新值构建新的树。所有交互式绘图都使用此渲染周期:回调更改状态,钩子根据新状态再次进行渲染。
In your shell, start Claude Code with `claude --plugin-dir ./hello-tabs`. At the Claude Code prompt, run `/hello-tabs`. A pane opens with `1: One` and `2: Two` across the top. Press `2`, then press `a`, the hotkey for Add one, a few times. The count rises.
在你的 shell 中,使用 `claude --plugin-dir ./hello-tabs` 启动 Claude Code。在 Claude Code 提示符下,运行 `/hello-tabs`。顶部会出现一个窗格,显示 `1: One` 和 `2: Two`。按 `2`,然后按几次 `a`(Add one 的快捷键)。计数会增加。
Press Esc to close the pane, then exit the session. In your shell, start Claude Code again with the same `claude --plugin-dir ./hello-tabs` command, and at the Claude Code prompt run `/hello-tabs`. The count is where you left it.
按 Esc 关闭窗格,然后退出会话。在你的 shell 中,使用相同的 `claude --plugin-dir ./hello-tabs` 命令再次启动 Claude Code,并在 Claude Code 提示符下运行 `/hello-tabs`。计数保留在你离开时的位置。
To clear the count, have the mod call `$.store.delete('count')`. Keep state covers how long each kind of value lasts.
要清除计数,让 mod 调用 `$.store.delete('count')`。Keep state 说明了每种类型的值持续多长时间。
Pick where to draw
选择绘制位置
A `ui.render` hook runs for every render site unless you narrow it to the one you want to draw in. To choose the render site, pass a filter, called a matcher, as the second argument to `on`. `{ component: 'Pane' }` runs the hook only for panes. In the hook, `e.component` names the site, `e.surface` says which app is drawing, and `e.props` holds the site's own data. For a pane, `e.requestId` is the `id` you opened it with.
除非你将范围缩小到你想要绘制的特定位置,否则 `ui.render` 钩子会在每个渲染站点运行。要选择渲染站点,请将一个称为匹配器(matcher)的过滤器作为第二个参数传递给 `on`。`{ component: 'Pane' }` 使钩子仅在窗格上运行。在钩子中,`e.component` 命名站点,`e.surface` 说明是哪个应用在进行绘制,而 `e.props` 持有站点自身的数据。对于窗格,`e.requestId` 是你打开它时使用的 `id`。
Two sites are empty until a mod fills them, the pane and the band. Select a tab to see what each one is and how to draw in it:
有两个站点在 mod 填充之前是空的,即窗格(pane)和栏(band)。选择一个标签页以查看它们各自是什么以及如何在其上绘制:
A pane is a sidebar beside the transcript in a wide fullscreen terminal, or a framed region above the prompt otherwise. With several panes open, each gets a tab that shows its title.
窗格是在宽屏全屏终端中转录区旁边的侧边栏,或者在其他情况下是提示符上方的带边框区域。打开多个窗格时,每个窗格都会获得一个显示其标题的标签页。
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力