SLOPSHOPPER

usage-band

입력창 위 오른쪽에 5시간·주간 사용량과 Claude status 를 한 줄로 보여 준다

newbandtoastnetworktimer
v0.1.0MITupdated 2026-10-16wonjangcloud9/wj-mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 5시간 ━━──── 31% ● Claude status 확인 불가 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5시간 ━━──── 31% ● Claude status 확인 불가 ⟨Claude Code's own drawing⟩
README

wj-mods

English · 한국어

A set of Claude Code mods that change the terminal UI and notifications. They are plugins with hook modules, installable one by one from this marketplace.

The UI text, notifications and summaries are written in Korean.

ModWhat it does
work-progressCollects multi-step work from every open session into a line above the prompt and a /progress pane. Each task's steps are drawn as a chain of nodes that opens into a vertical flow. It also keeps follow-ups left after a task is done, what a paused task is waiting for, and subagent records that ended without being closed. Each open task shows the time likely left, judged from how long its own steps and your recent tasks' steps took. /progress 요약 writes a summary of the last 24 hours.
plain-koreanA Korean readability bundle. An output style for short, plain Korean answers (conclusion first, no made-up abbreviations, nothing referred to by number alone), a guard that flags answers that slip into English and corrects the next one, a nudge to re-explain when you type "뭔 말" or "짧게", and the claude-warm theme. Installing it also installs plain-summary, turn-divider and compact-tool-rows.
usage-bandShows 5-hour and weekly usage and the Claude status page state in one line above the prompt.
long-turn-notifySends a macOS notification and plays a voice line when a turn ran longer than 2 minutes, or when a question or permission prompt is waiting. macOS only.
session-nicknameGives each new session a random name so other sessions can address it with SendMessage. The name reaches the session list with the first prompt.
turn-dividerDraws a divider above each of your prompts to separate questions from answers.
plain-summaryAdds a two-sentence plain-language summary under long, report-style answers, written by a small model.
compact-tool-rowsShortens long Bash commands in tool rows to the start of the first line.

work-progress pane:

업무 3개 · ● 작업 중 1 · □ 남은 일 1 · ✓ 오늘 완료 1

── ● 작업 중 1 ─────────────────────────────────────────────
▸ Theme builder spec
  ●━●━◉─○─○─○ 2/6 · 약 2시간 남음 · 이 단계 1시간째 · 5분 전 · @quiet-otter
  지금: Write the requirements
  최근: Bash Run the mod's tests · 방금
  └ ● Benchmark AI app builders 1/8 · 최근: WebSearch Lovable docs
  └ ‖ Seed starter audit 3/4 · 보류로 끝남 · only saving the file is left
  └ ✓ 서브에이전트 5개 끝남

── □ 남은 일 1 ─────────────────────────────────────────────
▸ Fix lost attachments · 1일 전
  □ push
── ✓ 오늘 완료 1 ───────────────────────────────────────────
▸ Release notes draft · 2시간 전

Click a task to open its steps as a flow, with the time each step took and its evidence:

▾ Theme builder spec
  ● Read the brief · 40분 — notes in brief.md
  ┃
  ● Benchmark · 1시간 20분 — 3 reports
  ┃
  ◉ Write the requirements  ← 지금
  │
  ○ Design

Install

From the marketplace:

claude plugin marketplace add wonjangcloud9/wj-mods
claude plugin install work-progress@wj-mods

Install other mods the same way with <mod>@wj-mods. They load in sessions started after installing.

The Korean readability bundle installs plain-summary, turn-divider and compact-tool-rows with it:

claude plugin install plain-korean@wj-mods

The output style and the theme are not switched on for you. Choose plain-korean as the output style in /config, and claude-warm in /theme if you want the colors too.

From a local clone, list the mod folders in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, separated by :. Folders listed this way do not pull in dependencies, so list the bundle's three mods as well:

