SLOPSHOPPER

hello-mod

function hooks 最小演示:自注册命令、prompt 上方可交互面板、持久化计数、工具调用监听、独立绘制线程动画

newbandguardcommand
v0.1.0MITupdated 2026-09-15sawzhang/hello-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hello-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hello ⎿ hello-mod: 面板已打开 · 这条回复由插件直接生成,没有经过模型 · /hello off 关闭 hello-mod · function hooks 演示 上下文 █████░░░░░ 49% 97.4k / 200.0k 本会话 $0.420 claude-opus-5-5 本会话 1 轮 · in 2.1k · out 1.5k · 缓存读 91.0k 写 4.3k · 工具 9 次(最近 Bash) 跨会话累计 1 轮 · $0.420 · 98.9k tokens(存在 $.store) [ +1 ] [ -1 ] [ 关闭 ] 点击 0 次 · 按钮不经过模型 ▣ client module ./clock.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
hello-mod · function hooks 演示 上下文 █████░░░░░ 49% 97.4k / 200.0k 本会话 $0.420 claude-opus-5-5 本会话 1 轮 · in 2.1k · out 1.5k · 缓存读 91.0k 写 4.3k · 工具 9 次(最近 Bash) 跨会话累计 1 轮 · $0.420 · 98.9k tokens(存在 $.store) [ +1 ] [ -1 ] [ 关闭 ] 点击 0 次 · 按钮不经过模型 ▣ client module ./clock.tsx ⟨Claude Code's own drawing⟩
README

hello-mod

Claude Code function hooks(产品名 Claude Mods)的最小可运行演示:一个 /hello 命令,一条画在 prompt 上方的面板,面板里有实时 token / 成本计、可点击的按钮,和一个跑在独立绘制线程上的动画。

全程零 token —— 命令回复、按钮点击、动画重绘,模型一步都不参与。

❯ /hello
  ⎿  面板已打开 · 这条回复由插件直接生成,没有经过模型 · /hello off 关闭
hello-mod · function hooks 演示
上下文 █░░░░░░░░░ 5% 46.9k / 1.00M 本会话 $0.179 claude-opus-5
本会话 1 轮 · in 4 · out 101 · 缓存读 80.3k 写 13.4k · 工具 1 次(最近 Bash)
跨会话累计 1 轮 · $0.179 · 93.9k tokens(存在 $.store)
[  +1  ] [  -1  ] [  关闭  ] 点击 6 次 · 按钮不经过模型
⠋ 独立绘制线程 · 第 100 帧 · 10fps · 零 token · 主线程忙时照转
────────────────────────────────────────────────────────────────────────
❯

上面这段是 scripts/demo.py 从一个真实会话里截下来的,不是手写的示意图。

它演示了什么

能力用到的 API在面板上的体现
插件自己注册斜杠命令$.command.register + command.run/hello,immediate: true 所以回复不经过模型
在 prompt 上方绘制ui.render + {component: 'AbovePrompt'}整条面板
读会话用量与花费$.session.usage()上下文进度条、本会话美元数
按轮结算 tokenturn.complete 的 e.usagein / out / 缓存读写四项计数
监听工具调用tool.call工具次数与最近一次的工具名
跨会话持久化$.store点击数、跨会话总账
交互Button + onPress+1 / -1 / 关闭
独立绘制线程Client surface module底部转圈动画,主线程忙时照转

环境要求

  • Claude Code 2.1.269+(第一个能让 function hooks 画在 prompt 上方的版本),本仓库在 2.1.271 上验证过
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
  • 交互式终端。claude -p、桌面端、移动端都不绘制(hook 照常触发,只是不画)

function hooks 目前是早期访问,API 可能随 Claude Code 版本变化。

跑起来

git clone https://github.com/sawzhang/hello-mod
cd hello-mod
claude --plugin-dir .

仓库自带的 .claude/settings.json 只为这个目录打开 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS,不动你的全局配置。进去之后输入 /hello。

  • /hello 打开面板
  • /hello off 关闭
  • /hello reset 清零点击数和跨会话总账

