statusline の内容をプロンプト上のバンドにゲージ付きで表示する

$ pwd
# -> /Users/hoge
$ git clone git@github.com:tocomi/dotfiles.git
$ cd dotfiles
$ ./setup.shhooks/register.tsx 243 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, ModelInfo, Usage } from '../types'
5
6const branchAtom = atom({ plugin: 'status-band', key: 'branch' } as const, null)
7const modelAtom = atom({ plugin: 'status-band', key: 'model' } as const, null)
8const usageAtom = atom({ plugin: 'status-band', key: 'usage' } as const, null)
9
10const BAR_WIDTH = 10
11const HOUR = 3_600_000
12const JST_OFFSET = 9 * HOUR
13const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
14const LIMIT_ICON: Record<string, string> = { five_hour: '🕐', seven_day: '🗓️' }
15
16const C = {
17 subtext: '#9ca3af',
18 muted: '#4b5563',
19 surface: '#374151',
20 base: '#111827',
21 dir: '#60a5fa',
22 branch: '#4ade80',
23 model: '#22d3ee',
24}
25
26const EFFORT_COLOR: Record<string, string> = {
27 low: '#9ca3af',
28 medium: '#4ade80',
29 high: '#facc15',
30 xhigh: '#f87171',
31 max: '#f87171',
32}
33
34// 使用率 0..100 を 緑 → 黄 → 橙 → 赤 のグラデーションに写す
35const STOPS: [number, number, number, number][] = [
36 [0, 0x22, 0xc5, 0x5e],
37 [0.55, 0xea, 0xb3, 0x08],
38 [0.8, 0xf9, 0x73, 0x16],
39 [1, 0xef, 0x44, 0x44],
40]
41
42function levelColor(usedPercent: number): string {
43 const x = Math.min(1, Math.max(0, usedPercent / 100))
44 for (let i = 1; i < STOPS.length; i++) {
45 const [p1, r1, g1, b1] = STOPS[i]!
46 const [p0, r0, g0, b0] = STOPS[i - 1]!
47 if (x <= p1) {
48 const k = (x - p0) / (p1 - p0)
49 const mix = (a: number, b: number) => Math.round(a + (b - a) * k)
50 return '#' + [mix(r0, r1), mix(g0, g1), mix(b0, b1)].map(v => v.toString(16).padStart(2, '0')).join('')
51 }
52 }
53 return '#ef4444'
54}
55
56const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
57
58// リセット時刻を JST で。今日なら HH:MM、別の日なら MM/DD HH:MM
59function resetLabel(resetsAt: number, now: number): string {
60 const d = new Date(resetsAt + JST_OFFSET)
61 const today = new Date(now + JST_OFFSET)
62 const p = (n: number) => String(n).padStart(2, '0')
63 const time = `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
64 const sameDay = d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate()
65 return sameDay ? time : `${p(d.getUTCMonth() + 1)}/${p(d.getUTCDate())} ${time}`
66}
67
68// 表示する指標ひとつ分: 絵文字、ラベル、値の文字列、ゲージの塗り(%)、色、補足
69type Metric = { icon: string; label: string; value: string; fill: number; color: string; note?: string }
70
71// 横並びに要る桁数。絵文字は2マスで数え、端末によって幅がぶれる分を ROW_SLACK で吸収する
72const SEP_WIDTH = 3
73const ROW_SLACK = 2
74const metricWidth = (m: Metric) => 2 + 1 + m.label.length + 1 + BAR_WIDTH + (m.note === undefined ? 0 : 1 + m.note.length)
75
76async function refreshBranch($: EngineInterface) {
77 const cwd = await $.session.cwd()
78 const head = await $.process.run(['git', '-C', cwd, 'symbolic-ref', '--short', 'HEAD'])
79 let branch: string | null = head.exitCode === 0 ? head.stdout.trim() : null
80 if (branch === null) {
81 // detached HEAD ならコミットの短縮ハッシュ、リポジトリ外なら null
82 const rev = await $.process.run(['git', '-C', cwd, 'rev-parse', '--short', 'HEAD'])
83 branch = rev.exitCode === 0 ? `@${rev.stdout.trim()}` : null
84 }
85 await update($, branchAtom, () => branch)
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 const result = await next(e)
91 await refreshBranch($)
92 const u = await $.session.usage()
93 const usage: Usage = {
94 ctxPercent: u.context.percent,
95 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
96 }
97 await update($, usageAtom, () => usage)
98 const name = await $.session.model()
99 await update($, modelAtom, m => ({ ...m, name }))
100 return result
101 })
102
103 on('session.measure', async ($, e, next) => {
104 const usage: Usage = {
105 ctxPercent: e.context.percent,
106 limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
107 }
108 await update($, usageAtom, () => usage)
109 return next(e)
110 })
111
112 // turn.step はストリーミングのイベントなので async generator で受けて流す
113 on('turn.step', async function* ($, e, next) {
114 if (e.agentId === undefined) {
115 const name = await $.session.model()
116 const effort = e.effort === undefined ? undefined : String(e.effort)
117 const info: ModelInfo = effort === undefined ? { name } : { name, effort }
118 await update($, modelAtom, () => info)
119 }
120 return yield* next(e)
121 })
122
123 on('turn.complete', async ($, e, next) => {
124 const result = await next(e)
125 await refreshBranch($)
126 return result
127 })
128
129 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130 if (e.props.hasSurvey) return next(e)
131
132 const branch = await read($, branchAtom)
133 const model = await read($, modelAtom)
134 const usage = await read($, usageAtom)
135 const cwd = await $.session.cwd()
136 const now = await $.clock.now()
137
138 const { Box, Text } = $.ui.resolve(e)
139
140 // 指標をまとめる。どれも残量で塗り、色は使用量で決める
141 const ctx = usage?.ctxPercent
142 const ctxLeft = ctx === undefined ? undefined : clamp(100 - ctx, 0, 100)
143 const metrics: Metric[] = [
144 {
145 icon: '🪣',
146 label: 'ctx',
147 value: ctxLeft === undefined ? '--' : `${ctxLeft}%`,
148 fill: ctxLeft ?? 0,
149 color: levelColor(ctx ?? 0),
150 },
151 ...(usage?.limits ?? [])
152 .filter((l: Limit) => l.kind in LIMIT_LABEL)
153 .map((l: Limit): Metric => {
154 const left = clamp(Math.round(100 - l.percentUsed), 0, 100)
155 const resetsAt = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
156 const metric: Metric = {
157 icon: LIMIT_ICON[l.kind]!,
158 label: LIMIT_LABEL[l.kind]!,
159 value: `${left}%`,
160 fill: left,
161 color: levelColor(l.percentUsed),
162 }
163 return Number.isFinite(resetsAt) ? { ...metric, note: `↺${resetLabel(resetsAt, now)}` } : metric
164 }),
165 ]
166
167 const sep = <Text color={C.muted}> │ </Text>
168
169 // ラベルはバーの外、値はバーの中。塗った部分の背景色で量を見せる
170 const bar = (m: Metric) => {
171 const text = ` ${m.value}`.padEnd(BAR_WIDTH).slice(0, BAR_WIDTH)
172 const filled = Math.round((clamp(m.fill, 0, 100) / 100) * BAR_WIDTH)
173 return (
174 <Box>
175 <Text>{m.icon} </Text>
176 <Text color={C.subtext}>{m.label} </Text>
177 <Text backgroundColor={m.color} color={C.base} bold>
178 {text.slice(0, filled)}
179 </Text>
180 <Text backgroundColor={C.surface} color={C.subtext}>
181 {text.slice(filled)}
182 </Text>
183 {m.note !== undefined && <Text color={C.subtext}> {m.note}</Text>}
184 </Box>
185 )
186 }
187
188 // 1行目: ディレクトリ・ブランチ
189 const dir = cwd.split('/').filter(Boolean).pop() ?? cwd
190 const row1 = (
191 <Box>
192 <Text color={C.dir} bold>
193 📁 {dir}
194 </Text>
195 {branch !== null && sep}
196 {branch !== null && <Text color={C.branch}>🌱 {branch}</Text>}
197 </Box>
198 )
199
200 // 2行目: モデル(effort)・コンテキスト
201 const [ctxMetric, ...limitMetrics] = metrics
202 const effort = model?.effort
203 const row2 = (
204 <Box>
205 <Text color={C.model} bold>
206 🤖 {model?.name ?? '…'}
207 </Text>
208 {effort !== undefined && <Text color={EFFORT_COLOR[effort] ?? C.subtext}> ({effort})</Text>}
209 {sep}
210 {bar(ctxMetric!)}
211 </Box>
212 )
213
214 // 3行目: レート制限。1行に収まらない幅では縦に並べる
215 const row3Width = limitMetrics.reduce((sum, m, i) => sum + (i > 0 ? SEP_WIDTH : 0) + metricWidth(m), 0)
216 const row3 =
217 limitMetrics.length === 0 ? null : e.props.bodyColumns < row3Width + ROW_SLACK ? (
218 limitMetrics.map(bar)
219 ) : (
220 <Box>
221 {limitMetrics.map((m, i) => (
222 <Box>
223 {i > 0 && sep}
224 {bar(m)}
225 </Box>
226 ))}
227 </Box>
228 )
229
230 // 同じ場所に描く他の mod の分も残す
231 const theirs = await next(e)
232
233 return (
234 <Box flexDirection="column">
235 {row1}
236 {row2}
237 {row3}
238 {theirs}
239 </Box>
240 )
241 })
242}
243types/index.d.ts 14 lines1export type ModelInfo = { name: string; effort?: string }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Usage = { ctxPercent?: number; limits: Limit[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'status-band': {
8 branch: string | null
9 model: ModelInfo | null
10 usage: Usage | null
11 }
12 }
13}
14