SLOPSHOPPER

usage-status

One line above the prompt: 5h/weekly usage with reset times, context %, repo, model and effort

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-status
› 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 Current session 31% · 9:53am Context 49% app Opus 5.5 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Current session 31% · 9:53am Context 49% app Opus 5.5
README

usage-status

A minimal Claude Code mod: one line above the prompt.

Current session 56% · 1:50pm   Current week 69% · 9am   Context 11%   my-repo Opus 5.5 (1M) · high
  • Current session / Current week: the 5-hour and weekly limits, worded as /usage does, with the reset time
  • Context: how full this conversation's context window is
  • repo · model · effort: the git repository's folder name, the main loop's model, and the effort actually sent

Percentages stay the default color below 40%, turn the theme's warning (yellow) at 40% and orange at 80%. No dependencies, no network, no file access: it only reads the session's own figures.

Notes

  • Mods are early access in Claude Code; the API may change between releases. Built and tested on 2.1.289.
  • Usage limits appear on Pro/Max subscriptions only. With an API key you get Context and repo/model/effort.
  • Effort updates on the next request, not the moment you run /effort (there is no event for it).
  • Context is hidden until the first response, and right after a compaction.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 149 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Where } from '../types'
5
6// 제목·리셋 시각 = /usage 화면(2.1.289 바이너리 실측)에서 따오고, 막대·'used'·시간대는 뺀 한 줄 요약.
7const TITLES: Record<string, string> = { five_hour: 'Current session', seven_day: 'Current week' }
8const YELLOW_AT = 40
9const ORANGE_AT = 80
10const ORANGE = '#ff8700' // 테마에 주황 키가 없어 고정색(xterm 208)
11const GAP = 3 // 묶음 사이 칸
12const MIN_REPO = 4 // 이보다 짧게 줄여야 하면 줄이지 않고 다음 줄로 넘김
13
14const limits = atom({ plugin: 'usage-status', key: 'limits' } as const, [] as Limit[])
15// 컨텍스트 창 사용률(%) — 첫 응답 전·압축 직후엔 null
16const context = atom({ plugin: 'usage-status', key: 'context' } as const, null as number | null)
17// 레포 이름·모델·effort — effort 는 첫 요청(turn.step) 전엔 모름
18const where = atom({ plugin: 'usage-status', key: 'where' } as const, {} as Where)
19
20// 3단계: 40% 미만 기본색, 40% 이상 노랑(테마 warning), 80% 이상 주황
21function toneOf(pct: number): string | undefined {
22  if (pct >= ORANGE_AT) return ORANGE
23  if (pct >= YELLOW_AT) return 'warning'
24  return undefined
25}
26
27// 'claude-opus-5-5[1m]' → 'Opus 5.5 (1M)', 모르는 형식은 그대로
28function modelName(id: string): string {
29  const m = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(\[1m\])?$/.exec(id)
30  if (!m) return id
31  const [, family = '', major, minor, long] = m
32  return `${family[0]?.toUpperCase()}${family.slice(1)} ${major}.${minor}${long ? ' (1M)' : ''}`
33}
34
35// 리셋 시각: 24시간 이내 '3pm'·'3:30pm', 넘으면 'Oct 9 at 3pm'(해가 다르면 연도)
36function formatReset(iso: string, now: Date): string {
37  const at = new Date(iso)
38  const minute = at.getMinutes() === 0 ? undefined : '2-digit'
39  const text =
40    (at.getTime() - now.getTime()) / 3_600_000 > 24
41      ? at.toLocaleString('en-US', {
42          month: 'short',
43          day: 'numeric',
44          hour: 'numeric',
45          minute,
46          hour12: true,
47          year: at.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
48        })
49      : at.toLocaleTimeString('en-US', { hour: 'numeric', minute, hour12: true })
50  return text.replace(/[  ]([AP]M)/i, (_, ampm: string) => ampm.toLowerCase())
51}
52
53// 좁은 화면에서 레포 묶음이 혼자 다음 줄로 밀릴 상황이면, 앞 줄에 붙도록 레포 이름을 '…'로 줄인다.
54// before = 앞 묶음들 너비, rest = 레포 뒤 모델·effort 너비. flexWrap 과 같은 순서로 줄을 채워 본다.
55// ponytail: 너비 = 글자 수(한글 폴더명은 2칸이라 오차) — 한글 폴더 쓰면 전각 너비 계산 추가
56function fitRepo(repo: string, before: number[], rest: number, columns: number): string {
57  let line = 0
58  for (const w of before) line = line === 0 || line + GAP + w > columns ? w : line + GAP + w
59  const room = columns - (line === 0 ? 0 : line + GAP) - rest
60  if (repo.length <= room || room < MIN_REPO) return repo
61  return `${repo.slice(0, room - 1)}…`
62}
63
64// 레포 루트 폴더명 (git 밖이면 세션 루트)
65async function repoName($: EngineInterface): Promise<string | undefined> {
66  const root = (await $.session.repo())?.root ?? (await $.session.root())
67  return root.split('/').pop()
68}
69
70export const register: Register = on => {
71  // 리로드·재개 직후에도 다음 측정까지 비어 있지 않게 한 번 채운다
72  on('session.start', async ($, e, next) => {
73    const result = await next(e)
74    const usage = await $.session.usage()
75    await update($, limits, () => [...usage.rateLimits])
76    await update($, context, () => usage.context.percent ?? null)
77    const repo = await repoName($)
78    const model = await $.session.model()
79    await update($, where, w => ({ ...w, repo, model }))
80    return result
81  })
82
83  // 메인 루프가 실제로 보내는 모델·effort (서브에이전트 요청은 제외)
84  // /clear 는 상태를 비우지만 session.start 가 다시 오지 않음 → 레포가 비었으면 여기서 다시 채운다
85  on('turn.step', async function* ($, e, next) {
86    if (!e.agentId) {
87      const effort = e.effort === undefined ? undefined : String(e.effort)
88      const repo = (await read($, where)).repo ?? (await repoName($))
89      await update($, where, w =>
90        w.model === e.model && w.effort === effort && w.repo === repo ? w : { ...w, repo, model: e.model, effort },
91      )
92    }
93    return yield* next(e)
94  })
95
96  on('session.measure', async ($, e, next) => {
97    if (e.changed.includes('rateLimits')) {
98      await update($, limits, () => [...e.rateLimits])
99    }
100    if (e.changed.includes('context')) {
101      await update($, context, () => e.context.percent ?? null)
102    }
103    return next(e)
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    const items: { title: string; pct: number; resetsAt?: string }[] = (await read($, limits))
108      .filter(l => TITLES[l.kind])
109      .map(l => ({ title: TITLES[l.kind] ?? '', pct: Math.floor(l.percentUsed), resetsAt: l.resetsAt }))
110    const ctx = await read($, context)
111    if (ctx !== null) items.push({ title: 'Context', pct: Math.floor(ctx) })
112    const { repo, model, effort } = await read($, where)
113    if (e.props.hasSurvey || (items.length === 0 && !repo)) {
114      return next(e)
115    }
116
117    const now = new Date(await $.clock.now())
118    const { Box, Text } = $.ui.resolve(e)
119    const resets = items.map(({ resetsAt }) => (resetsAt ? `· ${formatReset(resetsAt, now)}` : ''))
120    const widths = items.map(({ title, pct }, i) => `${title} ${pct}%`.length + (resets[i] ? 1 + resets[i]!.length : 0))
121    const tail = [model ? modelName(model) : '', effort ? `· ${effort}` : ''].filter(Boolean)
122    const shownRepo = repo ? fitRepo(repo, widths, tail.reduce((n, t) => n + 1 + t.length, 0), e.props.bodyColumns) : ''
123
124    // 'Current session 56% · 1:50pm   Current week 69% · 9am   Context 11%   my-repo Opus 5.5 (1M) · high' 한 줄
125    // 좁은 화면(모바일)에선 그룹을 누르지 않고(flexShrink 0) 그룹 단위로 다음 줄로 넘긴다(flexWrap)
126    return (
127      <Box key="line" flexDirection="row" flexWrap="wrap" columnGap={3}>
128        {items.map(({ title, pct }, i) => {
129          const tone = toneOf(pct)
130          return (
131            <Box key={title} flexDirection="row" flexShrink={0} gap={1}>
132              <Text bold>{title}</Text>
133              <Text {...(tone ? { color: tone } : {})}>{`${pct}%`}</Text>
134              {resets[i] ? <Text dimColor>{resets[i]}</Text> : null}
135            </Box>
136          )
137        })}
138        {repo ? (
139          <Box key="where" flexDirection="row" flexShrink={0} gap={1}>
140            <Text bold>{shownRepo}</Text>
141            {model ? <Text>{modelName(model)}</Text> : null}
142            {effort ? <Text dimColor>{`· ${effort}`}</Text> : null}
143          </Box>
144        ) : null}
145      </Box>
146    )
147  })
148}
149
types/index.d.ts 12 lines
1// session.measure 의 rateLimits 한 칸 (SessionRateLimit 과 같은 모양)
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3// 레포 이름·모델 ID·effort (session.start·turn.step 에서 채움)
4export type Where = { repo?: string; model?: string; effort?: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    // context = 컨텍스트 창 사용률(%), 첫 응답 전·압축 직후 null
9    'usage-status': { limits: Limit[]; context: number | null; where: Where }
10  }
11}
12