输入框下方的原生 HUD:模型、路径、git/worktree、上下文、5h/7d 用量与重置倒计时、输出速度、缓存命中率

用 Claude Code 自带的界面元素,在输入框下方显示模型、路径、git/worktree、上下文、5 小时与 7 天用量及重置倒计时、输出速度和缓存命中率。纯 mod:不向模型上下文写任何内容,不占 token。
⏵⏵ auto mode on (shift+tab to cycle)
◆ Opus 5.5 1M ~/code/app ⎇ main ●2 ↑1
ctx ━━━━━━━━━━ 38% 380k/1M 5h ━━━━━━━━━━ 72% ↻ 2h13m 7d ━━━━━━━━━━ 9% ↻ 3d4h ⚡ 53 tok/s cache 90%
在 Claude Code 中运行:
/plugin marketplace add Luban-Labs/native-hud
/plugin install native-hud
本地试用:
claude --plugin-dir /path/to/native-hud
需要 Claude Code ≥ 2.1.289(函数钩子 mod,在 2.1.289 上测试)。如果 settings.json 里还配着 statusLine(例如 claude-hud),两套会同时显示;只想留一套就删掉那段 statusLine。
| 段 | 含义 |
|---|---|
◆ Opus 5.5 1M | 当前模型;1M 表示 100 万 token 上下文 |
| 路径 | 会话工作目录,家目录写作 ~,过长时中间截断 |
⎇ main | 当前分支;不在分支上时显示提交号前 7 位。wt:名字 表示在 linked worktree 里,●N 是未提交文件数,✓ 表示干净,↑N / ↓N 是领先 / 落后远端的提交数 |
ctx | 上一次请求的输入占上下文窗口的比例和 token 数 |
5h / 7d | 订阅的 5 小时和 7 天用量,↻ 后是距离重置的时间;非订阅账号显示 — |
⚡ N tok/s | 上一次回复的输出速度:输出 token 数 ÷ 从第一个流式数据到结束的时间。包含屏幕上看不到的思考时间,不包含等待首字的时间。回复流式输出时显示 ~N 估算值,中断后清除 |
cache N% | 上一次请求的输入里命中提示缓存的比例 |
用量类数字按占用着色:低于 60% 绿,60–85% 黄,85% 及以上红。缓存命中率反过来:80% 及以上绿,50–80% 黄,低于 50% 红。只统计主会话,子代理不计入。终端变窄时,放不下的段会换到下一行。
HUD 画在输入框下方的提示行(PromptHint)里:原来的模式行(如 auto mode on)和它右侧的提示照常保留在第一行,HUD 的两行在它下面。mod 没有办法在 statusLine 那一格画图形,所以位置比 statusLine 低一行。
session.measure,不读 transcript,不联网。git --no-optional-locks status --porcelain=v2 --branch,在每轮结束、Bash 执行了 git 或 cd、以及每 30 秒时刷新。不拿锁,不会和你自己的 git 写操作抢 index.lock。claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 381 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit, Timer, TurnUsage } from 'claude-code'
3
4import type { HudCache, HudGit, HudInfo, HudLimit, HudSpeed, HudUsage } from '../types'
5
6const info = atom({ plugin: 'native-hud', key: 'info' } as const, {
7 model: '',
8 path: '',
9 git: null,
10} as HudInfo)
11const usage = atom({ plugin: 'native-hud', key: 'usage' } as const, {
12 ctxPercent: null,
13 ctxTokens: null,
14 ctxWindow: 0,
15 fiveHour: null,
16 sevenDay: null,
17} as HudUsage)
18const speed = atom({ plugin: 'native-hud', key: 'speed' } as const, { last: null, live: null } as HudSpeed)
19const cache = atom({ plugin: 'native-hud', key: 'cache' } as const, { last: null } as HudCache)
20const now = atom({ plugin: 'native-hud', key: 'now' } as const, 0)
21
22const GIT_EVERY_MS = 30_000
23const TICK_MS = 15_000
24const LIVE_EVERY = 24
25
26// ---------- pure helpers (exported for tests) ----------
27
28export function prettyModel(raw: string): string {
29 const is1m = /\[1m\]/i.test(raw)
30 let name = raw.replace(/\[[^\]]*\]/g, '').replace(/^claude-/, '')
31 const m = name.match(/^(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-|$)/i)
32 if (m) {
33 const family = m[1]!
34 name = family[0]!.toUpperCase() + family.slice(1).toLowerCase() + ' ' + m[2] + (m[3] ? '.' + m[3] : '')
35 } else if (/^[a-z]+$/.test(name)) {
36 name = name[0]!.toUpperCase() + name.slice(1)
37 }
38 return is1m ? name + ' 1M' : name
39}
40
41export function shortPath(cwd: string, home: string | undefined): string {
42 if (home && (cwd === home || cwd.startsWith(home + '/'))) return '~' + cwd.slice(home.length)
43 return cwd
44}
45
46export function fmtTokens(n: number): string {
47 if (n >= 1_000_000) return (n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1) + 'M'
48 if (n >= 1000) return Math.round(n / 1000) + 'k'
49 return String(n)
50}
51
52export function fmtDur(ms: number): string {
53 if (ms <= 0) return 'now'
54 const min = Math.floor(ms / 60_000)
55 const d = Math.floor(min / 1440)
56 const h = Math.floor((min % 1440) / 60)
57 const m = min % 60
58 if (d > 0) return `${d}d${h}h`
59 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
60 return `${Math.max(m, 1)}m`
61}
62
63export function level(pct: number): 'success' | 'warning' | 'error' {
64 return pct >= 85 ? 'error' : pct >= 60 ? 'warning' : 'success'
65}
66
67export function hitLevel(pct: number): 'success' | 'warning' | 'error' {
68 return pct >= 80 ? 'success' : pct >= 50 ? 'warning' : 'error'
69}
70
71// Rough tokens in streamed text for the live estimate: CJK about one per character,
72// everything else about one per four.
73const CJK = /[-ヿ㐀-䶿一-鿿가-- -〿]/g
74export function estTokens(text: string): number {
75 const cjk = text.match(CJK)?.length ?? 0
76 return cjk + (text.length - cjk) / 4
77}
78
79export function parseGit(status: string, revParse: string): HudGit | null {
80 let branch = ''
81 let oid = ''
82 let ahead = 0
83 let behind = 0
84 let dirty = 0
85 for (const line of status.split('\n')) {
86 if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
87 else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
88 else if (line.startsWith('# branch.ab ')) {
89 const ab = line.slice(12).match(/\+(\d+) -(\d+)/)
90 if (ab) {
91 ahead = Number(ab[1])
92 behind = Number(ab[2])
93 }
94 } else if (line.trim() !== '' && !line.startsWith('#')) dirty += 1
95 }
96 if (branch === '') return null
97 if (branch === '(detached)' && /^[0-9a-f]{7,}$/.test(oid)) branch = oid.slice(0, 7)
98 const [gitDir = '', commonDir = '', top = ''] = revParse.split('\n').map(s => s.trim())
99 const worktree = gitDir !== '' && commonDir !== '' && gitDir !== commonDir ? top.split('/').pop() || top : null
100 return { branch, dirty, ahead, behind, worktree }
101}
102
103function toLimit(limits: readonly SessionRateLimit[], kind: string): HudLimit | null {
104 const l = limits.find(x => x.kind === kind)
105 if (!l) return null
106 const t = l.resetsAt ? Date.parse(l.resetsAt) : NaN
107 return { percent: l.percentUsed, resetsAt: Number.isNaN(t) ? null : t }
108}
109
110export function toUsage(context: SessionContextUsage, limits: readonly SessionRateLimit[]): HudUsage {
111 return {
112 ctxPercent: context.percent ?? null,
113 ctxTokens: context.tokens ?? null,
114 ctxWindow: context.window,
115 fiveHour: toLimit(limits, 'five_hour'),
116 sevenDay: toLimit(limits, 'seven_day'),
117 }
118}
119
120// ---------- data refresh ----------
121
122async function readGit($: EngineInterface, cwd: string): Promise<HudGit | null> {
123 try {
124 const [st, rp] = await Promise.all([
125 // --no-optional-locks: a read must never take index.lock from the model's own git writes
126 $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 }),
127 $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'], {
128 cwd,
129 timeoutMs: 5000,
130 }),
131 ])
132 if (st.exitCode !== 0) return null
133 return parseGit(st.stdout, rp.exitCode === 0 ? rp.stdout : '')
134 } catch {
135 return null
136 }
137}
138
139async function refreshInfo($: EngineInterface): Promise<void> {
140 const [model, cwd, home] = await Promise.all([$.session.model(), $.session.cwd(), $.env.get('HOME')])
141 const git = await readGit($, cwd)
142 const next: HudInfo = { model, path: shortPath(cwd, home), git }
143 await update($, info, prev => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next))
144}
145
146async function refreshUsage($: EngineInterface): Promise<void> {
147 const u = await $.session.usage()
148 await update($, usage, () => toUsage(u.context, u.rateLimits))
149}
150
151async function settleStep($: EngineInterface, u: TurnUsage | null, ms: number): Promise<void> {
152 const out = u?.output_tokens ?? 0
153 await update($, speed, s => (ms >= 250 && out > 0 ? { last: out / (ms / 1000), live: null } : { ...s, live: null }))
154 if (u === null) return
155 const read = u.cache_read_input_tokens ?? 0
156 const total = (u.input_tokens ?? 0) + read + (u.cache_creation_input_tokens ?? 0)
157 if (total > 0) await update($, cache, () => ({ last: (read / total) * 100 }))
158}
159
160// ---------- module ----------
161
162export const register: Register = on => {
163 let lastGitAt = 0
164 let tick: Timer | undefined
165
166 on('session.start', async ($, e, next) => {
167 const result = await next(e)
168 await update($, now, () => Date.now())
169 await Promise.all([refreshInfo($), refreshUsage($)])
170 lastGitAt = Date.now()
171 // session.start can fire again in the same environment: keep one interval.
172 tick?.cancel()
173 tick = $.clock.every(TICK_MS, () => {
174 const t = Date.now()
175 void update($, now, () => t)
176 if (t - lastGitAt >= GIT_EVERY_MS) {
177 lastGitAt = t
178 void refreshInfo($)
179 }
180 })
181 return result
182 })
183
184 on('session.measure', async ($, e, next) => {
185 await update($, usage, () => toUsage(e.context, e.rateLimits))
186 return next(e)
187 })
188
189 on('turn.complete', async ($, e, next) => {
190 const result = await next(e)
191 if (e.agentId === undefined) {
192 lastGitAt = Date.now()
193 await refreshInfo($)
194 }
195 return result
196 })
197
198 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
199 const ran = await next(e)
200 if (/\bgit\b|\bcd\b/.test(e.command)) {
201 lastGitAt = Date.now()
202 void refreshInfo($)
203 }
204 return ran
205 })
206
207 // Token speed and cache hits of each main-loop response. Speed is output tokens
208 // over the time from the response's first streamed item to its stop: the first
209 // item (the envelope) arrives once the prompt is processed, so time-to-first-token
210 // stays out, and thinking the stream does not show stays in. While text streams,
211 // a live estimate from what it shows. Chunks pass through untouched.
212 on('turn.step', async function* ($, e, next) {
213 if (e.agentId !== undefined) {
214 yield* next(e)
215 return
216 }
217 let tStart = 0
218 let tShown = 0
219 let shown = 0
220 let pieces = 0
221 let isStopped = false
222 let isLive = false
223 try {
224 for await (const chunk of next(e)) {
225 const t = Date.now()
226 yield chunk
227 if (tStart === 0) tStart = t
228 if (chunk.kind === 'stop') {
229 isStopped = true
230 await settleStep($, chunk.usage, t - tStart)
231 continue
232 }
233 const piece = chunk.kind === 'text' || chunk.kind === 'thinking' ? chunk.text : chunk.kind === 'input' ? chunk.json : ''
234 if (piece === '') continue
235 if (tShown === 0) tShown = t
236 shown += estTokens(piece)
237 pieces += 1
238 if (pieces % LIVE_EVERY === 0 && t - tShown >= 800) {
239 const est = shown / ((t - tShown) / 1000)
240 isLive = true
241 await update($, speed, s => ({ ...s, live: est }))
242 }
243 }
244 } finally {
245 // Interrupted (Esc) or ended without a stop: no estimate may outlive its stream.
246 if (isLive && !isStopped) await update($, speed, s => ({ ...s, live: null })).catch(() => {})
247 }
248 })
249
250 // Drawn in the footer under the prompt, where a settings statusLine sits: the HUD
251 // rows first, then the engine's own hint line (mode, pills) kept live beneath them.
252 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
253 const engineLine = await next(e)
254 const [i, u, s, c, t] = await Promise.all([
255 read($, info),
256 read($, usage),
257 read($, speed),
258 read($, cache),
259 read($, now),
260 ])
261 if (i.model === '') return engineLine
262
263 const { Box, Text } = $.ui.resolve(e)
264 const cols = e.viewport?.columns ?? 120
265 const barW = cols >= 130 ? 10 : cols >= 100 ? 8 : 5
266 const clock = t || Date.now()
267
268 const bar = (k: string, pct: number) => {
269 const filled = Math.max(0, Math.min(barW, Math.round((pct / 100) * barW)))
270 return (
271 <Box key={k} flexDirection="row">
272 <Text color={level(pct)}>{'━'.repeat(filled)}</Text>
273 <Text dimColor>{'━'.repeat(barW - filled)}</Text>
274 </Box>
275 )
276 }
277
278 const limit = (k: string, label: string, l: HudLimit | null) =>
279 l === null ? (
280 <Box key={k} flexDirection="row" columnGap={1}>
281 <Text dimColor>{label}</Text>
282 <Text dimColor>—</Text>
283 </Box>
284 ) : (
285 <Box key={k} flexDirection="row" columnGap={1}>
286 <Text dimColor>{label}</Text>
287 {bar(k + '-bar', l.percent)}
288 <Text color={level(l.percent)} bold>
289 {Math.round(l.percent)}%
290 </Text>
291 {l.resetsAt !== null ? <Text dimColor>↻ {fmtDur(l.resetsAt - clock)}</Text> : null}
292 </Box>
293 )
294
295 const g = i.git
296 const gitSeg =
297 g === null ? (
298 <Text key="git" dimColor>
299 no git
300 </Text>
301 ) : (
302 <Box key="git" flexDirection="row" columnGap={1}>
303 <Text color="suggestion">⎇ {g.branch}</Text>
304 {g.worktree !== null ? <Text color="warning">wt:{g.worktree}</Text> : null}
305 {g.dirty > 0 ? <Text color="warning">●{g.dirty}</Text> : <Text color="success">✓</Text>}
306 {g.ahead > 0 ? <Text dimColor>↑{g.ahead}</Text> : null}
307 {g.behind > 0 ? <Text dimColor>↓{g.behind}</Text> : null}
308 </Box>
309 )
310
311 const ctxSeg =
312 u.ctxPercent === null ? (
313 <Box key="ctx" flexDirection="row" columnGap={1}>
314 <Text dimColor>ctx</Text>
315 <Text dimColor>— / {fmtTokens(u.ctxWindow)}</Text>
316 </Box>
317 ) : (
318 <Box key="ctx" flexDirection="row" columnGap={1}>
319 <Text dimColor>ctx</Text>
320 {bar('ctx-bar', u.ctxPercent)}
321 <Text color={level(u.ctxPercent)} bold>
322 {u.ctxPercent}%
323 </Text>
324 <Text dimColor>
325 {fmtTokens(u.ctxTokens ?? 0)}/{fmtTokens(u.ctxWindow)}
326 </Text>
327 </Box>
328 )
329
330 const speedSeg = (
331 <Box key="speed" flexDirection="row" columnGap={1}>
332 <Text color="claude">⚡</Text>
333 {s.live !== null ? (
334 <Text color="claude">~{Math.round(s.live)} tok/s</Text>
335 ) : s.last !== null ? (
336 <Text color="claude" bold>
337 {Math.round(s.last)} tok/s
338 </Text>
339 ) : (
340 <Text dimColor>— tok/s</Text>
341 )}
342 </Box>
343 )
344
345 const cacheSeg = (
346 <Box key="cache" flexDirection="row" columnGap={1}>
347 <Text dimColor>cache</Text>
348 {c.last === null ? (
349 <Text dimColor>—</Text>
350 ) : (
351 <Text color={hitLevel(c.last)} bold>
352 {Math.round(c.last)}%
353 </Text>
354 )}
355 </Box>
356 )
357
358 return (
359 <Box flexDirection="column">
360 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
361 <Text color="claude" bold>
362 ◆ {prettyModel(i.model)}
363 </Text>
364 <Text color="text" wrap="truncate-middle">
365 {i.path}
366 </Text>
367 {gitSeg}
368 </Box>
369 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
370 {ctxSeg}
371 {limit('5h', '5h', u.fiveHour)}
372 {limit('7d', '7d', u.sevenDay)}
373 {speedSeg}
374 {cacheSeg}
375 </Box>
376 {engineLine}
377 </Box>
378 )
379 })
380}
381types/index.d.ts 36 lines1export type HudGit = {
2 branch: string
3 dirty: number
4 ahead: number
5 behind: number
6 worktree: string | null
7}
8
9export type HudInfo = { model: string; path: string; git: HudGit | null }
10
11export type HudLimit = { percent: number; resetsAt: number | null }
12
13export type HudUsage = {
14 ctxPercent: number | null
15 ctxTokens: number | null
16 ctxWindow: number
17 fiveHour: HudLimit | null
18 sevenDay: HudLimit | null
19}
20
21export type HudSpeed = { last: number | null; live: number | null }
22
23export type HudCache = { last: number | null }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'native-hud': {
28 info: HudInfo
29 usage: HudUsage
30 speed: HudSpeed
31 cache: HudCache
32 now: number
33 }
34 }
35}
36