コンテキストの使用状況を天気で、このセッションが読んだ auto-memory を一覧で、右のペインに示す。カードを押すと内訳・残り時間・索引の項目が開く

Claude Code の mod(フックモジュール)を集めたリポジトリです。このリポジトリ自体が marketplace になっているので、1 行でインストールできます。
Claude Code のプロンプトで次を実行します。
/plugin install token-weather --marketplace haruki-cl/claude-code-mods
Add marketplace? に y、スコープは user を選ぶと、そのセッションからすぐ動き、以降のセッションでも読み込まれます。run-cat も同じ形で入ります。
/plugin install run-cat --marketplace haruki-cl/claude-code-mods
| mod | 何をするか |
|---|---|
token-weather | コンテキストの使用量を天気で、使用量の枠(5 時間・週)、このセッションが読んだ CLAUDE.md と auto-memory を右のペインに出す。/weather で開閉。各カードの見出しを押すと詳細が開く |
run-cat | Claude が考えている間、プロンプト下のヒント行で AA の猫が走る |
インストールせずにフォルダから読み込むには --plugin-dir を使います。
claude --plugin-dir ./token-weather --plugin-dir ./run-cat
検査とテストは次のとおりです。
claude plugin validate ./token-weather
claude plugin test ./token-weather
.claude-plugin/types/ はエンジンが書き出す型定義で、git には含めません。
hooks/register.tsx 414 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextBreakdown, SessionContextUsage, SessionRateLimit, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type { Card, IndexEntry, Limit, MemoryIndex, MemoryRead, Usage } from '../types'
5import { ageDaysIn, formatAge, isMemoryIndex, loadedFilesIn, memoryFileName, memoryPathsIn, parseEntries, shorten, stem } from './memory.ts'
6import { bar, formatLeft, formatReset, formatTokens, limitColor, limitLabel, limitOrder, padCells, percentOf, sparkline, weatherFor } from './weather.ts'
7
8const PANE = 'token-weather'
9const TITLE = 'Token Weather'
10const COMMAND = 'weather'
11// 全画面表示のときにサイドバーとして欲しい幅
12const PANE_COLUMNS = 32
13// 推移グラフに残すターン数
14const HISTORY = 40
15// 読んだメモリのうち、覚えておく数と見せる数
16const READS_KEPT = 30
17const READS_SHOWN = 8
18// 起動直後は CLAUDE.md や MEMORY.md がまだ読み込まれていないことがあるので、少し待ってから内訳を取り直す
19const LATE_REFRESH_MS = [3_000, 10_000]
20
21const usage = atom({ plugin: 'token-weather', key: 'usage' } as const, null)
22const history = atom({ plugin: 'token-weather', key: 'history' } as const, [])
23const limits = atom({ plugin: 'token-weather', key: 'limits' } as const, [])
24const autoCompactAt = atom({ plugin: 'token-weather', key: 'autoCompactAt' } as const, null)
25const memory = atom({ plugin: 'token-weather', key: 'memory' } as const, { index: null, reads: [] })
26const breakdown = atom({ plugin: 'token-weather', key: 'breakdown' } as const, null)
27const open = atom({ plugin: 'token-weather', key: 'open' } as const, { weather: false, limits: false, memory: false })
28
29// 応答がまだ使用量を報告していなければ null
30function snapshot(context: SessionContextUsage): Usage | null {
31 if (context.percent === undefined || context.tokens === undefined) {
32 return null
33 }
34
35 return { percent: context.percent, tokens: context.tokens, window: context.window }
36}
37
38function toLimits(rateLimits: readonly SessionRateLimit[]): Limit[] {
39 return rateLimits
40 .map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, ...(resetsAt ? { resetsAt } : {}) }))
41 .sort((a, b) => limitOrder(a.kind) - limitOrder(b.kind))
42}
43
44// 索引の項目は、パスとトークン数が変わらないかぎり読み直さない
45let counted: { path: string; tokens?: number; entries?: IndexEntry[] } | undefined
46
47async function describeIndex($: EngineInterface, path: string, tokens?: number): Promise<MemoryIndex> {
48 if (counted?.path !== path || counted.tokens !== tokens) {
49 let entries: IndexEntry[] | undefined
50 try {
51 entries = parseEntries(await $.fs.read(path))
52 } catch {
53 entries = undefined
54 }
55
56 counted = { path, ...(tokens !== undefined ? { tokens } : {}), ...(entries !== undefined ? { entries } : {}) }
57 }
58
59 return { path, ...(tokens !== undefined ? { tokens } : {}), ...(counted.entries !== undefined ? { entries: counted.entries } : {}) }
60}
61
62// 索引が載ったと分かったら覚える。トークン数は内訳から分かったときだけ更新し、一度載った索引は /clear まで消さない
63async function noteIndex($: EngineInterface, path: string, tokens?: number): Promise<void> {
64 const index = await describeIndex($, path, tokens)
65 await update($, memory, m => ({ ...m, index: { ...index, ...(index.tokens === undefined && m.index?.tokens !== undefined ? { tokens: m.index.tokens } : {}) } }))
66}
67
68// 内訳から、自動圧縮のしきい値と、カテゴリごとの行と、エンジンが数える memory files(CLAUDE.md 群と MEMORY.md)を取り出す
69async function absorb($: EngineInterface, b: SessionContextBreakdown): Promise<void> {
70 await update($, autoCompactAt, () => (b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : null))
71 await update($, breakdown, () => ({
72 categories: b.categories.flatMap(c => (c.kind === 'deferred' ? [] : [{ name: c.name, tokens: c.tokens, kind: c.kind }])),
73 window: b.rawMaxTokens,
74 }))
75
76 const others = b.memoryFiles.filter(f => f.type !== 'AutoMem')
77 const root = others.length > 0 ? await $.session.root() : undefined
78 const home = others.length > 0 ? await $.env.get('HOME') : undefined
79 for (const f of b.memoryFiles) {
80 if (f.type === 'AutoMem') {
81 await noteIndex($, f.path, f.tokens)
82 } else {
83 // 応答のたびに来るので、載っているものは並びを動かさない
84 await remember($, { name: shorten(f.path, root, home), path: f.path, how: 'auto' }, { keep: true })
85 }
86 }
87}
88
89// 読んだものとして覚える。同じファイルは 1 件だけ残し、読み直したら最新に上げる(keep なら動かさない)
90async function remember($: EngineInterface, read: Omit<MemoryRead, 'at'>, options?: { keep: boolean }): Promise<void> {
91 const at = await $.clock.now()
92 await update($, memory, m => {
93 if (options?.keep && m.reads.some(r => r.path === read.path)) {
94 return m
95 }
96
97 return { ...m, reads: [...m.reads.filter(r => r.path !== read.path), { ...read, at }].slice(-READS_KEPT) }
98 })
99}
100
101// サブエージェントでもチームメイトでもないループは、エンジン自身のもの(compaction やメモリ処理)
102async function isEngineLoop($: EngineInterface, agentId: string | undefined): Promise<boolean> {
103 if (agentId === undefined) {
104 return false
105 }
106
107 try {
108 return !(await $.agent.list()).some(a => a.id === agentId)
109 } catch {
110 return false
111 }
112}
113
114// エンジンが注入した文章に指示ファイルやメモリが含まれていたら、勝手に読まれたものとして覚える
115async function noteInjected($: EngineInterface, text: string): Promise<void> {
116 const files = loadedFilesIn(text)
117 const root = files.length > 0 ? await $.session.root() : undefined
118 const home = files.length > 0 ? await $.env.get('HOME') : undefined
119 for (const f of files) {
120 if (isMemoryIndex(f.path)) {
121 await noteIndex($, f.path)
122 continue
123 }
124
125 const name = memoryFileName(f.path)
126 await remember($, { name: name ? stem(name) : shorten(f.path, root, home), path: f.path, how: 'auto' })
127 }
128
129 // 見出しなしで本文に現れたメモリ(想起)も拾う
130 for (const path of memoryPathsIn(text)) {
131 const name = memoryFileName(path)
132 if (name && !files.some(f => f.path === path)) {
133 await remember($, { name: stem(name), path, how: 'auto' })
134 }
135 }
136}
137
138// 使用量と枠を測り直す。内訳は手元での見積もりなので API 呼び出しは発生しない
139async function refresh($: EngineInterface, withBreakdown: boolean): Promise<void> {
140 const measured = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : undefined)
141 await update($, usage, () => snapshot(measured.context))
142 await update($, limits, () => toLimits(measured.rateLimits))
143
144 const b = measured.context.breakdown
145 if (b) {
146 await absorb($, b)
147 }
148}
149
150// Read か Bash でメモリファイルを読んでいたら、読んだものとして覚える。エンジン自身のループが読んだものは auto
151async function noteMemoryRead($: EngineInterface, e: ToolCallInput, result: ToolCallResult): Promise<void> {
152 if (result.deny !== undefined || result.isError) {
153 return
154 }
155
156 const paths = e.tool === 'Read' ? [e.file_path] : e.tool === 'Bash' ? memoryPathsIn(e.command) : []
157 if (paths.length === 0) {
158 return
159 }
160
161 const ageDays = ageDaysIn(result.text)
162 const how = (await isEngineLoop($, e.agentId)) ? 'auto' : 'tool'
163 for (const path of paths) {
164 const name = memoryFileName(path)
165 if (name) {
166 await remember($, { name: stem(name), path, how, ...(ageDays !== undefined ? { ageDays } : {}) })
167 }
168 }
169}
170
171async function toggle($: EngineInterface): Promise<string> {
172 const mine = (await $.ui.panes()).find(pane => pane.id === PANE)
173 if (mine?.isShown) {
174 await $.ui.close({ id: PANE })
175 return 'Token Weather closed.'
176 }
177
178 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
179 return 'Token Weather opened.'
180}
181
182export const register: Register = on => {
183 on('session.start', async ($, e, next) => {
184 await $.command.register({ name: COMMAND, description: 'Open or close Token Weather (context usage as weather) on the right', immediate: true })
185 await refresh($, true)
186 for (const ms of LATE_REFRESH_MS) {
187 $.clock.after(ms, () => void refresh($, true).catch(() => undefined))
188 }
189
190 // 頼まれずに開くペインは、端末が 144 列以上のときだけ横に並ぶ。狭いときは /weather で開く
191 void $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
192
193 return next(e)
194 })
195
196 // プロンプトが送られた時点でも取り直す。最初の応答を待たずに、起動時に載った memory files を出すため
197 on('turn.start', async ($, e, next) => {
198 await refresh($, true)
199 return next(e)
200 }).catch(($, e, next) => next(e))
201
202 on('command.run', { command: COMMAND }, async $ => ({ text: await toggle($) }))
203
204 // 応答のたびに Claude Code が使用量を知らせてくる
205 on('session.measure', async ($, e, next) => {
206 if (e.changed.includes('context')) {
207 const measured = snapshot(e.context)
208 await update($, usage, () => measured)
209 if (measured) {
210 await update($, history, list => [...list, measured.percent].slice(-HISTORY))
211 }
212
213 const detailed = await $.session.usage({ breakdown: 'summary' })
214 const b = detailed.context.breakdown
215 if (b) {
216 await absorb($, b)
217 }
218 }
219
220 if (e.changed.includes('rateLimits')) {
221 await update($, limits, () => toLimits(e.rateLimits))
222 }
223
224 return next(e)
225 })
226
227 // 長い作業の途中でも天気が変わるように、ツールを呼ぶたびにも測り直す。メモリを読んでいればそれも控える
228 on('tool.call', async ($, e, next) => {
229 const result = await next(e)
230 await noteMemoryRead($, e, result)
231 if (!e.agentId) {
232 await refresh($, false)
233 }
234
235 return result
236 }).catch(($, e, next) => next(e))
237
238 // エンジンがモデルに向けて勝手に差し込む文章(起動時の CLAUDE.md と MEMORY.md、ディレクトリに触れたときの CLAUDE.md、想起)
239 on('prompt.attachment', async ($, e, next) => {
240 await noteInjected($, e.text)
241 return next(e)
242 }).catch(($, e, next) => next(e))
243
244 on('session.end', async ($, e, next) => {
245 if (e.reason === 'clear') {
246 await update($, usage, () => null)
247 await update($, history, () => [])
248 await update($, autoCompactAt, () => null)
249 await update($, memory, () => ({ index: null, reads: [] }))
250 await update($, breakdown, () => null)
251 }
252
253 return next(e)
254 })
255
256 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
257 const { Box, Text, Button } = $.ui.resolve(e)
258 const u = await read($, usage)
259 const trend = await read($, history)
260 const windows = await read($, limits)
261 const compactAt = await read($, autoCompactAt)
262 const mem = await read($, memory)
263 const parts = await read($, breakdown)
264 const opened = await read($, open)
265 const now = await $.clock.now()
266
267 // 枠線 2 と左右の余白 2 を引いた、カードの中の幅
268 const inner = Math.max(12, e.props.bodyColumns - 4)
269 // 枠の名前(7 列)と使用率(5 列)、空白 1 列のあとに残る幅をバーにする
270 const limitBar = Math.max(inner - 13, 4)
271 // 印 2 列、メモリの名前、空白 1 列、日数 4 列
272 const memoryName = Math.max(inner - 7, 6)
273 // 内訳の行: カテゴリ名、空白 1 列、トークン数 5 列、空白 1 列、割合 4 列
274 const categoryName = Math.max(inner - 11, 6)
275
276 // カードの見出しを押すと、そのカードの詳細を開け閉めする。描画中は書けないので押されたときに書く
277 const toggle = (card: Card) => () => void update($, open, o => ({ ...o, [card]: !o[card] }))
278 const mark = (card: Card) => (opened[card] ? '▾' : '▸')
279
280 const weather = u ? weatherFor(u.percent) : undefined
281 const untilCompact = u && compactAt ? Math.max(compactAt - u.tokens, 0) : undefined
282 // 新しく読んだものを上に。開いていれば全部、畳んでいれば上から数件
283 const recent = [...mem.reads].reverse().slice(0, opened.memory ? mem.reads.length : READS_SHOWN)
284 const hidden = mem.reads.length - recent.length
285 const hasAuto = recent.some(r => r.how === 'auto')
286
287 return (
288 <Box flexDirection="column">
289 <Box borderStyle="round" borderColor={weather?.color ?? 'gray'} paddingX={1} flexDirection="column">
290 {u && weather ? (
291 <Box flexDirection="column">
292 <Box justifyContent="space-between">
293 <Button key="weather" plain onPress={toggle('weather')}>
294 <Text bold color={weather.color}>
295 {weather.icon} {weather.label}
296 </Text>{' '}
297 <Text dimColor>{mark('weather')}</Text>
298 </Button>
299 <Text bold color={weather.color}>
300 {u.percent}%
301 </Text>
302 </Box>
303 <Text color={weather.color}>{bar(u.percent, inner)}</Text>
304 <Text>
305 {formatTokens(u.tokens)} / {formatTokens(u.window)} tokens
306 </Text>
307 {untilCompact !== undefined &&
308 (weather.note ? (
309 <Text color="red">
310 {weather.note} ({formatTokens(untilCompact)} left)
311 </Text>
312 ) : (
313 <Text dimColor>{formatTokens(untilCompact)} until auto-compact</Text>
314 ))}
315 {trend.length >= 2 && (
316 <Text dimColor wrap="truncate-end">
317 Trend {sparkline(trend.slice(-(inner - 6)))}
318 </Text>
319 )}
320 </Box>
321 ) : (
322 <Box flexDirection="column">
323 <Button key="weather" plain onPress={toggle('weather')}>
324 <Text dimColor>🌫 Not measured yet</Text> <Text dimColor>{mark('weather')}</Text>
325 </Button>
326 <Text dimColor>Shows after the first reply</Text>
327 </Box>
328 )}
329 {/* 開くと /context と同じ内訳。手元の見積もりなので、上の使用率と合計は一致しないことがある */}
330 {opened.weather &&
331 (parts ? (
332 <Box flexDirection="column" marginTop={1}>
333 {parts.categories.map(c => (
334 <Text wrap="truncate-end" dimColor={c.kind !== 'used'}>
335 {padCells(c.name.slice(0, categoryName), categoryName)} {formatTokens(c.tokens).padStart(5)} {`${percentOf(c.tokens, parts.window)}%`.padStart(4)}
336 </Text>
337 ))}
338 <Text dimColor>of {formatTokens(parts.window)} (est.)</Text>
339 </Box>
340 ) : (
341 <Text dimColor>No breakdown yet</Text>
342 ))}
343 </Box>
344
345 {windows.length > 0 && (
346 <Box borderStyle="round" borderColor={limitColor(Math.max(...windows.map(w => w.percentUsed)))} paddingX={1} flexDirection="column">
347 <Button key="limits" plain onPress={toggle('limits')}>
348 <Text bold>Usage limits</Text> <Text dimColor>{mark('limits')}</Text>
349 </Button>
350 {windows.map(w => (
351 <Box flexDirection="column">
352 <Text wrap="truncate-end">
353 {padCells(limitLabel(w.kind), 7)}
354 <Text color={limitColor(w.percentUsed)}>
355 {`${w.percentUsed}%`.padStart(5)} {bar(w.percentUsed, limitBar)}
356 </Text>
357 </Text>
358 {w.resetsAt && (
359 <Text dimColor wrap="truncate-end">
360 {' '}
361 resets {formatReset(w.resetsAt)}
362 </Text>
363 )}
364 {/* 開くと、リセットまでの残り時間と枠の名前 */}
365 {opened.limits && w.resetsAt && (
366 <Text dimColor wrap="truncate-end">
367 {' '}
368 {formatLeft(Date.parse(w.resetsAt) - now)} left ({w.kind})
369 </Text>
370 )}
371 </Box>
372 ))}
373 </Box>
374 )}
375
376 <Box borderStyle="round" borderColor="magenta" paddingX={1} flexDirection="column">
377 <Button key="memory" plain onPress={toggle('memory')}>
378 <Text bold>🧠 Memory</Text> <Text dimColor>{mark('memory')}</Text>
379 </Button>
380 {mem.index ? (
381 <Box justifyContent="space-between">
382 <Text wrap="truncate-end">Index {mem.index.entries !== undefined ? `${mem.index.entries.length} entries` : 'loaded'}</Text>
383 {mem.index.tokens !== undefined && <Text dimColor>{formatTokens(mem.index.tokens)}</Text>}
384 </Box>
385 ) : (
386 <Text dimColor>Index not in context</Text>
387 )}
388 {/* 開くと、索引に並ぶ項目の名前 */}
389 {opened.memory &&
390 mem.index?.entries?.map(entry => (
391 <Text dimColor wrap="truncate-end">
392 {' · '}
393 {entry.title}
394 </Text>
395 ))}
396 {opened.memory && recent.length > 0 && <Text dimColor>Read this session</Text>}
397 {recent.length === 0 ? (
398 <Text dimColor>No memory file read yet</Text>
399 ) : (
400 recent.map(r => (
401 <Text wrap="truncate-end">
402 {r.how === 'auto' ? '* ' : ' '}
403 {padCells(r.name.slice(0, memoryName), memoryName)} <Text dimColor>{formatAge(r.ageDays).padStart(4)}</Text>
404 </Text>
405 ))
406 )}
407 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
408 {hasAuto && <Text dimColor>* loaded by the engine</Text>}
409 </Box>
410 </Box>
411 )
412 })
413}
414hooks/memory.ts 85 lines1// auto-memory(~/.claude/projects/<プロジェクト>/memory/)と指示ファイルの読み込みを見つける。Claude Code の API には触れない純粋な関数だけを置く
2
3// 1 つめの括弧がパス全体、2 つめがファイル名
4const MEMORY_FILE = /([^\s'"]*\/\.claude\/projects\/[^/\s'"]+\/memory\/([^/\s'"]+\.md))\b/
5const MEMORY_FILES = new RegExp(MEMORY_FILE.source, 'g')
6// エンジンがメモリファイルの Read 結果の先頭に付ける注意書き
7const AGE = /This memory is (\d+) days? old/
8// エンジンが注入する文章の、ファイルごとの見出し
9const CONTENTS_OF = /^Contents of (\S+?) \(([^)]*)\):/gm
10// 索引そのもの。読んだファイルの一覧には出さず、Index の行で示す
11const INDEX = 'MEMORY.md'
12
13/** auto-memory の個別ファイルのパスなら、そのファイル名を返す */
14export function memoryFileName(path: string): string | undefined {
15 const name = MEMORY_FILE.exec(path)?.[2]
16 return name && name !== INDEX ? name : undefined
17}
18
19/** auto-memory の索引(MEMORY.md)のパスなら true */
20export function isMemoryIndex(path: string): boolean {
21 return MEMORY_FILE.exec(path)?.[2] === INDEX
22}
23
24/** 文章の中に現れる auto-memory の個別ファイルのパスを、重複なく返す */
25export function memoryPathsIn(text: string): string[] {
26 const found = new Set<string>()
27 for (const m of text.matchAll(MEMORY_FILES)) {
28 if (m[2] !== INDEX && m[1]) {
29 found.add(m[1])
30 }
31 }
32
33 return [...found]
34}
35
36/** 注入された文章の見出し「Contents of <path> (<説明>):」を、パスと説明の組で返す */
37export function loadedFilesIn(text: string): { path: string; label: string }[] {
38 return [...text.matchAll(CONTENTS_OF)].flatMap(m => (m[1] ? [{ path: m[1], label: m[2] ?? '' }] : []))
39}
40
41/** Read の結果から「N days old」の N を取り出す */
42export function ageDaysIn(text: string | undefined): number | undefined {
43 const m = text ? AGE.exec(text) : null
44 return m ? Number(m[1]) : undefined
45}
46
47// 索引の 1 行。1 つめの括弧が Title、2 つめが file
48const ENTRY = /^\s*- \[([^\]]+)\]\(([^)]+)\)/
49
50/** MEMORY.md に並ぶ項目(- [Title](file.md) — hook)を、並んだ順に返す */
51export function parseEntries(index: string): { title: string; file: string }[] {
52 return index.split('\n').flatMap(line => {
53 const m = ENTRY.exec(line)
54 return m?.[1] && m[2] ? [{ title: m[1], file: m[2] }] : []
55 })
56}
57
58/** MEMORY.md に並ぶ項目を数える */
59export function countEntries(index: string): number {
60 return parseEntries(index).length
61}
62
63/** 「flow-html-structure.md」を「flow-html-structure」にする */
64export function stem(name: string): string {
65 return name.replace(/\.md$/, '')
66}
67
68/** プロジェクト配下なら相対パス、ホーム配下なら ~/ 始まりにする */
69export function shorten(path: string, root: string | undefined, home: string | undefined): string {
70 if (root && path.startsWith(`${root}/`)) {
71 return path.slice(root.length + 1)
72 }
73
74 if (home && path.startsWith(`${home}/`)) {
75 return `~/${path.slice(home.length + 1)}`
76 }
77
78 return path
79}
80
81/** 日数を「48d」にする。不明なら空 */
82export function formatAge(days: number | undefined): string {
83 return days === undefined ? '' : `${days}d`
84}
85hooks/weather.ts 131 lines1// 使用率を天気やバーに置き換える。Claude Code の API には触れない純粋な関数だけを置く
2
3export type Weather = { icon: string; label: string; color: string; note?: string }
4
5const STORM: Weather = { icon: '⛈', label: 'Stormy', color: 'red', note: 'Auto-compact soon' }
6
7// 上から順に、使用率がしきい値未満なら当てはまる。どれにも当たらなければ STORM
8const LEVELS: readonly (Weather & { below: number })[] = [
9 { below: 25, icon: '☀️', label: 'Sunny', color: 'green' },
10 { below: 50, icon: '🌤', label: 'Mostly sunny', color: 'green' },
11 { below: 70, icon: '⛅', label: 'Cloudy', color: 'yellow' },
12 { below: 85, icon: '🌧', label: 'Rainy', color: 'yellow' },
13]
14
15export function weatherFor(percent: number): Weather {
16 const level = LEVELS.find(l => percent < l.below)
17 if (!level) {
18 return STORM
19 }
20
21 const { below: _, ...weather } = level
22 return weather
23}
24
25/** 使用量の枠(5 時間・週)の色。天気より早めに注意を促す */
26export function limitColor(percentUsed: number): string {
27 if (percentUsed < 50) {
28 return 'green'
29 }
30
31 return percentUsed < 80 ? 'yellow' : 'red'
32}
33
34export function formatTokens(n: number): string {
35 if (n < 1000) {
36 return `${n}`
37 }
38
39 if (n < 1_000_000) {
40 return `${Math.round(n / 1000)}k`
41 }
42
43 return `${(n / 1_000_000).toFixed(1)}M`
44}
45
46/** 窓に対する割合を整数の百分率にする。窓が 0 なら 0 */
47export function percentOf(tokens: number, window: number): number {
48 return window > 0 ? Math.round((tokens / window) * 100) : 0
49}
50
51/** 残りミリ秒を「2h 13m」「3d 5h」「12m」にする。過ぎていれば 0m */
52export function formatLeft(ms: number): string {
53 const minutes = Math.max(Math.floor(ms / 60_000), 0)
54 const days = Math.floor(minutes / 1440)
55 const hours = Math.floor((minutes % 1440) / 60)
56 if (days > 0) {
57 return `${days}d ${hours}h`
58 }
59
60 return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
61}
62
63/** width 文字ぶんの横棒。percent が 0〜100 の外でも端で止める */
64export function bar(percent: number, width: number): string {
65 const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
66 return '█'.repeat(filled) + '░'.repeat(Math.max(width - filled, 0))
67}
68
69const SPARKS = '▁▂▃▄▅▆▇█'
70
71/** 0〜100 の値の並びを 1 行の推移グラフにする */
72export function sparkline(values: readonly number[]): string {
73 return values
74 .map(v => SPARKS[Math.min(SPARKS.length - 1, Math.floor((Math.min(Math.max(v, 0), 100) / 100) * SPARKS.length))] ?? '')
75 .join('')
76}
77
78const LIMIT_LABELS: Record<string, string> = {
79 five_hour: '5-hour',
80 seven_day: 'Weekly',
81 spend_limit: 'Spend',
82}
83
84export function limitLabel(kind: string): string {
85 return LIMIT_LABELS[kind] ?? kind
86}
87
88/** 枠を 5 時間・週・その他の順に並べる */
89export function limitOrder(kind: string): number {
90 const order = Object.keys(LIMIT_LABELS).indexOf(kind)
91 return order === -1 ? Object.keys(LIMIT_LABELS).length : order
92}
93
94const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
95
96/** ISO 8601 の時刻を「Thu 10/8 03:10」の形にする */
97export function formatReset(iso: string): string {
98 const d = new Date(iso)
99 if (Number.isNaN(d.getTime())) {
100 return iso
101 }
102
103 const hh = `${d.getHours()}`.padStart(2, '0')
104 const mm = `${d.getMinutes()}`.padStart(2, '0')
105 return `${WEEKDAYS[d.getDay()]} ${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`
106}
107
108// 全角(東アジアの広い文字)は 2 列ぶんとして数える
109function cells(text: string): number {
110 let width = 0
111 for (const ch of text) {
112 const code = ch.codePointAt(0) ?? 0
113 const isWide =
114 (code >= 0x1100 && code <= 0x115f) ||
115 (code >= 0x2e80 && code <= 0xa4cf) ||
116 (code >= 0xac00 && code <= 0xd7a3) ||
117 (code >= 0xf900 && code <= 0xfaff) ||
118 (code >= 0xfe30 && code <= 0xfe4f) ||
119 (code >= 0xff00 && code <= 0xff60) ||
120 (code >= 0xffe0 && code <= 0xffe6)
121 width += isWide ? 2 : 1
122 }
123
124 return width
125}
126
127/** 表示幅が width 列になるまで右に空白を足す */
128export function padCells(text: string, width: number): string {
129 return text + ' '.repeat(Math.max(width - cells(text), 0))
130}
131types/index.d.ts 47 lines1// 最後に測ったコンテキストの使用状況
2export type Usage = { percent: number; tokens: number; window: number }
3
4// 使用量の枠(five_hour、seven_day など)。resetsAt は ISO 8601
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7// 索引(MEMORY.md)の 1 項目: `- [Title](file.md) — hook` の Title と file
8export type IndexEntry = { title: string; file: string }
9
10// コンテキストに載っている auto-memory の索引(MEMORY.md)。entries は索引に並ぶ項目(読めなければ undefined)
11export type MemoryIndex = { path: string; tokens?: number; entries?: IndexEntry[] }
12
13// このセッションが読んだファイル。how は読まれ方: tool = Claude が Read や Bash で読んだ、
14// auto = エンジンが勝手に載せた(起動時の読み込み、ディレクトリに触れたときの CLAUDE.md、想起、エンジン自身のメモリ処理)。
15// ageDays はエンジンが Read の結果に付ける「N days old」
16export type MemoryRead = { name: string; path: string; at: number; how: 'tool' | 'auto'; ageDays?: number }
17
18// 過去セッションの記憶(auto-memory)と指示ファイルのうち、このセッションが読んだもの
19export type Memory = { index: MemoryIndex | null; reads: MemoryRead[] }
20
21// /context が出す内訳の 1 行。deferred(窓の外にある遅延ロードのツール定義)は載せない
22export type Category = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
23
24// 内訳と、それを測った窓(圧縮の窓。モデルの窓より小さいことがある)
25export type Breakdown = { categories: Category[]; window: number }
26
27// ペインのカード。押すと詳細が開く
28export type Card = 'weather' | 'limits' | 'memory'
29
30// どのカードが開いているか
31export type Open = Record<Card, boolean>
32
33declare module 'claude-code' {
34 interface PluginState {
35 'token-weather': {
36 usage: Usage | null
37 history: number[]
38 limits: Limit[]
39 // 自動圧縮が始まるトークン数(オフなら null)
40 autoCompactAt: number | null
41 memory: Memory
42 breakdown: Breakdown | null
43 open: Open
44 }
45 }
46}
47