SLOPSHOPPER

desk-meter

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…

newpanebandguardcommandtool
v0.3.0NOASSERTIONupdated 2026-10-08nakadaharuki/desk-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk-meter
│ ┃ desk-meter ✕ › fix the failing auth test and add an audit log call │ ┃ Context not measured yet │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ The cache hit shows after the first request ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Tools Bash 4 · Write 2 · Read 1 · Grep 1 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Notes 0 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Note: One line, then Enter ⏎ Add │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Ask Claude for a review ] │ › /meter │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · desk-meter
Context not measured yet The cache hit shows after the first request Tools Bash 4 · Write 2 · Read 1 · Grep 1 Notes 0 Note: One line, then Enter ⏎ Add [ Ask Claude for a review ]
README

desk-meter

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 (above the prompt, off by default; set band to on): context usage, branch, tool count, time spent
  • Pane (/meter): tokens per request as stacked bars (Svg), the cache hit, the most used tools, notes, and "Ask Claude for a review"
  • Claude's tool note: Claude leaves a note in the pane
  • Check: rm -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.

Install

/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.

What it touches

Mods are not sandboxed, so here it is before you install. The code is two files, hooks/register.js and hooks/chart.js.

  • No network ($.http is not used). No environment variables, no files
  • One outside process: git branch --show-current (the branch in the band)
  • It reads Claude Code's language setting to pick the language
  • claude plugin validate . lists every API it uses

Tests

claude 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 に入れて使うことはできますが、複製・改変・再配布は許可していません)

Source 2 files
hooks/register.js 347 lines
1// 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}
347
hooks/chart.js 67 lines
1// 要求ごとのトークンを積み上げ棒の 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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