自动验证

function hooks 只在真 tty 里绘制,所以验证脚本用 pty + 终端仿真跑一个真实会话,自动点按钮、截屏:

python3 -m venv .venv && .venv/bin/pip install pyte
.venv/bin/python scripts/demo.py

它会依次验证:面板渲染 → 帧时钟在走 → 点击 +1 生效 → 重启会话后计数还在 → Claude 跑一个工具后 tool.call 计数 +1 → 回合结束后 token 与花费结算。

开发

claude plugin validate .claude-plugin/plugin.json   # 列出钩的事件、用到的 $ 能力、surface module
claude plugin test .                                # 7 个测试,含一个走引擎的 /hello 集成测试

类型检查需要先生成早期访问的类型定义:在本目录开一个会话,运行 /plugin-types(写入被 git 忽略的 .claude/types/),然后:

bunx -p typescript tsc -p .

结构

.claude-plugin/plugin.json   插件清单
.claude/settings.json        只对本目录开启 function hooks
hooks/
  hooks.json                 入口:{"modules": ["./register.tsx"]}
  register.tsx               5 个 hook:session.start / command.run / tool.call / turn.complete / ui.render
  cost.ts                    纯函数:token 累加与格式化(可单测)
  clock.tsx                  surface module,跑在独立绘制线程
tests/cost.test.ts           claude plugin test
scripts/demo.py              pty 自动化验证

踩过的坑

写 function hooks 插件时这几条目前没有文档,但会实打实咬人:

  1. surface module 里绝不能有名为 h 的局部变量 —— 每个 JSX 标签都编译成 h() 调用,撞了会在首帧崩掉。
  2. Client 的 module 必须写字符串字面量 —— 引擎从源码里静态读取路径,变量拼接找不到。
  3. hook 里未捕获的 Promise reject 会卸载整个模块 —— 所有 $.store 调用都要挂 .catch。
  4. prompt 上方这条带子大约占终端一半高度,重绘约 10fps(实测 1.5 秒走 14 帧)。
  5. band 里别设快捷键 —— 会吃掉用户在 prompt 里键入的第一个字符。
  6. tsconfig.json 需要 allowImportingTsExtensions ——/plugin-types 给的示例配置里没有,但带扩展名的相对 import 需要它。
  7. 改代码会热重载;重载失败一半会留着旧版本,要重启会话。

参考

License

MIT

