SLOPSHOPPER

usage-hint

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

newbandspinnercommand
v0.1.0no licenseupdated 2026-10-09tett23/dotfiles/claude/mods/usage-hint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-hint
› 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 › /usage-hint ⎿ usage-hint: context: 49% ⎿ usage-hint: rateLimits: ⎿ usage-hint: - five_hour: 31% (reset 1760003600000) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts · 5h 31% · 7d -- · Fable -- · ctx 49%

Draws

Prompt hint
? for shortcuts · 5h 31% · 7d -- · Fable -- · ctx 49%
README

dotfiles

Install

curl -fsSL https://raw.githubusercontent.com/tett23/dotfiles/HEAD/install.sh | sh

これだけで以下が完了します (冪等。失敗したら同じコマンドを再実行)。

  1. Xcode Command Line Tools
  2. ~/dotfiles への clone (HTTPS) と submodule の取得
  3. bootstrap.sh: Nix → nix-darwin / home-manager / GUI アプリ / dotfiles のリンク (途中で sudo のパスワードを聞かれます)
  4. 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 を参照。

Source 3 files
hooks/register.tsx 140 lines
1import { 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}
140
hooks/format.ts 65 lines
1import 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
65
types/index.d.ts 13 lines
1export 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