ClaudeClaude Code系列第 14 / 15 篇
Claude Code mod 的界面元素一览:十种元素在终端里长什么样、怎么写、键盘怎么操作
mod 画界面能用的元素放在一页:文字、Markdown、链接、代码和改动、排版用的 Box、按钮、输入框、下拉、色块。每一种给出代码和它在真实终端里画出来的样子。本站把它们做成一个带页签的示例 mod 跑了一遍,顺带查清了输入框打不进字的原因:少了 autoFocus。
本站已实测,测试日期:2026-10-13。
这篇解决一个问题:mod 的窗格里能放哪些东西,每一种写出来是什么样?
怎么开一个窗格、怎么重画,在《mod 进阶》的第八节。这一篇接着那一节,把官方元素一览页里的元素逐个画出来。下面每一段“画出来的样子”,都是从服务器上真实的终端会话里抓的文字。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| 版本 | Claude Code 2.1.296,Linux 服务器,tmux 里的终端 |
| 做法 | 一个带七个页签的示例 mod;claude plugin test 2 项;真实会话里用按键逐页操作 |
先说实测的结论:
- 七页都画出来了,页签、输入框、下拉、按钮在真实终端里都能用键盘操作。
- 输入框要加
autoFocus: true,才能在窗格一打开就直接打字。 上一篇里“打的字进了提示框”那一处,原因就是少了它,这次补测确认了。 - 抓下来的是纯文字,颜色、粗体、反色这些样式看不出来,文中会说明。
- 这一篇没有调用模型,没有花费。
一、一个能逐页看样例的 mod
官方的做法是做一个小 mod,每次把一个样例粘进去看。本站改成了带页签的:/gallery 打开窗格,按数字键换页,这样一次能看完。
文件开头是页签的清单和几个变量:
// ui-gallery:把常用的界面元素各画一遍。/gallery 打开窗格,按数字键换一页。
const PANE = 'gallery'
const PAGES = [
['text', '文字'],
['markdown', 'Markdown'],
['code', '代码'],
['diff', '改动'],
['box', '排版'],
['controls', '控件'],
['raster', '色块'],
]
let page = 'text' // 现在看的是哪一页
let title = '' // 输入框提交的内容
let picked = 'md' // 下拉里选中的值
let presses = 0 // 按钮按了几次
// Raster 要的是打包好的格子:每格三个数(字符、前景色、背景色)
const DEFAULT_COLOR = 0x01000000
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
register 部分和上一篇的窗格一样,命令负责打开,ui.render 负责画。页签是一排 plain 的按钮,数字键是它们的快捷键:
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'gallery', description: '打开界面元素的示例窗格' })
return next(e)
})
on('command.run', { command: 'gallery' }, async ($) => {
await $.ui.open({ id: PANE, title: '界面元素', focus: true, closeOnEscape: true })
return {}
})
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== PANE) return next(e)
const ui = $.ui.resolve(e)
const { Box, Text, Button } = ui
const redraw = () => $.ui.invalidate('ui.render')
// 顶上一排页签:plain 的按钮,数字键就是它的 hotkey
const tabs = PAGES.map(([name, label], i) =>
Button({
key: 'tab-' + name,
label,
hotkey: String(i + 1),
plain: true,
dimColor: page !== name,
onPress: () => {
page = name
redraw()
},
}),
)
return Box({
flexDirection: 'column',
children: [Box({ flexDirection: 'row', columnGap: 2, children: tabs }), Text({ children: [' '] }), sample(page, ui, redraw)],
})
})
}
后面每一节的代码,都是 sample() 里对应那一页返回的东西。元素从 $.ui.resolve(e) 里取。
二、显示文字
Text:自己定样式
Box({
flexDirection: 'column',
children: [
Text({ children: ['普通文字'] }),
Text({ bold: true, children: ['bold 加粗'] }),
Text({ italic: true, children: ['italic 斜体'] }),
Text({ underline: true, children: ['underline 下划线'] }),
Text({ strikethrough: true, children: ['strikethrough 删除线'] }),
Text({ dimColor: true, children: ['dimColor 变灰'] }),
Text({ inverse: true, children: ['inverse 反色'] }),
Text({ color: 'red', children: ["color: 'red'"] }),
Text({ backgroundColor: 'blue', children: ["backgroundColor: 'blue'"] }),
],
})
│ 1: 文字 2: Markdown 3: 代码 4: 改动 5: 排版 6: 控件 7: 色块
│
│ 普通文字
│ bold 加粗
│ italic 斜体
│ underline 下划线
│ strikethrough 删除线
│ dimColor 变灰
│ inverse 反色
│ color: 'red'
│ backgroundColor: 'blue'
九行都在,样式在纯文字里看不出来。文档补充了两点:dimColor 是把字画成灰色;backgroundColor 只填到文字那么宽,不会铺满一行。
Markdown 和 Link
内容本来就是 Markdown 的,用 Markdown,它按 Claude 回答的那套样式来排。注意内容放在 text 里,不是 children:
Box({
flexDirection: 'column',
gap: 1,
children: [
Markdown({ text: '## 发布说明\n\n这一版有**两处**修复和一个 `flag`:\n\n- 启动更快\n- 提示更少\n\n> 引用的一段话' }),
Link({ href: 'https://code.claude.com/docs', label: 'Claude Code 文档' }),
],
})
│ 发布说明
│
│ 这一版有两处修复和一个 flag:
│
│ - 启动更快
│ - 提示更少
│
│ ▎ 引用的一段话
│
│ Claude Code 文档
标题前面的 ## 和行内代码的反引号都不见了,变成了样式;引用的那一行左边多了一条竖线。
Link 这一行实测只看到了标签。文档说终端会把网址作为文字画在标签后面,能不能点开取决于用户的终端;本站抓到的文字里没有网址,原因没有查清。要保证用户看得到网址,把它直接写进文字里更稳妥。
三、显示代码和改动
Code 用 Claude Code 自己的语法配色来画源码。写明 language,或者给一个 path 让它自己猜;给了 startLine 就从那个数开始标行号:
Code({ language: 'javascript', startLine: 1, source: "const name = 'mods'\nconsole.log('hello ' + name)" })
│ 1 const name = 'mods'
│ 2 console.log('hello ' + name)
加上 format: 'diff',source 就是一段统一格式的 diff:
Code({ format: 'diff', source: '@@ -1,3 +1,3 @@\n # Mods\n-A mod is a plugin.\n+A mod is a plugin that runs code.\n Read on.' })
│ 1 # Mods
│ 2 -A mod is a plugin.
│ 2 +A mod is a plugin that runs code.
│ 3 Read on.
@@ 那一行被换成了行号。文档说,删掉的行和加上的行相近时,变了的那几个词会用更重的底色标出来。颜色跟着用户的主题走。
四、排版:Box
Box 把里面的东西排成一行或者一列,还能画边框:
Box({
flexDirection: 'column',
gap: 1,
children: [
Box({
flexDirection: 'row',
columnGap: 4,
children: [Text({ children: ['一行'] }), Text({ children: ['三个'] }), Text({ children: ['并排'] })],
}),
Box({ borderStyle: 'round', paddingX: 1, children: [Text({ children: ["borderStyle: 'round'"] })] }),
Box({ borderStyle: 'double', paddingX: 1, children: [Text({ children: ["borderStyle: 'double'"] })] }),
Box({ borderStyle: 'classic', paddingX: 1, children: [Text({ children: ["borderStyle: 'classic'"] })] }),
],
})
│ 一行 三个 并排
│
│ ╭──────────────────────────────────────────
│ │ borderStyle: 'round'
│ ╰──────────────────────────────────────────
│
│ ╔══════════════════════════════════════════
│ ║ borderStyle: 'double'
│ ╚══════════════════════════════════════════
│
│ +------------------------------------------
│ | borderStyle: 'classic'
边框会撑满窗格的宽度。borderStyle 能填的名字:
| 名字 | 画出来 |
|---|---|
single |
细线,直角 |
double |
双线 |
round |
细线,圆角 |
bold |
粗线 |
singleDouble、doubleSingle |
横竖一细一双 |
classic |
用 +、-、竖线这几个 ASCII 字符 |
arrow |
一圈朝里指的箭头 |
dashed |
虚线,四角留空 |
quote |
只在左边画一条竖线 |
名字写错不会报错,只是没有边框。 文档举的例子是写成 'rounded'。
Box 的其他常用属性:flexDirection、gap、columnGap、padding 和 paddingX、margin、width、height、backgroundColor。
五、控件:按钮、输入框、下拉
Box({
flexDirection: 'column',
children: [
Input({
key: 'title',
label: '标题',
placeholder: '打一个标题再回车',
value: '',
submitLabel: '保存',
// 窗格一打开,键盘就先给这个输入框
autoFocus: true,
onSubmit: (value) => {
title = value
redraw()
},
}),
Text({ children: ['已保存的标题:' + (title || '(空)')] }),
Select({
key: 'format',
label: '格式',
value: picked,
options: [
{ value: 'md', label: 'Markdown' },
{ value: 'html', label: 'HTML' },
{ value: 'txt', label: '纯文本' },
],
onSelect: (value) => {
picked = value
redraw()
},
}),
Text({ children: ['选中的格式:' + picked] }),
Button({
key: 'count',
label: '按我',
onPress: () => {
presses += 1
redraw()
},
}),
Button({
key: 'reset',
label: '清零',
hotkey: 'r',
plain: true,
onPress: () => {
presses = 0
redraw()
},
}),
Text({ children: ['按了 ' + presses + ' 次'] }),
],
})
窗格刚打开这一页时:
│ 标题: 打一个标题再回车 ⏎ 保存
│ 已保存的标题:(空)
│ 格式: Markdown ▾
│ 选中的格式:md
│ [ 按我 ]
│ r: 清零
│ 按了 0 次
能看出几件事:
- 输入框显示“标签: 占位文字”。它现在有焦点,所以后面跟着
⏎和submitLabel写的“保存”。 - 下拉合着的时候显示当前选项和一个
▾。 - 普通按钮带方括号,不显示快捷键;
plain: true的按钮显示成“快捷键: 标签”。在终端里想让用户知道按哪个键,要么用plain,要么把键名写进标签。
一路按下去
直接打 hello,字进了输入框,占位文字被替换:
│ 标题: hello ⏎ 保存
回车,onSubmit 被调用,输入框清空:
│ 标题: 打一个标题再回车 ⏎ 保存
│ 已保存的标题:hello
按 Tab,焦点到了下拉,它自己展开了:
│ 格式: Markdown ▴
│ Markdown
│ HTML
│ 纯文本
按一次下方向键,再回车,选中第二项,列表收起:
│ 格式: HTML ▾
│ 选中的格式:html
再按 Tab 到“按我”,回车两次:
│ 按了 2 次
按 r,“清零”的快捷键:
│ 按了 0 次
整个过程里,下面的提示框一个字都没有多出来。
键盘是怎么分配的
mod 不直接读键盘。用户按键,Claude Code 决定给哪个控件,再调那个控件的回调。
| 键 | 作用 |
|---|---|
| Tab | 下一个控件 |
| 上下方向键 | 内容放得下时在控件之间移动;下拉展开时在选项之间移动 |
| 回车 | 按下当前按钮、提交当前输入框、选中下拉里的当前项 |
按钮的 hotkey |
一位数字或一个小写字母,直接按那个按钮 |
| Esc | 焦点回到提示框;closeOnEscape: true 时同时关掉窗格 |
| Ctrl+X 再按 X | 关掉窗格,输入框有焦点时也行 |
Tab 和方向键 mod 改不了。输入框有焦点时,所有能打出来的键都进输入框,包括数字,所以这时按页签的数字键是换不了页的,要先 Tab 走。
autoFocus:上一篇没测成的原因
《mod 进阶》里的示例窗格有一个输入框,本站当时往里打字,字却进了下面的提示框,被当成一条提示发给了模型。
原因在这一页的文档里写着:用 focus: true 打开窗格,键盘焦点给的是窗格;字要打进输入框,得输入框自己有焦点,想一打开就能打,给它加 autoFocus: true。那个示例当时没有加,现在那一篇的代码已经补上了。
这次给它补上一行再试:
Input({
key: 'word',
label: '再拦一个词',
placeholder: '比如 DROP TABLE',
autoFocus: true,
// …
})
│ 再拦一个词: DROP TABLE ⏎ submit
回车之后:
│ 另外拦:DROP TABLE
字进了输入框,提示框是空的。顺带看到:没写 submitLabel 时,⏎ 后面显示的是 submit。
autoFocus 只接受 true,一个窗格里只给一个控件加。
六、画图
Raster:一格一格的色块
给热力图、迷你走势图、棋盘用。它要的是打包好的格子数据,每格三个数:字符、前景色、背景色。示例里用一个小函数来打包:
// Raster 要的是打包好的格子:每格三个数(字符、前景色、背景色)
const DEFAULT_COLOR = 0x01000000
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
Raster({
key: 'grid',
columns: 3,
rows: 2,
cells: cellsOf([
[['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
[['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
]),
})
│ ███
│ ███
两行三列的方块画出来了,六个格子的颜色在纯文字里看不出来。文档说 Raster 会把颜色归到一个较小的调色板上,比如 0x2e7d32 实际画成 #337733。上限是 512 列、256 行。
另外三种,本站没有测
| 元素 | 是什么 | 要注意的 |
|---|---|---|
Svg |
画一个 SVG 文档 | 只有桌面应用画。 终端里只返回一个 Svg 的窗格是空的,要按 e.surface 判断,在终端里另画一棵树 |
Image |
画一张 PNG 或者原始像素 | 只有 kitty 0.28 以上和 Ghostty 能画出图,别的终端看到的是变灰的 alt 文字;在 tmux、screen 里和后台会话里一律是 alt 文字。所以 alt 要写得单独能看懂 |
Client |
一块由你的另一个文件来画的区域 | 给动画和鼠标操作用 |
七、元素速查
| 元素 | 主要属性 | 终端 | 桌面应用 |
|---|---|---|---|
Box |
key、排版的那几个、borderStyle、backgroundColor |
✓ | ✓ |
Text |
color、backgroundColor、bold、italic、underline、dimColor、inverse、wrap |
✓ | ✓ |
Markdown |
text、key、dimColor |
✓ | ✓ |
Link |
href、label |
✓ | ✓ |
Code |
source、language、path、startLine、format、wrap |
✓ | ✓ |
Button |
key、label、onPress、hotkey、plain、dimColor、autoFocus |
✓ | ✓ |
Input |
key、label、placeholder、value、submitLabel、onSubmit、onInput、autoFocus |
✓ | ✓ |
Select |
key、label、options、value、onSelect、autoFocus |
✓ | ✓ |
Raster |
key、columns、rows、cells |
✓ | — |
Image |
图片数据或路径、columns、rows、alt |
✓ | — |
Svg |
SVG 文档,最多 131,072 个字符 | — | ✓ |
Client |
module、key |
✓ | ✓ |
按官方的参考页:Raster 和 Image 只有终端画,Svg 只有桌面应用画,其余两边都画。
几个上限:一棵树里的文字只画前 100,000 个字符;Link 的 href 最长 2,048 个字符,超了整棵树都不画;重画每秒最多 10 次,终端里看得见的窗格是 30 次。
除了窗格,mod 还能画在提示框上方的一条(AbovePrompt),能改 Claude Code 自己画的行(比如转圈那一行),也能用 $.ui.toast、$.ui.status、$.ui.log 请 Claude Code 代为显示一句话。
八、用测试来按键
真实终端里一路按键是为了看样子;要防止以后改坏,用测试。$.ui.mount 返回的句柄有这几个方法,都按 key 找控件:
| 方法 | 作用 |
|---|---|
press({ key }) |
按那个按钮 |
input({ key, text }) |
往那个输入框里打字并回车;加 kind: 'change' 只打字不提交 |
select({ key, value }) |
在那个下拉里选中某个值 |
find({ key }) 或 find({ type, text }) |
找第一个匹配的元素,找不到返回 undefined |
unmount() |
撤掉这次绘制 |
控件那一页的测试:
test('控件那一页:输入、选择、按钮各自改了该改的东西', async ($) => {
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
await ui.press({ key: 'tab-controls' })
await ui.input({ key: 'title', text: '发布说明' })
expect(await ui.find({ type: 'Text', text: '已保存的标题:发布说明' })).toBeDefined()
await ui.select({ key: 'format', value: 'html' })
expect(await ui.find({ type: 'Text', text: '选中的格式:html' })).toBeDefined()
await ui.press({ key: 'count' })
await ui.press({ key: 'count' })
expect(await ui.find({ type: 'Text', text: '按了 2 次' })).toBeDefined()
await ui.press({ key: 'reset' })
expect(await ui.find({ type: 'Text', text: '按了 0 次' })).toBeDefined()
await ui.unmount()
})
tests/ui-gallery.test.ts:
(pass) 每一页都能画出来,页签能切换 [197.74ms]
(pass) 控件那一页:输入、选择、按钮各自改了该改的东西 [106.17ms]
2 pass
0 fail
测试不管焦点:它直接按 key 调回调。所以上一篇的测试是过的,真实终端里却打不进字。测试通过不等于键盘能用,窗格做完要在真实的终端里按一遍。
这次实测没有覆盖到的
- 样式看不到。 文字是从 tmux 里抓的,颜色、粗体、斜体、反色、语法配色都没法在文中呈现,这些是照文档描述的。
- 只在终端里测过,桌面应用里各元素的样子没有看。
Svg、Image、Client没有测。Image在 tmux 里本来也只显示alt文字。Link的网址为什么没有显示出来,没有查清。- 提示框上方的那一条、改写 Claude Code 自己的行没有测,上一篇和《mod 的测试和排错》里各有一个改转圈那一行的例子。
- 只在一个版本上测过。