Source 3 files
hooks/register.tsx 140 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3import { add, bar, barColor, fmt, isLifetime, shortModel, total, usd, zero, type Lifetime, type Tokens } from './cost.ts'
4
5// hello-mod:Claude Code function hooks 的最小可运行演示。
6// 一个 /hello 命令,一条 prompt 上方的面板,面板里有可点的按钮、实时 token/成本计,
7// 和一个跑在独立绘制线程上的动画。全部零 token —— 模型不参与其中任何一步。
8
9let open = false
10let clicks = 0
11let tools = 0
12let turns = 0
13let lastTool = '—'
14let tokens: Tokens = zero()
15let model = ''
16// $.session.usage() 给的是本会话累计花费,取差值才能累加到跨会话总账上
17let lastUsd = 0
18let lifetime: Lifetime = { turns: 0, usd: 0, tokens: 0 }
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    const r = await next(e)
23    // store 读写失败只该损失计数,不该掀翻整个模块:hook 里未捕获的 reject 会卸载模块
24    const read = (key: string) => $.store.get(key).catch(err => { $.ui.log(`hello-mod: 读取 ${key} 失败: ${err}`); return undefined })
25    const savedClicks = await read('clicks')
26    if (typeof savedClicks === 'number') clicks = savedClicks
27    const savedLifetime = await read('lifetime')
28    if (isLifetime(savedLifetime)) lifetime = savedLifetime
29    await $.command.register({
30      name: 'hello',
31      description: 'function hooks 演示面板:token/成本计 + 可点按钮 (hello-mod)',
32      argumentHint: '[off | reset]',
33      immediate: true,
34    }).catch(err => $.ui.log(`hello-mod: /hello 注册失败: ${err}`))
35    return r
36  })
37
38  on('command.run', { command: 'hello' }, async ($, e, next) => {
39    const arg = e.args.trim().toLowerCase()
40    if (arg === 'off') {
41      open = false
42      $.ui.invalidate('ui.render')
43      return { text: '面板已关闭' }
44    }
45    if (arg === 'reset') {
46      clicks = 0
47      lifetime = { turns: 0, usd: 0, tokens: 0 }
48      await $.store.set('clicks', 0).catch(() => {})
49      await $.store.set('lifetime', lifetime).catch(() => {})
50      $.ui.invalidate('ui.render')
51      return { text: '点击数和跨会话总账已清零' }
52    }
53    open = true
54    $.ui.invalidate('ui.render')
55    return { text: '面板已打开 · 这条回复由插件直接生成,没有经过模型 · /hello off 关闭' }
56  })
57
58  on('tool.call', async ($, e, next) => {
59    const r = await next(e)
60    tools++
61    lastTool = e.tool
62    if (open) $.ui.invalidate('ui.render')
63    return r
64  })
65
66  // 一轮结束时结算:e.usage 是这一轮所有响应的 token 计数之和
67  on('turn.complete', async ($, e, next) => {
68    const r = await next(e)
69    turns++
70    if (e.usage) {
71      tokens = add(tokens, e.usage)
72      model = e.usage.model
73    }
74    const session = await $.session.usage().catch(err => { $.ui.log(`hello-mod: 读取用量失败: ${err}`); return undefined })
75    const spent = session?.cost?.usd ?? 0
76    const delta = Math.max(0, spent - lastUsd)
77    lastUsd = spent
78    lifetime = {
79      turns: lifetime.turns + 1,
80      usd: lifetime.usd + delta,
81      tokens: lifetime.tokens + (e.usage ? total(add(zero(), e.usage)) : 0),
82    }
83    await $.store.set('lifetime', lifetime).catch(err => $.ui.log(`hello-mod: 写入总账失败: ${err}`))
84    if (open) $.ui.invalidate('ui.render')
85    return r
86  })
87
88  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89    // 只在终端画:桌面端和移动端有自己的一条带子
90    if (!open || e.surface !== 'terminal') return next(e)
91    const { Box, Button, Client, Text } = await $.ui.resolve(e)
92    const session = await $.session.usage().catch(() => undefined)
93    const ctx = session?.context
94    const percent = ctx?.percent ?? 0
95    const spent = session?.cost?.usd ?? lastUsd
96
97    const bump = (n: number) => {
98      clicks += n
99      $.ui.invalidate('ui.render')
100      void $.store.set('clicks', clicks).catch(err => $.ui.log(`hello-mod: 写入失败: ${err}`))
101    }
102    const close = () => {
103      open = false
104      $.ui.invalidate('ui.render')
105    }
106
107    return (
108      <Box flexDirection="column">
109        <Text bold color="cyan">hello-mod · function hooks 演示</Text>
110
111        <Box flexDirection="row" columnGap={1}>
112          <Text>上下文</Text>
113          <Text color={barColor(percent)}>{bar(percent)}</Text>
114          <Text>{`${percent}%`}</Text>
115          <Text dimColor>{`${fmt(ctx?.tokens ?? 0)} / ${fmt(ctx?.window ?? 0)}`}</Text>
116          <Text color="yellow" bold>{`本会话 ${usd(spent)}`}</Text>
117          {model ? <Text dimColor>{shortModel(model)}</Text> : null}
118        </Box>
119
120        <Text dimColor>
121          {`本会话 ${turns} 轮 · in ${fmt(tokens.input)} · out ${fmt(tokens.output)} · 缓存读 ${fmt(tokens.cacheRead)} 写 ${fmt(tokens.cacheWrite)} · 工具 ${tools} 次(最近 ${lastTool})`}
122        </Text>
123        <Text dimColor>
124          {`跨会话累计 ${lifetime.turns} 轮 · ${usd(lifetime.usd)} · ${fmt(lifetime.tokens)} tokens(存在 $.store)`}
125        </Text>
126
127        <Box flexDirection="row" columnGap={1}>
128          <Button key="hm:inc" label=" +1 " onPress={() => bump(1)} />
129          <Button key="hm:dec" label=" -1 " onPress={() => bump(-1)} />
130          <Button key="hm:close" label=" 关闭 " onPress={close} />
131          <Text dimColor>{`点击 ${clicks} 次 · 按钮不经过模型`}</Text>
132        </Box>
133
134        <Client key="hm:clock" module="./clock.tsx" width={e.viewport?.columns ?? 60} height={1} props={{ percent }} />
135        {await next(e)}
136      </Box>
137    )
138  })
139}
140
hooks/cost.ts 44 lines
1// 纯函数:token 累加与格式化。与 Claude Code API 无关,可以单独测试。
2
3export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
4export type Lifetime = { turns: number; usd: number; tokens: number }
5
6export const zero = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
7
8export const add = (t: Tokens, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Tokens => ({
9  input: t.input + u.input_tokens,
10  output: t.output + u.output_tokens,
11  cacheRead: t.cacheRead + u.cache_read_input_tokens,
12  cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
13})
14
15export const total = (t: Tokens): number => t.input + t.output + t.cacheRead + t.cacheWrite
16
17// 1234 -> 1.2k, 1234567 -> 1.2M
18export function fmt(n: number): string {
19  if (n < 1000) return String(Math.round(n))
20  if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
21  return `${(n / 1_000_000).toFixed(2)}M`
22}
23
24// 很小的金额多给几位小数,否则一直显示 $0.00
25export function usd(n: number): string {
26  if (n === 0) return '$0'
27  if (n < 0.01) return `$${n.toFixed(4)}`
28  if (n < 1) return `$${n.toFixed(3)}`
29  return `$${n.toFixed(2)}`
30}
31
32export function bar(percent: number, width = 10): string {
33  const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
34  return '█'.repeat(filled) + '░'.repeat(width - filled)
35}
36
37export const barColor = (percent: number): string => (percent >= 80 ? 'red' : percent >= 60 ? 'yellow' : 'green')
38
39// 模型 id 通常形如 claude-opus-5-20260101,展示时去掉日期后缀
40export const shortModel = (id: string): string => id.replace(/-\d{8}$/, '')
41
42export const isLifetime = (v: unknown): v is Lifetime =>
43  typeof v === 'object' && v !== null && typeof (v as Lifetime).turns === 'number' && typeof (v as Lifetime).usd === 'number'
44
hooks/clock.tsx 26 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3
4// surface module:跑在独立绘制线程上,有自己的帧时钟、键盘和鼠标。
5// 注意:这个文件里绝不能出现名为 h 的局部变量 —— 每个 JSX 标签都编译成 h() 调用。
6const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
7type State = { tick: number }
8
9export default function Clock(props: { percent?: number } | undefined, surface: ClientSurface<State>) {
10  const { Box, Text } = surface.elements
11  if (surface.state === undefined) {
12    surface.setState({ tick: 0 })
13    surface.every(100, () => {
14      const s = surface.state
15      if (s) surface.setState({ tick: s.tick + 1 })
16    })
17  }
18  const tick = surface.state?.tick ?? 0
19  return (
20    <Box flexDirection="row" columnGap={1}>
21      <Text color="green">{FRAMES[tick % FRAMES.length]}</Text>
22      <Text dimColor>{`独立绘制线程 · 第 ${tick} 帧 · 10fps · 零 token · 主线程忙时照转`}</Text>
23    </Box>
24  )
25}
26