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

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
/usage does, with the reset timePercentages 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.
/effort (there is no event for it).claude plugin validate .
claude plugin test .hooks/register.tsx 149 lines1import { 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}
149types/index.d.ts 12 lines1// 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