入力欄の下に 5h / 週 / Fable の上限とコンテキスト使用率を % で表示する

curl -fsSL https://raw.githubusercontent.com/tett23/dotfiles/HEAD/install.sh | sh
これだけで以下が完了します (冪等。失敗したら同じコマンドを再実行)。
~/dotfiles への clone (HTTPS) と submodule の取得bootstrap.sh: Nix → nix-darwin / home-manager / GUI アプリ / dotfiles のリンク (途中で sudo のパスワードを聞かれます)mise install: 言語 / ツールDOTFILES / DOTFILES_REPO / DOTFILES_BRANCH / DOTFILES_REMOTE 環境変数で clone 先などを上書きできます。
clone は SSH 鍵が無くてもできるよう HTTPS で行い、その後 origin を SSH の URL (git@github.com:tett23/dotfiles.git) に切り替えます。手元で git pull / push するには SSH 鍵の設定が必要です (install.sh の再実行は SSH 鍵が無くても動きます)。
現在の仕様は docs/specifications.md、依存の一覧は dependencies.md、 設計判断は docs/adr を参照。
hooks/register.tsx 140 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Engine,
4 Register,
5 RenderElement,
6 SessionRateLimit,
7 TextProps,
8} from 'claude-code'
9
10import type { Limit, Snapshot } from '../types'
11import type { Segment } from './format'
12import {
13 SEPARATOR,
14 estimatePercent,
15 parseLimits,
16 restoreLimits,
17 usageSegments,
18} from './format'
19
20const EMPTY: Snapshot = { contextPercent: null, rateLimits: [] }
21
22const snapshot = atom({ plugin: 'usage-hint', key: 'snapshot' } as const, EMPTY)
23
24const STORE_KEY = 'rateLimits'
25
26const toLimits = (rateLimits: readonly SessionRateLimit[]): Limit[] =>
27 rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
28
29// 最初の API 応答の前は、窓を前回保存した値から、コンテキストをローカルの推定から作る
30// (docs/adr/0008)
31const initialSnapshot = async ($: Engine): Promise<Snapshot> => {
32 const { context, rateLimits } = await $.session.usage()
33 const contextPercent =
34 context.percent ??
35 (await $.session
36 .usage({ breakdown: 'summary' })
37 .then(({ context: estimated }) =>
38 estimated.breakdown
39 ? estimatePercent(estimated.breakdown.totalTokens, estimated.window)
40 : null,
41 )
42 .catch(() => null))
43 const limits =
44 rateLimits.length > 0
45 ? toLimits(rateLimits)
46 : restoreLimits(parseLimits(await $.store.get(STORE_KEY)), await $.clock.now())
47
48 return { contextPercent, rateLimits: limits }
49}
50
51// 75% を超えた項目は黄色、それ以外は dim (docs/adr/0005)。ラベルは太字 (docs/adr/0006)
52const toText =
53 (Text: (props: TextProps & { children?: unknown }) => RenderElement) =>
54 ({ label, value, isWarning }: Segment): RenderElement => (
55 <Text {...(isWarning ? { color: 'yellow' } : { dimColor: true })}>
56 <Text bold>{label}</Text>
57 {` ${value}`}
58 </Text>
59 )
60
61const interleave = <T,>(items: readonly T[], separator: T): T[] =>
62 items.flatMap((item, index) => (index === 0 ? [item] : [separator, item]))
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 await $.command.register({
67 name: 'usage-hint',
68 description: 'usage-hint が受け取ったレートリミット窓をそのまま表示する',
69 })
70
71 const result = await next(e)
72 const initial = await initialSnapshot($).catch(() => undefined)
73 if (initial) {
74 await update($, snapshot, () => initial)
75 }
76
77 return result
78 })
79
80 on('session.measure', async ($, e, next) => {
81 if (e.rateLimits.length > 0) {
82 await $.store.set(STORE_KEY, toLimits(e.rateLimits))
83 }
84 // 窓が空の報告では直前の窓を残す (docs/adr/0008)
85 await update($, snapshot, previous => ({
86 contextPercent: e.context.percent ?? null,
87 rateLimits: e.rateLimits.length > 0 ? toLimits(e.rateLimits) : previous.rateLimits,
88 }))
89
90 return next(e)
91 })
92
93 on('command.run', { command: 'usage-hint' }, async $ => {
94 const { rateLimits, contextPercent } = await read($, snapshot)
95 const rows = rateLimits.map(
96 ({ kind, percentUsed, resetsAt }) =>
97 `- ${kind}: ${percentUsed}%${resetsAt ? ` (reset ${resetsAt})` : ''}`,
98 )
99
100 return {
101 text: [
102 `context: ${contextPercent ?? '--'}%`,
103 rows.length === 0 ? 'rateLimits: (まだ受信していません)' : 'rateLimits:',
104 ...rows,
105 ].join('\n'),
106 }
107 })
108
109 // terminal: 項目ごとに色を付けるため tail ではなくヒント行ごと描く (docs/adr/0005)
110 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
111 if (e.surface !== 'terminal') {
112 return next(e)
113 }
114 const { Box, Text } = $.ui.resolve(e)
115 const segments = usageSegments(await read($, snapshot))
116 const hint = e.props.hint === '' ? [] : [<Text dimColor>{e.props.hint}</Text>]
117
118 return (
119 <Box flexDirection="row">
120 {interleave([...hint, ...segments.map(toText(Text))], <Text dimColor>{SEPARATOR}</Text>)}
121 </Box>
122 )
123 })
124
125 // desktop は PromptHint を描かないため、入力欄のすぐ上の帯に出す (docs/adr/0004)
126 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
127 if (e.surface === 'terminal' || e.props.hasSurvey) {
128 return next(e)
129 }
130 const { Box, Text } = $.ui.resolve(e)
131 const segments = usageSegments(await read($, snapshot))
132
133 return (
134 <Box flexDirection="row">
135 {interleave(segments.map(toText(Text)), <Text dimColor>{SEPARATOR}</Text>)}
136 </Box>
137 )
138 })
139}
140hooks/format.ts 65 lines1import type { Limit, Snapshot } from '../types'
2
3export const SEPARATOR = ' · '
4
5const WARNING_PERCENT = 75
6
7export type Segment = { label: string; value: string; isWarning: boolean }
8
9const byKind =
10 (kind: string) =>
11 (limits: readonly Limit[]): Limit | undefined =>
12 limits.find(limit => limit.kind === kind)
13
14// Fable の込み使用量は Fable へのリクエスト時だけ API が返す
15// seven_day_overage_included 窓と推定する。fable を含む kind があればそちらを優先する
16// (docs/adr/0004)
17export const fableLimit = (limits: readonly Limit[]): Limit | undefined =>
18 limits.find(limit => limit.kind.includes('fable')) ??
19 byKind('seven_day_overage_included')(limits)
20
21const segment = (label: string, value: number | null | undefined): Segment =>
22 value === null || value === undefined
23 ? { label, value: '--', isWarning: false }
24 : { label, value: `${Math.round(value)}%`, isWarning: value > WARNING_PERCENT }
25
26export const usageSegments = ({ contextPercent, rateLimits }: Snapshot): Segment[] => [
27 segment('5h', byKind('five_hour')(rateLimits)?.percentUsed),
28 segment('7d', byKind('seven_day')(rateLimits)?.percentUsed),
29 segment('Fable', fableLimit(rateLimits)?.percentUsed),
30 segment('ctx', contextPercent),
31]
32
33export const formatUsage = (snapshot: Snapshot): string =>
34 usageSegments(snapshot)
35 .map(({ label, value }) => `${label} ${value}`)
36 .join(SEPARATOR)
37
38const isLimit = (value: unknown): value is Limit => {
39 if (typeof value !== 'object' || value === null) {
40 return false
41 }
42 const { kind, percentUsed, resetsAt } = value as Record<string, unknown>
43
44 return (
45 typeof kind === 'string' &&
46 typeof percentUsed === 'number' &&
47 (resetsAt === undefined || typeof resetsAt === 'string')
48 )
49}
50
51// $.store の値は unknown なので、形の正しい窓だけを取り出す
52export const parseLimits = (stored: unknown): Limit[] =>
53 Array.isArray(stored) ? stored.filter(isLimit) : []
54
55// リセット時刻を過ぎた窓は使用量 0、次のリセット時刻は不明として扱う (docs/adr/0008)
56export const restoreLimits = (limits: readonly Limit[], now: number): Limit[] =>
57 limits.map(limit =>
58 limit.resetsAt !== undefined && Date.parse(limit.resetsAt) <= now
59 ? { kind: limit.kind, percentUsed: 0 }
60 : limit,
61 )
62
63export const estimatePercent = (tokens: number, window: number): number | null =>
64 window > 0 ? Math.round((tokens / window) * 100) : null
65types/index.d.ts 13 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Snapshot = {
4 contextPercent: number | null
5 rateLimits: Limit[]
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'usage-hint': { snapshot: Snapshot }
11 }
12}
13