Context usage, branch and tool count in a band; tokens per request charted in a pane; a note tool for Claude and a check before dangerous commands. English and…

A mod for Claude Code on the desktop (the Code tab). One small mod that uses a band, a pane, an Svg chart, a tool for Claude and a confirming hook, so it also works as a sample to start writing mods from.
band to on): context usage, branch, tool count, time spent/meter): tokens per request as stacked bars (Svg), the cache hit, the most used tools, notes, and "Ask Claude for a review"note: Claude leaves a note in the panerm -r, git reset --hard and git push --force are asked about before they run (no answer means stop)English and Japanese: the language option (auto, en, ja) follows Claude Code's language setting on auto.
/plugin marketplace add nakadaharuki/desk-meter
/plugin install desk-meter@desk-meter
Claude Code v2.1.287 or later (also seen drawing in the 2.1.286 the desktop app ships). It is also listed, pinned to a read commit with a manifest and a preview of what it draws, at modscode.com/mods/desk-meter.
Mods are not sandboxed, so here it is before you install. The code is two files, hooks/register.js and hooks/chart.js.
$.http is not used). No environment variables, no filesgit branch --show-current (the branch in the band)language setting to pick the languageclaude plugin validate . lists every API it usesclaude plugin validate .
claude plugin test
デスクトップ版 Claude Code(Code タブ)で動く Mod。帯(既定は出さない。設定 band を on で出る)にコンテキストの使用率・ブランチ・道具の回数、欄(/meter)に要求ごとのトークンのグラフとメモを出し、危ないコマンドは走らせる前に聞きます。表示は日本語にもなります(language の設定が auto なら Claude Code の language 設定に合わせる)。成分表と入れ方は modscode.com/ja/mods/desk-meter。デスクトップ版の Mods で踏んだ罠は Zenn に: https://zenn.dev/nakadaharuki
All rights reserved(著作権は nakadadev。読むことと Claude Code に入れて使うことはできますが、複製・改変・再配布は許可していません)
hooks/register.js 347 lines1// desk-meter: 帯にコンテキストの使用率・ブランチ・道具の回数、欄に要求ごとのトークンのグラフとメモ
2import { tokenChart, short } from './chart.js'
3
4// 表示の言葉。language の設定が auto なら Claude Code の language 設定("japanese" など)に合わせ、無ければ英語
5const MESSAGES = {
6 en: {
7 noteToolDesc: 'Leaves one line of notes in the desk-meter pane: something decided, or something to check later',
8 noteToolArg: 'one line of notes',
9 noteToolFailed: 'Could not register the note tool: ',
10 cmdDesc: 'Open the desk-meter pane (/meter reset starts the counts over)',
11 cmdFailed: 'Could not register /meter: ',
12 ask: 'Run this command? ',
13 run: 'Run',
14 stop: 'Stop',
15 stopped: 'The user stopped this command. Ask the user before suggesting another way.',
16 askFailed: 'desk-meter could not ask, so this command was not run.',
17 noteEmpty: 'The note is empty. Put one line in text.',
18 noteSaved: 'Note left: ',
19 reset: 'Counts started over',
20 ctx: 'Context {pct}%',
21 ctxFull: 'Context {pct}% ({tokens} / {window})',
22 ctxNone: 'Context not measured yet',
23 tools: 'Tools {n}',
24 toolsNone: 'No tools used yet',
25 toolsTop: 'Tools ',
26 hit: 'Cache hit on the last request {pct}%',
27 hitNone: 'The cache hit shows after the first request',
28 notes: 'Notes {n}',
29 noteLabel: 'Note',
30 notePlaceholder: 'One line, then Enter',
31 noteSubmit: 'Add',
32 review: 'Ask Claude for a review',
33 reviewPrompt: 'With the desk-meter notes ({notes}) in mind, review the work so far in 3 lines and name one thing to do next.',
34 step: 'In {input} (read {read}) Out {output}',
35 chartAlt: 'Tokens per request ({n})',
36 chartAxis: 'older ← requests: {n} → newer',
37 chartLegend: ['Cache read', 'Write', 'New input', 'Output'],
38 elapsed: (h, m) => (h ? `${h}h ${m}m` : `${m}m`),
39 },
40 ja: {
41 noteToolDesc: '作業のメモを desk-meter の欄に 1 行残す。決めたこと・後で確かめることを残すときに使う',
42 noteToolArg: '1 行のメモ',
43 noteToolFailed: 'note の道具を登録できませんでした: ',
44 cmdDesc: 'desk-meter の欄を開く(/meter reset で数え直す)',
45 cmdFailed: '/meter を登録できませんでした: ',
46 ask: 'このコマンドを走らせますか? ',
47 run: '走らせる',
48 stop: '止める',
49 stopped: '利用者がこのコマンドを止めました。別のやり方を提案する前に、利用者に確かめてください。',
50 askFailed: 'desk-meter の確認が失敗したので、このコマンドは走らせていません。',
51 noteEmpty: 'メモが空です。text に 1 行を入れてください。',
52 noteSaved: 'メモを残しました: ',
53 reset: '数え直しました',
54 ctx: '文脈 {pct}%',
55 ctxFull: '文脈 {pct}%({tokens} / {window})',
56 ctxNone: '文脈はまだ測れていません',
57 tools: '道具 {n} 回',
58 toolsNone: '道具はまだ使われていません',
59 toolsTop: '道具 ',
60 hit: '直近の要求のキャッシュの当たり {pct}%',
61 hitNone: 'キャッシュの当たりは、最初の要求の後に出ます',
62 notes: 'メモ {n}',
63 noteLabel: 'メモ',
64 notePlaceholder: '1 行書いて Enter',
65 noteSubmit: '残す',
66 review: 'Claude に振り返りを頼む',
67 reviewPrompt: 'desk-meter のメモ({notes})を踏まえて、ここまでの作業を 3 行で振り返り、次にやることを 1 つ挙げてください。',
68 step: '入力 {input}(読み {read}) 出力 {output}',
69 chartAlt: '要求ごとのトークン({n} 回)',
70 chartAxis: '古い ← 要求 {n} 回 → 新しい',
71 chartLegend: ['キャッシュ読み', '書き', '新しい入力', '出力'],
72 elapsed: (h, m) => (h ? `${h}時間${m}分` : `${m}分`),
73 },
74}
75let lang = 'en'
76const t = (key, params = {}) => String(MESSAGES[lang][key]).replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''))
77// "japanese"・"日本語"・"ja-JP" → 'ja'。それ以外は英語
78const pickLang = (option, setting) => {
79 const v = option && option !== 'auto' ? option : setting
80 return typeof v === 'string' && /^(ja\b|ja[-_]|japanese|日本)/i.test(v.trim()) ? 'ja' : 'en'
81}
82
83const PANE = 'desk-meter'
84const TOOL_NOTE = 'mcp__desk-meter__note'
85// rm -r / rm -rf / git reset --hard / git push --force(-f も)
86const RISKY = /\brm\s+-[a-z]*r[a-z]*\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*(--force|\s-f\b)/
87// デスクトップの欄の 1 マス(CSS の点)。環境で少し違うので、絵は viewBox で収める
88const CELL = { w: 7.6, h: 18.6 }
89
90// ---- 状態(モジュールの変数は読み直しで消える。残したい物は $.store へ) ----
91let steps = [] // 要求ごとのトークン(主の会話だけ・新しい 40 回)
92let tools = {} // 道具の名前 → 回数
93let context = null // { percent, tokens, window }
94let branch = ''
95let startedAt = 0
96let notes = [] // メモ($.store の 'notes' と同じ物)
97let toSend = null // ボタンから頼まれた、次に Claude に送る文
98let draft = '' // メモの欄に打ちかけの字(描き直しで消さないため)
99
100// $ を渡す関数は同じファイルに置く(claude plugin validate が「via refresh」と呼び先をたどれる)
101async function refresh($) {
102 try {
103 const u = await $.session.usage()
104 context = u.context
105 startedAt = u.startedAt
106 } catch {
107 // 使用率が取れない面(claude -p など)では出さない
108 }
109 try {
110 const r = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 3000 })
111 branch = r.exitCode === 0 ? r.stdout.trim() : ''
112 } catch {
113 branch = ''
114 }
115 $.ui.invalidate('ui.render')
116}
117
118async function saveNotes($, change) {
119 // 別のセッションも同じ store に書くので、書く直前に読み直してから変える
120 const now = await $.store.get('notes')
121 notes = change(Array.isArray(now) ? now : [])
122 await $.store.set('notes', notes)
123 $.ui.invalidate('ui.render')
124}
125
126const elapsed = (ms) => {
127 const s = Math.max(0, Math.floor(ms / 1000))
128 const h = Math.floor(s / 3600)
129 const m = Math.floor((s % 3600) / 60)
130 return MESSAGES[lang].elapsed(h, m)
131}
132
133const topTools = (n) => Object.entries(tools).sort((a, b) => b[1] - a[1]).slice(0, n)
134const toolTotal = () => Object.values(tools).reduce((a, b) => a + b, 0)
135
136export function register(on, options = {}) {
137 // ---- 起動: コマンドと道具を登録し、メモを読み、時計を 1 本だけ回す ----
138 on('session.start', async ($, e, next) => {
139 const settings = await $.settings.read().catch(() => ({}))
140 lang = pickLang(options.language, settings?.language)
141 const saved = await $.store.get('notes')
142 if (Array.isArray(saved)) notes = saved
143 // 読み直し(hot reload)で同じ名前をもう一度登録すると例外になりうるので、1 つずつ try で包む
144 try {
145 await $.tool.register({
146 name: 'note',
147 description: t('noteToolDesc'),
148 inputSchema: { type: 'object', properties: { text: { type: 'string', description: t('noteToolArg') } }, required: ['text'] },
149 })
150 } catch (err) {
151 $.ui.log(t('noteToolFailed') + err.message)
152 }
153 let n = 0
154 $.clock.every(1000, async () => {
155 n += 1
156 // ボタンの中で $.prompt.submit を呼んでも、その出来事が終わると捨てられる。だから時計から送る
157 if (toSend) {
158 const text = toSend
159 toSend = null
160 $.prompt.submit({ text })
161 }
162 if (n % 5 === 1) await refresh($)
163 })
164 // 名前がかぶると例外になるので、登録は最後に・try で包む
165 try {
166 await $.command.register({ name: 'meter', description: t('cmdDesc'), argumentHint: '[reset]', immediate: true })
167 } catch (err) {
168 $.ui.log(t('cmdFailed') + err.message)
169 }
170 return next(e)
171 })
172
173 // ---- 道具の回数を数える(止めない。$.ui.ask の質問も AskUserQuestion の呼び出しとして来るので除く) ----
174 on('tool.call', async ($, e, next) => {
175 if (e.tool === 'AskUserQuestion') return next(e)
176 tools[e.tool] = (tools[e.tool] ?? 0) + 1
177 $.ui.invalidate('ui.render')
178 return next(e)
179 })
180
181 // ---- 危ないコマンドは、走らせる前に聞く ----
182 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183 if (!RISKY.test(e.command ?? '')) return next(e)
184 let answer = t('stop')
185 try {
186 answer = await $.ui.ask(t('ask') + e.command, [t('run'), t('stop')])
187 } catch {
188 // 答える人がいない(claude -p)・閉じられた → 止める側に倒す
189 }
190 if (answer !== t('run')) return { deny: t('stopped') }
191 return next(e)
192 }).catch(async () => ({ deny: t('askFailed') }))
193
194 // ---- Claude 用の道具: メモを残す ----
195 on('tool.call', { tool: TOOL_NOTE }, async ($, e) => {
196 const text = String(e.text ?? '').trim().slice(0, 200)
197 if (!text) return { result: t('noteEmpty') }
198 await saveNotes($, (list) => [...list, text].slice(-20))
199 return { result: t('noteSaved') + text }
200 })
201
202 // 自分の道具は確認なしで通す(メモを書くだけで、外に出ない)
203 on('tool.check', { tool: TOOL_NOTE }, async () => ({ decision: 'allow' }))
204
205 // ---- 要求ごとのトークンを記録(turn.step は流れるので async generator) ----
206 on('turn.step', async function* ($, e, next) {
207 const result = yield* next(e)
208 const u = result?.usage
209 if (u && !e.agentId) {
210 steps = [...steps, {
211 input: u.input_tokens ?? 0,
212 output: u.output_tokens ?? 0,
213 cacheRead: u.cache_read_input_tokens ?? 0,
214 cacheWrite: u.cache_creation_input_tokens ?? 0,
215 }].slice(-40)
216 $.ui.invalidate('ui.render')
217 }
218 return result
219 })
220
221 // ---- /meter ----
222 on('command.run', { command: 'meter' }, async ($, e) => {
223 if (e.args.trim() === 'reset') {
224 steps = []
225 tools = {}
226 $.ui.invalidate('ui.render')
227 return { text: t('reset') }
228 }
229 await $.ui.open({ id: PANE, title: 'desk-meter', focus: true, closeOnEscape: true })
230 return {}
231 })
232
233 // ---- 帯(入力欄の上の 1 行) ----
234 // 既定は出さない(入力欄の上を占めるため)。数えるのは続け、/meter の欄で見る
235 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236 if (options.band !== 'on') return next(e)
237 if (!context?.percent && !branch && !toolTotal()) return next(e)
238 const { Box, Text } = $.ui.resolve(e)
239 const desk = e.surface !== 'terminal'
240 const pct = context?.percent
241 const hot = (pct ?? 0) >= 80
242 const parts = []
243 if (pct !== undefined && pct !== null) {
244 // color に undefined を渡さない(値の無い項目は書かない)
245 const style = hot ? { bold: true, color: desk ? 'warning' : 'yellow' } : { bold: true }
246 parts.push(Text({ key: 'ctx', ...style, children: [t('ctx', { pct })] }))
247 }
248 if (branch) parts.push(Text({ key: 'branch', children: ['⎇ ' + branch] }))
249 parts.push(Text({ key: 'tools', dimColor: true, children: [t('tools', { n: toolTotal() })] }))
250 if (startedAt) parts.push(Text({ key: 'time', dimColor: true, children: [elapsed(Date.now() - startedAt)] }))
251 return Box({ flexDirection: 'row', columnGap: 3, children: parts })
252 })
253
254 // ---- 欄 ----
255 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
256 if (e.requestId !== PANE) return next(e)
257 const { Box, Text, Button, Input, Svg } = $.ui.resolve(e)
258 const desk = e.surface === 'desktop'
259 const cols = e.props?.bodyColumns ?? 60
260 const last = steps[steps.length - 1]
261
262 const head = Text({
263 bold: true,
264 children: [
265 context?.percent !== undefined && context?.percent !== null
266 ? t('ctxFull', { pct: context.percent, tokens: short(context.tokens ?? 0), window: short(context.window) })
267 : t('ctxNone'),
268 ],
269 })
270
271 // デスクトップは Svg のグラフ、端末は字で直近 5 回
272 const chart = desk
273 ? Svg({
274 source: tokenChart(steps, Math.floor(cols * CELL.w), Math.floor(9 * CELL.h), { legend: MESSAGES[lang].chartLegend, axis: t('chartAxis', { n: steps.length }) }),
275 alt: t('chartAlt', { n: steps.length }),
276 width: Math.floor(cols * CELL.w),
277 height: Math.floor(9 * CELL.h),
278 })
279 : Box({
280 flexDirection: 'column',
281 children: steps.slice(-5).map((s, i) =>
282 Text({ key: 'step-' + i, children: [t('step', { input: short(s.input + s.cacheRead + s.cacheWrite), read: short(s.cacheRead), output: short(s.output) })] }),
283 ),
284 })
285
286 const hit = last ? Math.round((last.cacheRead / Math.max(1, last.input + last.cacheRead + last.cacheWrite)) * 100) : null
287 const toolLine = Text({
288 dimColor: true,
289 children: [toolTotal() ? t('toolsTop') + topTools(4).map(([k, v]) => `${k.replace(/^mcp__[^_]+(?:_[^_]+)*__/, '')} ${v}`).join(' · ') : t('toolsNone')],
290 })
291
292 const noteRows = notes.map((text, i) =>
293 Box({
294 key: 'row-' + i,
295 flexDirection: 'row',
296 columnGap: 1,
297 children: [
298 Button({
299 key: 'del-' + i,
300 label: '×',
301 plain: true,
302 onPress: () => saveNotes($, (list) => list.filter((_, j) => j !== i)),
303 }),
304 Text({ wrap: 'truncate-end', children: [text] }),
305 ],
306 }),
307 )
308
309 return Box({
310 flexDirection: 'column',
311 rowGap: 1,
312 children: [
313 head,
314 chart,
315 Text({ dimColor: true, children: [hit === null ? t('hitNone') : t('hit', { pct: hit })] }),
316 toolLine,
317 Text({ bold: true, children: [t('notes', { n: notes.length })] }),
318 Input({
319 key: 'note-input',
320 label: t('noteLabel'),
321 placeholder: t('notePlaceholder'),
322 // 道具の呼び出しのたびに描き直すので、打ちかけの字を自分で持って返す
323 value: draft,
324 submitLabel: t('noteSubmit'),
325 onInput: (value) => {
326 draft = value
327 },
328 onSubmit: (value) => {
329 draft = ''
330 const text = value.trim()
331 if (text) return saveNotes($, (list) => [...list, text].slice(-20))
332 $.ui.invalidate('ui.render')
333 },
334 }),
335 ...noteRows,
336 Button({
337 key: 'ask-review',
338 label: t('review'),
339 onPress: () => {
340 toSend = t('reviewPrompt', { notes: notes.join(' / ') })
341 },
342 }),
343 ],
344 })
345 })
346}
347hooks/chart.js 67 lines1// 要求ごとのトークンを積み上げ棒の SVG にする($ を使わない純な関数)
2// 1 本 = 1 回の要求。下から キャッシュ読み・キャッシュ書き・新しい入力、上に出力を細く重ねる
3
4const COLORS = {
5 cacheRead: '#4f8a8b',
6 cacheWrite: '#d9b26a',
7 input: '#e07a5f',
8 output: '#7fd1a6',
9 axis: '#8a8780',
10}
11
12const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
13
14// 12345 → '12k'
15export function short(n) {
16 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
17 if (n >= 1_000) return Math.round(n / 1_000) + 'k'
18 return String(n)
19}
20
21/**
22 * @param {{ input: number, output: number, cacheRead: number, cacheWrite: number }[]} steps 古い順
23 * @param {number} w SVG の幅(CSS の点)
24 * @param {number} h SVG の高さ(CSS の点)
25 * @param {{ legend?: string[], axis?: string }} words 凡例 4 つと下の軸の字(無ければ英語)
26 */
27export function tokenChart(steps, w, h, words = {}) {
28 const pad = { l: 8, r: 44, t: 22, b: 18 }
29 const iw = w - pad.l - pad.r
30 const ih = h - pad.t - pad.b
31 const totals = steps.map((s) => s.cacheRead + s.cacheWrite + s.input)
32 const max = Math.max(1, ...totals)
33 const n = Math.max(steps.length, 1)
34 const slot = iw / n
35 const bw = Math.max(2, Math.min(18, slot * 0.7))
36 const y = (v) => pad.t + ih - (v / max) * ih
37 const parts = []
38 steps.forEach((s, i) => {
39 const x = pad.l + slot * i + (slot - bw) / 2
40 let base = 0
41 for (const k of ['cacheRead', 'cacheWrite', 'input']) {
42 if (s[k] <= 0) continue
43 parts.push(`<rect x="${x.toFixed(1)}" y="${y(base + s[k]).toFixed(1)}" width="${bw.toFixed(1)}" height="${((s[k] / max) * ih).toFixed(1)}" fill="${COLORS[k]}"/>`)
44 base += s[k]
45 }
46 // 出力は棒の頭に細い線で(量は入力よりずっと小さいので、印として見せる)
47 parts.push(`<rect x="${x.toFixed(1)}" y="${(y(base) - 3).toFixed(1)}" width="${bw.toFixed(1)}" height="2" fill="${COLORS.output}"/>`)
48 })
49 const last = totals[totals.length - 1] ?? 0
50 const labels = words.legend ?? ['Cache read', 'Write', 'New input', 'Output']
51 const legend = ['cacheRead', 'cacheWrite', 'input', 'output']
52 .map((k, i) => [k, labels[i]])
53 .map(([k, label], i) => `<rect x="${pad.l + i * 92}" y="6" width="9" height="9" fill="${COLORS[k]}"/><text x="${pad.l + 13 + i * 92}" y="14">${esc(label)}</text>`)
54 .join('')
55 return [
56 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMin meet">`,
57 `<style>text{font:11px system-ui,'Yu Gothic UI',sans-serif;fill:${COLORS.axis}}</style>`,
58 legend,
59 `<line x1="${pad.l}" x2="${pad.l + iw}" y1="${pad.t + ih}" y2="${pad.t + ih}" stroke="${COLORS.axis}" stroke-width="1"/>`,
60 `<text x="${pad.l + iw + 4}" y="${pad.t + 8}">${short(max)}</text>`,
61 `<text x="${pad.l + iw + 4}" y="${y(last).toFixed(1)}">${short(last)}</text>`,
62 `<text x="${pad.l}" y="${h - 4}">${esc(words.axis ?? `older ← requests: ${steps.length} → newer`)}</text>`,
63 ...parts,
64 '</svg>',
65 ].join('')
66}
67