ClaudeClaude Code系列第 14 / 15 篇

Claude Code mod 的界面元素一览:十种元素在终端里长什么样、怎么写、键盘怎么操作

mod 画界面能用的元素放在一页:文字、Markdown、链接、代码和改动、排版用的 Box、按钮、输入框、下拉、色块。每一种给出代码和它在真实终端里画出来的样子。本站把它们做成一个带页签的示例 mod 跑了一遍,顺带查清了输入框打不进字的原因:少了 autoFocus。

发布 · 约 11 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-13。

本页目录(9 节)
  1. 一、一个能逐页看样例的 mod
  2. 二、显示文字
  3. 三、显示代码和改动
  4. 四、排版:Box
  5. 五、控件:按钮、输入框、下拉
  6. 六、画图
  7. 七、元素速查
  8. 八、用测试来按键
  9. 这次实测没有覆盖到的

这篇解决一个问题: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 的,用 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 的测试和排错》里各有一个改转圈那一行的例子。
  • 只在一个版本上测过。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章