git clone https://github.com/wonjangcloud9/wj-mods.git ~/wj-mods
{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/me/wj-mods/work-progress:/Users/me/wj-mods/usage-band"
  }
}

Notes

  • Tested with Claude Code 2.1.301.
  • long-turn-notify records its voice lines with the macOS say Yuna voice on first use and keeps them in ~/.claude/long-turn-notify/sounds/. Without that voice it shows the notification silently.
  • plain-summary calls a small model once per long answer, which counts toward your usage.
  • work-progress keeps its records in ~/.claude/work-progress/ on your machine.
  • Run a mod's tests with claude plugin test <mod folder>.

License

MIT

Source 4 files
hooks/register.tsx 118 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClaudeStatus, Limit } from '../types'
5import { crossedAlerts, toSent } from './alerts'
6import { STATUS_TEXT, meter, parseStatus, sortLimits, toLimits } from './format'
7
8const limits = atom({ plugin: 'usage-band', key: 'limits' } as const, [])
9const claude = atom({ plugin: 'usage-band', key: 'claude' } as const, { level: 'pending' })
10const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
11
12const STATUS_URL = 'https://status.claude.com/api/v2/summary.json'
13const STATUS_EVERY_MS = 5 * 60_000
14const TICK_MS = 60_000
15const LIMITS_KEY = 'limits'
16const ALERTS_KEY = 'alerts'
17const ALERT_TOAST_MS = 10_000
18const CHIP_BAR_WIDTH = 6
19const CHIP_BAR_MIN_COLUMNS = 72
20
21const STATUS_COLOR: Record<ClaudeStatus['level'], string> = {
22  pending: 'subtle',
23  ok: 'success',
24  minor: 'warning',
25  major: 'error',
26  maintenance: 'suggestion',
27  unknown: 'subtle',
28}
29
30async function alertCrossed($: EngineInterface, live: Limit[]): Promise<void> {
31  const { alerts, sent } = crossedAlerts(live, toSent(await $.store.get(ALERTS_KEY)), await $.clock.now())
32  await $.store.set(ALERTS_KEY, sent)
33  for (const text of alerts) $.ui.toast(text, { timeoutMs: ALERT_TOAST_MS })
34}
35
36async function saveLimits($: EngineInterface, live: Limit[]): Promise<void> {
37  if (live.length === 0) return
38  await update($, limits, () => live)
39  await $.store.set(LIMITS_KEY, live)
40  await alertCrossed($, live)
41}
42
43async function loadLimits($: EngineInterface): Promise<void> {
44  const live = toLimits((await $.session.usage()).rateLimits)
45  if (live.length > 0) return saveLimits($, live)
46
47  const stored = toLimits(await $.store.get(LIMITS_KEY))
48  if (stored.length > 0) await update($, limits, () => stored)
49}
50
51async function refreshStatus($: EngineInterface): Promise<void> {
52  const status = await $.http.fetch(STATUS_URL).then(
53    res => (res.ok ? parseStatus(res.text) : { level: 'unknown' as const }),
54    () => ({ level: 'unknown' as const }),
55  )
56  await update($, claude, () => status)
57}
58
59async function tick($: EngineInterface): Promise<void> {
60  const at = await $.clock.now()
61  await update($, now, () => at)
62}
63
64export const register: Register = on => {
65  on('session.start', async ($, e, next) => {
66    await Promise.all([tick($), loadLimits($)])
67    void refreshStatus($)
68    $.clock.every(TICK_MS, () => void tick($))
69    $.clock.every(STATUS_EVERY_MS, () => void refreshStatus($))
70
71    return next(e)
72  })
73
74  on('session.measure', async ($, e, next) => {
75    if (e.changed.includes('rateLimits')) await saveLimits($, toLimits(e.rateLimits))
76
77    return next(e)
78  })
79
80  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81    const below = await next(e)
82    if (e.props.hasSurvey) return below
83
84    const { Box, Text } = $.ui.resolve(e)
85    const [list, status, at] = await Promise.all([read($, limits), read($, claude), read($, now)])
86    const columns = e.props.bodyColumns
87    const hasBars = columns >= CHIP_BAR_MIN_COLUMNS
88    const meters = sortLimits(list).map(limit => meter(limit, at, CHIP_BAR_WIDTH))
89
90    const chip = (
91      <Box flexDirection="row" columnGap={2}>
92        {meters.length === 0 ? <Text dimColor>사용량 대기 중</Text> : ''}
93        {meters.map(m => (
94          <Text>
95            <Text bold>{m.label}</Text>{' '}
96            {hasBars ? <Text color={m.tone}>{m.filled}</Text> : ''}
97            {hasBars ? <Text dimColor>{`${m.rest} `}</Text> : ''}
98            <Text color={m.tone} bold>{`${m.percent}%`}</Text>
99            {m.reset ? <Text dimColor>{` ${m.reset}`}</Text> : ''}
100          </Text>
101        ))}
102        <Text>
103          <Text color={STATUS_COLOR[status.level]}>●</Text> {status.level === 'ok' ? '정상' : STATUS_TEXT[status.level]}
104        </Text>
105      </Box>
106    )
107
108    return (
109      <Box flexDirection="column">
110        <Box flexDirection="row" justifyContent="flex-end" width={columns}>
111          {chip}
112        </Box>
113        {below}
114      </Box>
115    )
116  })
117}
118
hooks/alerts.ts 31 lines
1import type { Limit } from '../types'
2import { hasReset, limitLabel, untilReset } from './format'
3
4export type Sent = Record<string, number>
5
6const THRESHOLDS = [80, 95]
7
8export function toSent(value: unknown): Sent {
9  if (typeof value !== 'object' || value === null) return {}
10  return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'number'))
11}
12
13// 창마다 이미 알린 가장 높은 기준을 기억한다. 창이 초기화돼 사용률이 내려가면 기준도 내려가 다음에 다시 알린다.
14export function crossedAlerts(limits: readonly Limit[], sent: Sent, now: number): { alerts: string[]; sent: Sent } {
15  const next: Sent = { ...sent }
16  const alerts: string[] = []
17
18  for (const limit of limits) {
19    if (hasReset(limit, now)) continue
20
21    const crossed = Math.max(0, ...THRESHOLDS.filter(t => limit.percentUsed >= t))
22    if (crossed > (sent[limit.kind] ?? 0)) {
23      const reset = untilReset(limit.resetsAt, now)
24      alerts.push(`${limitLabel(limit.kind)} 사용량이 ${crossed}%를 넘었습니다${reset ? ` · ${reset} 뒤 초기화` : ''}`)
25    }
26    next[limit.kind] = crossed
27  }
28
29  return { alerts, sent: next }
30}
31
hooks/format.ts 116 lines
1import type { ClaudeStatus, ClaudeStatusLevel, Limit } from '../types'
2
3export type Tone = 'success' | 'warning' | 'error'
4
5export type Meter = { label: string; percent: number; tone: Tone; filled: string; rest: string; reset?: string }
6
7const LABEL: Record<string, string> = { five_hour: '5시간', seven_day: '주간', spend_limit: '지출 한도' }
8const ORDER = ['five_hour', 'seven_day']
9const WATCHED_COMPONENTS = ['Claude Code', 'Claude API (api.anthropic.com)', 'claude.ai']
10const OPEN_INCIDENT = ['investigating', 'identified', 'monitoring']
11
12export const STATUS_TEXT: Record<ClaudeStatusLevel, string> = {
13  pending: 'Claude status 확인 중',
14  ok: 'Claude 정상',
15  minor: 'Claude 일부 장애',
16  major: 'Claude 장애',
17  maintenance: 'Claude 점검 중',
18  unknown: 'Claude status 확인 불가',
19}
20
21export function limitLabel(kind: string): string {
22  return LABEL[kind] ?? kind
23}
24
25export function sortLimits(limits: readonly Limit[]): Limit[] {
26  const rank = (kind: string) => (ORDER.includes(kind) ? ORDER.indexOf(kind) : ORDER.length)
27  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
28}
29
30export function toLimits(value: unknown): Limit[] {
31  if (!Array.isArray(value)) return []
32  return value.flatMap(item => {
33    const { kind, percentUsed, resetsAt } = (item ?? {}) as Record<string, unknown>
34    if (typeof kind !== 'string' || typeof percentUsed !== 'number') return []
35    return [typeof resetsAt === 'string' ? { kind, percentUsed, resetsAt } : { kind, percentUsed }]
36  })
37}
38
39export function hasReset(limit: Limit, now: number): boolean {
40  const at = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
41  return !Number.isNaN(at) && at <= now
42}
43
44export function tone(percent: number): Tone {
45  if (percent >= 90) return 'error'
46  if (percent >= 70) return 'warning'
47  return 'success'
48}
49
50export function bar(percent: number, width: number): { filled: string; rest: string } {
51  const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
52  return { filled: '━'.repeat(filled), rest: '─'.repeat(width - filled) }
53}
54
55export function meter(limit: Limit, now: number, width: number): Meter {
56  const percent = hasReset(limit, now) ? 0 : Math.round(limit.percentUsed)
57  const reset = untilReset(limit.resetsAt, now)
58  return {
59    label: limitLabel(limit.kind),
60    percent,
61    tone: tone(percent),
62    ...bar(percent, width),
63    ...(reset ? { reset } : {}),
64  }
65}
66
67export function untilReset(resetsAt: string | undefined, now: number): string | undefined {
68  const at = resetsAt ? Date.parse(resetsAt) : NaN
69  if (Number.isNaN(at)) return undefined
70
71  const minutes = Math.ceil((at - now) / 60_000)
72  if (minutes <= 0) return undefined
73
74  const days = Math.floor(minutes / 1440)
75  const hours = Math.floor((minutes % 1440) / 60)
76  const rest = minutes % 60
77  if (days > 0) return `${days}d${hours}h`
78  if (hours > 0) return `${hours}h${rest}m`
79  return `${rest}m`
80}
81
82type Summary = {
83  status?: { indicator?: string; description?: string }
84  incidents?: { name?: string; status?: string }[]
85  components?: { name?: string; status?: string }[]
86}
87
88export function parseStatus(text: string): ClaudeStatus {
89  let summary: Summary
90  try {
91    summary = JSON.parse(text) as Summary
92  } catch {
93    return { level: 'unknown' }
94  }
95
96  const incident = summary.incidents?.find(i => OPEN_INCIDENT.includes(i.status ?? ''))?.name
97  const degraded = (summary.components ?? [])
98    .filter(c => WATCHED_COMPONENTS.includes(c.name ?? '') && c.status !== 'operational')
99    .map(c => c.name)
100  const detail = incident ?? (degraded.length > 0 ? `${degraded.join(', ')} 영향` : undefined)
101
102  switch (summary.status?.indicator) {
103    case 'none':
104      return detail ? { level: 'minor', detail } : { level: 'ok' }
105    case 'minor':
106      return { level: 'minor', detail: detail ?? summary.status.description }
107    case 'major':
108    case 'critical':
109      return { level: 'major', detail: detail ?? summary.status.description }
110    case 'maintenance':
111      return { level: 'maintenance', detail }
112    default:
113      return { level: 'unknown' }
114  }
115}
116
types/index.d.ts 12 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type ClaudeStatusLevel = 'pending' | 'ok' | 'minor' | 'major' | 'maintenance' | 'unknown'
4
5export type ClaudeStatus = { level: ClaudeStatusLevel; detail?: string }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-band': { limits: Limit[]; claude: ClaudeStatus; now: number }
10  }
11}
12