从零写第一个 Claude Code mod:在输入框上方显示上下文用了多少
跟着官方教程的思路,从一个空文件夹写出一个 Claude Code mod:在输入框上方实时显示上下文窗口的占用。讲清目录结构、怎么画界面、数据为什么要放在 $.state、怎么校验和测试、怎么分享。
本文根据公开分享整理,本站尚未实测。操作前请对照文末的原始来源。
本页目录(14 节)
这篇教程解决一个问题:自己动手写一个 mod,让 Claude Code 在输入框上方显示一行你关心的信息。 例子用的是官方教程里的 Token Weather:把上下文窗口的占用画成一行“天气预报”。
Mods 是什么、和 hooks 与 skills 有什么区别、安装别人的 mod 要注意什么,见本站的 Claude Code Mods 是什么。这篇只讲怎么写。
你会做出什么
每一轮对话结束后,输入框上方的那一行会更新,显示:
- 一个天气图标和一个词,表示上下文用了多少;
- 已用的百分比,以及已用 token 数和窗口大小;
- 最近几轮的一个小柱状图;
- 上一轮增加了多少。
| 已用 | 显示 |
|---|---|
| 25% 以下 | ☀ Clear |
| 25% 到 49% | ☁ Cloudy |
| 50% 到 74% | ☂ Showers |
| 75% 到 89% | ☇ Storm |
| 90% 及以上 | ↯ Compact soon |
完整的代码大约 80 行。
准备
需要 Claude Code 2.1.287 或更高版本,Mods 默认开启,不用另外打开:
claude --version
官方提醒:Mods 的 API 在不同版本之间可能变化。每次加载 mod 时,Claude Code 会把当前版本的类型声明写进 mod 的 .claude-plugin/types/ 文件夹,以那里的内容为准。
捷径:直接让 Claude 写
你不需要先学 API。启动 claude,把想要的效果描述给它就行。官方给的提示词大意是:
Make me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.
What it should show, on one line:
- A weather icon and word for how full the context window is.
- The percentage used, then the tokens used out of the window.
- A small chart of the last 12 turns.
- How much the last turn added.
It should update after every turn.
之后会发生的事:
- Claude 问一次是否为这个会话打开热加载,选允许。
- 这一轮结束时,那一行就出现在输入框上方。
- 之后每次修改都会原地重新加载,可以继续提要求,比如“make Storm start at 70%”,看着它变。
要注意:这样做出来的 mod 只在当前会话里加载,它的文件夹之后会被清理。 想留下来,要把文件夹复制出来,再像普通插件一样安装(见第六步)。
提示词里只描述了想看到什么,没有写怎么实现。把“显示什么”那几行换掉,就是你自己的 mod。
想知道它是怎么搭起来的,或者想检查 Claude 写的代码,继续往下看。
先理解三件事
一个 mod 就是一个插件。 它的行为写在一个 JavaScript 或 TypeScript 模块里。模块导出一个 register(on) 函数,在里面用 on(事件, 匹配条件, 处理函数) 登记要处理的事件。
每个处理函数都是同一个形状:
on("tool.call", { tool: "Bash" }, async ($, e, next) => {
// $ Mods API:界面、会话、状态、文件、进程、网络等都通过它
// e 这次事件的数据
// next 把事件交给其他插件,最后交给 Claude Code 自己
return next(e);
});
处理函数像中间件一样串成一条链,对一个事件可以做三种事:
| 做法 | 怎么写 | 例子 |
|---|---|---|
| 观察 | const r = await next(e); return r |
记录每一次文件修改;每轮结束后读一次数 |
| 改写 | return next({ ...e, command: safer }) |
改掉后面的环节看到的内容 |
| 接管 | 不调用 next,直接 return { deny: "…" } |
拒绝一次工具调用;自己实现一个命令 |
模块运行在自己的沙箱里,没有 DOM,也没有 Node 的接口,所以要碰外面的任何东西都得通过 $。
1建文件夹
token-weather/
├── .claude-plugin/
│ ├── plugin.json
│ └── types/ Claude Code 加载 mod 时自动写入
├── hooks/
│ ├── hooks.json
│ └── token-weather.mjs
├── types/
│ └── index.d.ts 第三步添加
└── tests/
└── token-weather.test.ts 第五步添加
.claude-plugin/plugin.json 是普通的插件清单:
{
"name": "token-weather",
"version": "0.1.0",
"description": "A live forecast of the context window, drawn above the prompt.",
"author": { "name": "You" }
}
hooks/hooks.json 指向模块文件。一个 mod 只有一个模块:
{
"modules": ["./token-weather.mjs"]
}
2先画出一行字
输入框正上方那一行是一个叫 AbovePrompt 的组件。Claude Code 自己不在那里画任何东西,所以它是最合适的第一个目标。处理它的 ui.render 事件,返回要画的内容:
// hooks/token-weather.mjs
export function register(on) {
on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
const { Box, Text } = $.ui.resolve(e);
return Box({
paddingX: 1,
children: [Text({ color: "yellow", bold: true, children: "☀ Clear skies" })],
});
});
}
Box 和 Text 不是全局变量,要用 $.ui.resolve(e) 取出来,因为 Claude Code 的不同界面支持的元素略有不同。
带着这个插件启动一个会话:
claude --plugin-dir ./token-weather
输入框上方会出现“☀ Clear skies”。这个会话不要关。 文件夹是被监视的,之后每次保存文件,模块都会原地重新加载,不用重启。
3读取真实的数字,存进 $.state
$.session.usage() 返回的数字和状态栏里的一样:context.tokens 是已用的 token,context.window 是模型的窗口大小,context.percent 是两者的比值。
在会话开始时和每一轮结束后各读一次:
on("session.start", async ($, e, next) => {
const result = await next(e);
await takeReading($);
return result;
});
on("turn.complete", async ($, e, next) => {
const result = await next(e);
if (!e.agentId) {
await takeReading($); // 只算主对话的轮次,不算子代理
}
return result;
});
两个处理函数都是先调用 next(e) 再观察,不改变原本的行为。
读到的数字放在哪里,是写 mod 最容易踩的坑。 直觉是在模块里写一个 let readings = [],但热加载等于重新加载一遍:register 会再运行一次,session.start 会再触发一次,模块里的变量全部重来。
正确的做法是放进 $.state。它把命名的值保存在 Claude Code 那一侧,整个会话期间都在,热加载之后也在:
const readings = { plugin: "token-weather", key: "readings" };
async function takeReading($) {
const { context } = await $.session.usage();
if (!context?.window) return;
const tokens = context.tokens ?? 0;
const percent = context.percent ?? Math.round((tokens / context.window) * 100);
const { value: history = [] } = await $.state.get(readings);
await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-12));
}
用 $.state 还要多做一件事:在插件的类型声明里登记这个值。新建 types/index.d.ts:
export type TokenWeatherReading = { tokens: number; window: number; percent: number };
declare module "claude-code" {
interface PluginState {
"token-weather": { readings: TokenWeatherReading[] };
}
}
再在 plugin.json 里加一行 "types": "./types/index.d.ts"。漏了这一步,校验时会报错,并告诉你怎么改。
这样做还有一个好处:界面会自动重画。 在画界面的处理函数里调用过 $.state.get,就等于订阅了这个值,之后每次 $.state.set 都会让那一行重画,不需要自己去通知。
4把预报画出来
把第二步里写死的那行字,换成根据读数算出来的内容。处理函数变成这样:
on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
const { value: history = [] } = await $.state.get(readings);
if (e.props.hasSurvey || history.length === 0) {
return next(e);
}
const { Box, Text } = $.ui.resolve(e);
return band(Box, Text, history, e.props.bodyColumns);
});
band 函数做的事不复杂:取最新一次读数,按上面那张表选出图标、文字和颜色,拼出百分比和 token 数;宽度够的时候,再加上最近几轮的柱状图和上一轮的增量。完整的写法见官方教程,以及 GitHub 上 anthropics/claude-code-playground 仓库里的 Token Weather,这里不整段照搬。
官方教程特别指出三个细节,值得带到你自己的 mod 里:
- 组件的属性在
e.props上,不在e上。hasSurvey表示有问卷要用这一行,这时用next(e)让出来;bodyColumns是这一行实际的宽度,旁边停靠了面板时它比终端窄,要按它来排版。 - 没东西可画时就放行。 返回
next(e),把这一行还给 Claude Code 和其他 mod。 - 用单字符宽度的符号,不要用 emoji。 ☀ ☁ ☂ ☇ ↯ 在各种终端字体里都能对齐。
保存文件,开着的会话会自动加载。让 Claude 读几个大文件,那一行就会从 Clear 变成 Showers,再变成 Storm。
5校验和测试
claude plugin validate 用和 Claude Code 相同的方式读取清单和模块源码,报告这个模块处理哪些事件、调用哪些能力:
$ claude plugin validate ./token-weather
> types ./types/index.d.ts declares state: token-weather.readings
> ./token-weather.mjs hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
> ./token-weather.mjs calls: $.session.usage (via takeReading), $.state.get, $.state.set (via takeReading), $.ui.resolve
> ./token-weather.mjs state writes: token-weather.readings
> ./token-weather.mjs state reads: token-weather.readings
√ Validation passed
这个命令对别人的 mod 同样有用:安装之前先跑一遍,就知道它会监听什么、能做什么。
claude plugin test 在真实的 Claude Code 运行环境里执行插件的 *.test.ts 文件。测试里可以替 Claude Code 给出假的回答,所以能精确控制 $.session.usage() 返回什么,再断言那一行显示了对应的文字。测试文件的完整写法见官方教程和仓库。
$ claude plugin test ./token-weather
(pass) token-weather > the band follows the context window
1 pass
0 fail
6安装和分享
mod 就是插件,分发方式和插件一样:放进一个 marketplace。最简单的 marketplace 是一个带 .claude-plugin/marketplace.json 的文件夹:
{
"name": "my-mods",
"owner": { "name": "You" },
"plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}
在本机安装:
claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user
想给别人用,就把它放进一个 GitHub 仓库,这个仓库就成了你的 marketplace。别人在 Claude Code 里三条命令装上:
/plugin marketplace add your-org/my-mods
/plugin install token-weather@my-mods
/reload-plugins
重新加载后 mod 开始运行;没出现的话,重启 Claude Code。
再进一步:另外两个示例教了什么
Token Weather 只是观察和画界面。官方仓库里还有两个更大的示例,各展示了一种做法:
Blast Radius:高风险命令执行前先拦住。 Claude 要执行 rm -rf、git reset --hard、强制推送这类命令时,它先把调用扣住,算出会影响什么,打开一个面板让你选继续或取消。它展示的是“接管”:不调用 next,返回 { deny: "…" },Claude 就会收到拒绝和原因。能学到的几点:
- 用
$.process.run做试运行,参数以数组形式传入,路径里的内容不会被当成 shell 代码执行; - 一个处理函数每次有 10 秒的时间,但等待
$调用的时间不计入,所以可以等用户按键; - 按钮可以带快捷键;终端太窄放不下面板时,退而在输入框上方显示。
官方也说明了它的定位:它是一张安全网,不是权限系统。 它只看命令的文字,$(…)、别名、调用 rm 的脚本都能绕过它。要硬性禁止,用权限规则。
Replay Theater:回放上一轮的修改。 它在一轮进行中记下每一次 Edit 和 Write 的前后内容,这一轮结束后,按一个键就能逐个查看差异。它从不拦截或修改,只是观察。能学到的几点:
- 用
turn.start和turn.complete把一轮里的修改归成一组,用e.agentId排除子代理; - 在
session.start里用$.command.register注册一个斜杠命令,再在command.run里响应它; - 用
$.fs.read在写入之前读出旧内容,得到真实的差异。
四个值得养成的习惯
- 依靠 Claude Code 写给你的类型声明。 它们在
.claude-plugin/types/里,是每个事件、$上每个方法、每个元素属性的权威参考。 - 属性从
e.props读。 - 按热加载来设计。 每次保存都会重新运行
register和session.start,数据放$.state,不放模块变量。 - 画的东西没出现,就看日志。 用
claude --debug启动,找一行说“处理函数返回的内容没有通过校验”的记录。
可以拿来练手的点子
官方教程列的几个:
- 用
$.session.usage()做一个花费或限额的显示; - 处理
prompt.submit,给每条提示词加上团队的约定; - 一个面板,列出这次会话里 Claude 读过的文件;
- 一个专注计时器,长任务结束时弹一条提示;
- 针对你自己技术栈的
tool.call防护,比如生产环境的 kubectl 或terraform apply。
最后提醒一次安全
mod 是在你电脑上、以 Claude Code 同样的权限运行的代码,由发布者编写,不是 Anthropic 写的。安装别人的 mod 要像安装软件包一样:先看仓库,只装信任的人写的。细节见 Claude Code Mods 是什么 里的安全一节。