claude-hud 0.10.0 as a mod: model, project, git, context, usage, tools, agents and todos, plus alerts, budget, history, a task summary, a detail pane and…

简体中文 · English
把 claude-hud 0.10.0 改成了 Claude Code mod:模型、项目、git、上下文、用量、正在跑的工具、子代理和待办,都集中在输入框旁边。可以放在输入框上方,也可以放在下方原来状态栏的位置。在 claude-hud 的基础上,还加了额度预警、用量耗尽预测、每日预算、一行任务摘要、详情面板,以及 12 套随时切换的主题。

上一轮 +98k ▂▁█),一眼看出是哪一轮把上下文撑大的;未提交的改动或未推送的提交太多时提醒。/hud detail 详情面板:每个工具的调用次数和耗时、最近几轮的用时、花费和上下文增长、子代理、待办、今天和本周的花费。claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install hud@hoobnn-agent-mods
它读取 claude-hud 自己的配置文件(~/.claude/plugins/claude-hud/config.json 和 ~/.claude/claude-hud.json),已有的 claude-hud 设置可以直接沿用,/claude-hud:configure 也照常可用。
用 /hud theme <名字> 立即切换;只输入 /hud theme 会弹窗选择;/hud theme next 换下一套,/hud theme reset 回到 classic。选择会写回 /config 的 theme 选项,以后的会话也会沿用。
| 主题 | 风格 |
|---|---|
classic | claude-hud 原样 |
neon | 赛博朋克:霓虹真彩色,⬢ ◆ ◈ ⚡,▰▱ 进度条, ❯ 分隔 |
rainbow | 每个元素一种颜色,进度条和模型名是彩虹渐变 |
emoji | 🤖 📂 🌿 🧠 ⚡ 📅 ⏳ ✅ |
sakura | 樱花粉,🌸 🎀 🍡 💗,✿ 进度条,颜文字看板娘 (◕‿◕)♡ |
kawaii | 马卡龙色,「Opus」,●○ 进度条,猫咪看板娘 ฅ^•ω•^ฅ |
mecha | 紫、绿、橙,UNIT·Opus◤,SYNC / PWR 仪表,机器人看板娘 [•_•] |
shonen | 红橙金,🔥 ⭐ 🍥 💥,渐变进度条,热血看板娘 (ง •̀_•́)ง |
tokyo-night | Tokyo Night 配色,图标克制 |
matrix | 黑底绿字,▮▯ 进度条, ┊ 分隔 |
nerd | Nerd Font 图标(需要 Nerd Font) |
powerline | Nerd Font 图标加 powerline 色块(需要 Nerd Font) |
同一个示例会话在 12 套主题下的样子:assets/themes/gallery.png;每套主题的单图在 assets/themes/<主题>.png。
动漫风主题的看板娘会跟着状态变脸:平时很淡定,跑工具时忙碌,上下文到 70%(或额度到 90%)开始担心,85% 慌张,用量耗尽时直接晕倒。showMascot 可以关掉它。
/hud:显示或隐藏 HUD;/hud off、/hud on 直接设定(输入框底栏的 HUD 按钮也一样)。/hud theme [名字|next|reset]:切换主题。/hud detail:打开或关闭详情面板。Claude 工作时也能用。在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:
| 选项 | 作用 | 默认 |
|---|---|---|
visible | 显示 HUD | 开 |
footerButton | 在输入框底栏显示 HUD 开关按钮 | 开 |
position | above 放在输入框上方,below 放在下方原状态栏的位置 | above |
theme | 主题 | classic |
showMascot | 动漫风主题显示颜文字看板娘 | 开 |
contextAlerts | 上下文到这些百分比时提醒,逗号分隔,如 80,90;留空关闭 | 空 |
usageAlerts | 5 小时或 7 天额度到这些百分比时提醒;留空关闭 | 空 |
showForecast | 按当前速度会在重置前用完时,显示预计用完的时间 | 开 |
compactWarnPercent | 上下文到达自动压缩阈值的这个百分比后,显示还剩多少 token;0 关闭 | 60 |
coldCacheTokens | 提示缓存过期后,上下文至少有这么多 token 时,提醒下一条消息要重新写入多少;0 关闭 | 20000 |
turnGrowthTokens | 一轮让上下文涨了至少这么多 token 时,显示涨幅和最近几轮的迷你柱状图;0 关闭 | 20000 |
dailyBudgetUsd | 每日预算(美元),显示今天的花费对比;0 关闭 | 0 |
showHistory | 显示近 7 天花费折线和连续使用天数 | 关 |
summaryEveryTurns | 第一轮后和每隔几轮生成一行任务摘要(复用提示词缓存);0 关闭 | 5 |
notifyAfterSeconds | 一轮跑了这么多秒以上才在结束时提醒;0 关闭 | 0 |
notifySound | 结束提醒带提示音(macOS) | 开 |
gitDirtyWarn | 未提交的改动达到这么多个路径时提醒;0 关闭 | 20 |
gitAheadWarn | 未推送的提交达到这么多个时提醒;0 关闭 | 5 |
showAgents | 显示 claude-hud 的子代理行(Claude Code 自己已经会列出,默认关) | 关 |
extraCmd | claude-hud 的 --extra-cmd:一条 shell 命令,输出作为一个标签(需要 CLAUDE_HUD_ALLOW_EXTRA_CMD=1) | 空 |
debug | 注册 mcp__hud__hud_debug 调试工具 | 关 |
语言跟随 claude-hud 配置文件里的 language(en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ru),mod 新加的预警、面板、/hud 回复和任务摘要也一起跟随。
hoobnn-agent-mods 里还有任务进度条(todo-bar)、回合回执(receipt)、运行动画和宠物(spinner)、Tailscale 节点状态(ts-band)和一言(hitokoto),可以搭配使用。
和 claude-hud 的差异、mod 自行推算的字段、目录结构和从上游同步的步骤,见 英文 README。
hooks/register.tsx 665 lines1// claude-hud as a mod. claude-hud's own source (hooks/hud, MIT, see
2// LICENSE.claude-hud) renders the lines; this module wires the engine's
3// events to it and draws its output above or below the prompt. The work sits
4// beside it: stdin.ts builds the statusline's stdin, render.ts runs a pass,
5// remote.ts follows Remote Control, draw.tsx draws. Whatever calls `$` stays
6// in this file (the engine follows `$` only within the hooks module's own
7// file); the other modules get closures over it (`Io`, `SessionApi`).
8import './shims/globals.js'
9
10import { atom, derive, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { DockPet, Fired, HudLine, Remote, StepInfo, ToolStats, TurnCost } from '../types'
14import { readConfig } from './config.js'
15import { drawPane, drawRows } from './draw.js'
16import { addSample, appendExtras, chimeWav, crossThresholds, exhaustAt, extrasLine, formatDuration, growthOf, lastDays, localDay, paceAt, pruneHistory, streak } from './extras.js'
17import type { Samples } from './extras.js'
18import { loadConfig, setConfigPatch } from './hud/config.js'
19import { setLanguage } from './hud/i18n/index.js'
20import { setTranscriptProvider } from './hud/transcript.js'
21import type { StdinData } from './hud/types.js'
22import { FIVE_HOUR_WINDOW_MS, SEVEN_DAY_WINDOW_MS } from './hud/usage-pace.js'
23import { m, summaryPrompt } from './i18n.js'
24import { isPickerOpen } from './kit/band.js'
25import { cellWidth, dockFits, drawPet } from './kit/pet.js'
26import { keptRows, migrateStore, persist, switchArg } from './kit/prefs.js'
27import type { Prefs } from './kit/prefs.js'
28import { fitColumns, live, useTheme } from './live.js'
29import { BRIDGE, remoteControl } from './remote.js'
30import { gitCounts, renderHud } from './render.js'
31import { factsSummary, type Io, runWithFacts, setIo } from './shims/host.js'
32import { loadHostFacts, type SessionApi } from './stdin.js'
33import { cleanSummary } from './summary.js'
34import { applyPalette, applyTheme, findTheme, moodOf, THEMES } from './themes.js'
35import type { Theme } from './themes.js'
36import { transcriptData } from './transcript-feed.js'
37
38const lines = atom({ plugin: 'hud', key: 'lines' } as const, [] as HudLine[])
39// Set this session by `/hud` or session.start; null in a session resumed or
40// cleared, which gets no session.start: `isHidden` is the `visible` row's then.
41const hiddenSet = atom({ plugin: 'hud', key: 'isHidden' } as const, null as boolean | null)
42// The `visible` row's, set in register.
43let isRowHidden = false
44const isHidden = derive([hiddenSet], set => set ?? isRowHidden)
45// Session state, so a reload keeps what earlier requests reported.
46const steps = atom({ plugin: 'hud', key: 'step' } as const, {
47 model: null,
48 effort: null,
49 apiDurationMs: 0,
50 currentUsage: null,
51 lastRequestAt: null,
52} as StepInfo)
53// Remote clients attached to the session (a phone, the web), with their surface.
54const remotes = atom({ plugin: 'hud', key: 'remotes' } as const, [] as Remote[])
55const summary = atom({ plugin: 'hud', key: 'summary' } as const, null as string | null)
56const turns = atom({ plugin: 'hud', key: 'turns' } as const, 0)
57const fired = atom({ plugin: 'hud', key: 'fired' } as const, { context: [], fiveHour: [], sevenDay: [] } as Fired)
58const history = atom({ plugin: 'hud', key: 'history' } as const, {} as Record<string, number>)
59const tools = atom({ plugin: 'hud', key: 'tools' } as const, {} as ToolStats)
60const turnLog = atom({ plugin: 'hud', key: 'turnLog' } as const, [] as TurnCost[])
61const turnStart = atom({ plugin: 'hud', key: 'turnStart' } as const, null as { usd: number | null; tokens: number | null } | null)
62/** Turns `/hud detail` lists. */
63const TURN_LOG_SIZE = 8
64// spinner's pet, and whether it stands beside the HUD's rows (below the prompt only).
65const petDock = atom({ plugin: 'spinner', key: 'dock' } as const, null as DockPet | null)
66const isPetDocked = atom({ plugin: 'hud', key: 'dock' } as const, false)
67const petPats = atom({ plugin: 'hud', key: 'petPats' } as const, 0)
68// True while a picker is open above the band (see kit/band.tsx).
69const isPicking = atom({ plugin: 'hud', key: 'isPicking' } as const, false)
70
71const PANE = 'hud-detail'
72// Days of spend the store keeps; the HUD draws the last 7.
73const HISTORY_DAYS = 60
74
75setTranscriptProvider(async path => transcriptData(path))
76
77// Events (tool calls, model requests, turn ends) drive the live updates; the
78// tick only keeps minute-grained clocks (duration, resets, cache) current.
79const TICK_MS = 15_000
80const DEBOUNCE_MS = 250
81const RC_POLL_MS = 3_000
82
83/** The kit's hold on this mod's store and `/config` rows. */
84function prefsOf($: EngineInterface): Prefs {
85 return {
86 kept: key => $.store.get(key),
87 forget: key => $.store.delete(key),
88 write: (field, value) => $.config.set({ key: `hud.${field}`, value }),
89 }
90}
91
92/** Marks the message-only client present when `origin` came over the bridge; true if that is news. */
93async function sawBridge($: EngineInterface, origin: { kind: string }): Promise<boolean> {
94 if (origin.kind !== 'bridge') return false
95 if ((await read($, remotes)).some(r => r.surface === BRIDGE)) return false
96 await update($, remotes, rs => [...rs.filter(r => r.surface !== BRIDGE), { id: BRIDGE, surface: BRIDGE }])
97 return true
98}
99
100type Gauges = { context?: number; fiveHour?: number; sevenDay?: number }
101
102/** Toasts once per threshold a gauge crosses; re-armed once it drops back. */
103async function alert($: EngineInterface, gauges: Gauges, contextAlerts: number[], usageAlerts: number[]): Promise<void> {
104 const was = await read($, fired)
105 const context = crossThresholds(gauges.context, contextAlerts, was.context)
106 const fiveHour = crossThresholds(gauges.fiveHour, usageAlerts, was.fiveHour)
107 const sevenDay = crossThresholds(gauges.sevenDay, usageAlerts, was.sevenDay)
108 if (context.alert !== null) $.ui.toast(m('alert.context', { p: context.alert }))
109 if (fiveHour.alert !== null) $.ui.toast(m('alert.fiveHour', { p: fiveHour.alert }))
110 if (sevenDay.alert !== null) $.ui.toast(m('alert.sevenDay', { p: sevenDay.alert }))
111 const now = { ...was, context: context.fired, fiveHour: fiveHour.fired, sevenDay: sevenDay.fired }
112 if (JSON.stringify(now) !== JSON.stringify(was)) await update($, fired, () => now)
113}
114
115/** The same for the model-scoped weekly windows (Fable's), which `session.measure` does not carry. */
116async function alertScoped($: EngineInterface, windows: readonly { name: string; percent: number }[], usageAlerts: number[]): Promise<void> {
117 if (usageAlerts.length === 0 || windows.length === 0) return
118 const was = await read($, fired)
119 const scoped = { ...was.scoped }
120 for (const { name, percent } of windows) {
121 const crossed = crossThresholds(percent, usageAlerts, scoped[name] ?? [])
122 if (crossed.alert !== null) $.ui.toast(m('alert.scoped', { name, p: crossed.alert }))
123 scoped[name] = crossed.fired
124 }
125 if (JSON.stringify(scoped) !== JSON.stringify(was.scoped ?? {})) await update($, fired, f => ({ ...f, scoped }))
126}
127
128/** The gauges a usage reading carries (`$.session.usage()`, or a pushed `session.measure`). */
129function gaugesOf(usage: { context: { percent?: number }; rateLimits: readonly { kind: string; percentUsed: number }[] }): Gauges {
130 const percent = (kind: string) => usage.rateLimits.find(r => r.kind === kind)?.percentUsed
131 return { context: usage.context.percent, fiveHour: percent('five_hour'), sevenDay: percent('seven_day') }
132}
133
134/** The context the next message re-caches, once a cache this session used has expired and holds at least `min` tokens. */
135function coldCacheOf(stdin: StdinData, min: number): number | null {
136 const cache = stdin.prompt_cache
137 const tokens = stdin.context_window?.total_input_tokens ?? 0
138 return min > 0 && cache?.caching_observed && !cache.warm && tokens >= min ? tokens : null
139}
140
141/** Draws `picked` (the redraw scheduled) and writes the theme row. */
142async function setTheme($: EngineInterface, picked: Theme, schedule: () => void): Promise<{ text: string }> {
143 useTheme(picked)
144 schedule()
145 await persist(prefsOf($), 'theme', picked.name)
146 return { text: m('theme.set', { name: `${picked.name} ${picked.sample}` }) }
147}
148
149/** `/hud [off|on]`: the HUD hidden or shown (no verb toggles), its row written when it changed. */
150async function setHidden($: EngineInterface, verb: string, schedule: () => void): Promise<boolean> {
151 const was = await read($, isHidden)
152 const hidden = switchArg(verb, was)
153 await update($, hiddenSet, () => hidden)
154 if (hidden !== was) await persist(prefsOf($), 'visible', !hidden)
155 schedule()
156 return hidden
157}
158
159// Before 0.8 a theme picked with `/hud theme` was kept in the store; it is the `theme` row now.
160const STORE_MOVES = { theme: (kept: unknown) => (findTheme(kept) ? (['theme', findTheme(kept)!.name] as const) : null) }
161
162export const register: Register = (on, options) => {
163 const config = readConfig(options)
164 isRowHidden = !config.isVisible
165 useTheme(config.theme)
166 setConfigPatch(hud => {
167 const themed = applyPalette(hud, live.theme)
168 // Claude Code lists running subagents itself, with their time and tokens; claude-hud's
169 // agent lines would repeat them, so they show only when asked for.
170 return config.hasAgents ? themed : { ...themed, display: { ...themed.display, showAgents: false } }
171 })
172 let isSummarizing = false
173 // Set in session.start: everything that outlives one dispatch calls the
174 // engine through these closures.
175 let refresh: () => Promise<void> = async () => {}
176 let after: (ms: number, fn: () => void) => void = () => {}
177 let isRunning = false
178 let isQueued = false
179 let isScheduled = false
180 let isStarted = false
181 // Where auto-compaction runs (null: off), for the window it was read for: it
182 // moves only with the window, so a model switch reads it again.
183 let compactAt: { window: number; at: number | null } | null = null
184 // The 5-hour window's readings this session: its forecast follows the last hour's pace.
185 let fiveHour: Samples | undefined
186
187 const schedule = () => {
188 // A draw can come before session.start: nothing to schedule on yet, and
189 // session.start refreshes anyway.
190 if (isScheduled || !isStarted) return
191 isScheduled = true
192 after(DEBOUNCE_MS, () => {
193 isScheduled = false
194 void refresh()
195 })
196 }
197
198 on('session.start', async ($, e, next) => {
199 const io: Io = {
200 stat: path => $.fs.stat(path, { resolve: true }),
201 read: path => $.fs.read(path),
202 list: path => $.fs.list(path),
203 write: (path, text) => $.fs.write(path, text),
204 run: (argv, init) => $.process.run(argv, init),
205 }
206 const session: SessionApi = {
207 info: async () => {
208 const [id, cwd, root, model, usage, version, settings, step, repo] = await Promise.all([
209 $.session.id(),
210 $.session.cwd(),
211 $.session.root(),
212 $.session.model(),
213 $.session.usage(),
214 $.session.version(),
215 $.settings.read().catch(() => ({})),
216 read($, steps),
217 $.session.repo().catch(() => null),
218 ])
219 return { id, cwd, root, model, usage, version, settings: settings as Record<string, unknown>, step, repo }
220 },
221 // A value from before 0.4.3 (bare ids) is dropped rather than misread.
222 remotes: async () => (await read($, remotes)).filter(r => typeof r === 'object' && r !== null),
223 exists: path => $.fs.exists(path),
224 }
225 setIo(io)
226 after = (ms, fn) => void $.clock.after(ms, fn)
227 isStarted = true
228 refresh = async () => {
229 if (isRunning) {
230 isQueued = true
231 return
232 }
233 isRunning = true
234 const started = Date.now()
235 try {
236 const { rows, stdin, todayUsd } = await renderHud(io, session)
237 const window = stdin.context_window?.context_window_size ?? 0
238 if (config.compactWarnPercent > 0 && window > 0 && compactAt?.window !== window) {
239 const breakdown = (await $.session.usage({ breakdown: 'summary' }).catch(() => null))?.context.breakdown
240 compactAt = { window, at: breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null }
241 }
242 const tokens = stdin.context_window?.total_input_tokens ?? 0
243 const compactLeft =
244 compactAt?.at && tokens >= (compactAt.at * config.compactWarnPercent) / 100 ? compactAt.at - tokens : null
245 const pet = config.position === 'below' ? await read($, petDock) : null
246 const now = await $.clock.now()
247 if (todayUsd !== null) await recordSpend(now, todayUsd)
248 const days = await read($, history)
249 const today = localDay(now)
250 const limits = stdin.rate_limits
251 const five = limits?.five_hour
252 if (five && typeof five.used_percentage === 'number' && five.resets_at) {
253 fiveHour = addSample(fiveHour, five.used_percentage, five.resets_at, now)
254 }
255 const scoped = (stdin.model_scoped ?? []).flatMap(w =>
256 w.display_name && typeof w.utilization === 'number'
257 ? [{ name: w.display_name, percent: w.utilization, resetsAt: w.resets_at ? Math.floor(Date.parse(w.resets_at) / 1000) : null }]
258 : [],
259 )
260 await alertScoped($, scoped, config.usageAlerts)
261 // The 7-day window keeps the pace since it began: an hour of work says little about a week.
262 const exhaust = config.hasForecast
263 ? [
264 { label: m('limit.fiveHour'), at: paceAt(fiveHour, five?.used_percentage, five?.resets_at, FIVE_HOUR_WINDOW_MS, now) },
265 { label: m('limit.sevenDay'), at: exhaustAt(limits?.seven_day?.used_percentage, limits?.seven_day?.resets_at, SEVEN_DAY_WINDOW_MS, now) },
266 ...scoped.map(w => ({ label: m('limit.scoped', { name: w.name }), at: exhaustAt(w.percent, w.resetsAt, SEVEN_DAY_WINDOW_MS, now) })),
267 ].flatMap(({ label, at }) => (at === null ? [] : [{ label, at }]))
268 : []
269 const extra = extrasLine({
270 summary: await read($, summary),
271 exhaust,
272 todayUsd,
273 budgetUsd: config.budgetUsd,
274 week: config.hasHistory ? { values: lastDays(days, today, 7), streak: streak(days, today) } : null,
275 compactLeft,
276 coldCache: coldCacheOf(stdin, config.coldCacheTokens),
277 growth: growthOf(await read($, turnLog), config.turnGrowthTokens),
278 git: config.gitDirtyWarn > 0 || config.gitAheadWarn > 0 ? await gitCounts(io, stdin.cwd ?? '') : null,
279 gitDirtyWarn: config.gitDirtyWarn,
280 gitAheadWarn: config.gitAheadWarn,
281 columns: fitColumns(),
282 style: live.theme.extras,
283 // spinner's pet stands in for the theme's mascot below the prompt.
284 mascot: config.hasMascot && live.theme.mascot && !pet ? live.theme.mascot[mood(stdin)] : null,
285 })
286 const all = applyTheme(appendExtras(rows, extra, fitColumns()), live.theme)
287 // The pet moves in beside the rows when it fits, and out when it does not.
288 const widest = Math.max(0, ...all.map(row => row.reduce((sum, span) => sum + cellWidth(span.text), 0)))
289 const wasDocked = await read($, isPetDocked)
290 const isDocked = pet !== null && !(await read($, isHidden)) && live.columns !== undefined && dockFits(widest, live.columns, pet.width, wasDocked)
291 if (isDocked !== wasDocked) await update($, isPetDocked, () => isDocked)
292 live.lastLines = all.map(row => row.map(span => span.text).join(''))
293 live.lastError = null
294 await update($, lines, () => all)
295 } catch (err) {
296 live.lastError = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err)
297 } finally {
298 live.refreshMs = Date.now() - started
299 isRunning = false
300 }
301 if (isQueued) {
302 isQueued = false
303 void refresh()
304 }
305 }
306
307 // The mascot's mood: the quota, the context, whether a tool is running.
308 const mood = (stdin: StdinData) => {
309 const limits = stdin.rate_limits
310 const usage = Math.max(limits?.five_hour?.used_percentage ?? 0, limits?.seven_day?.used_percentage ?? 0)
311 const tools = live.transcriptPath ? transcriptData(live.transcriptPath)?.tools ?? [] : []
312 return moodOf(stdin.context_window?.used_percentage, usage, tools.some(t => t.status === 'running'))
313 }
314 // Today's spend into the history, and the store, which other sessions share.
315 const recordSpend = async (now: number, todayUsd: number) => {
316 const today = localDay(now)
317 const usd = Math.round(todayUsd * 100) / 100
318 if ((await read($, history))[today] === usd) return
319 const stored = ((await $.store.get('history')) ?? {}) as Record<string, number>
320 const merged = pruneHistory({ ...stored, [today]: usd }, today, HISTORY_DAYS)
321 await $.store.set('history', merged)
322 await update($, history, () => merged)
323 }
324
325 const kept = await $.store.get('history')
326 if (kept && typeof kept === 'object') {
327 const today = localDay(await $.clock.now())
328 await update($, history, () => pruneHistory(kept as Record<string, number>, today, HISTORY_DAYS))
329 }
330
331 const keptTheme = findTheme((await keptRows(prefsOf($), STORE_MOVES)).theme)
332 if (keptTheme) useTheme(keptTheme)
333 await update($, hiddenSet, () => !config.isVisible)
334
335 await loadHostFacts(io, config.extraCmd)
336 // claude-hud sets its language in each pass; the command's description is read before the first.
337 await runWithFacts(async () => setLanguage((await loadConfig()).language))
338 await $.command.register({ name: 'hud', description: m('cmd.description'), argumentHint: '[off|on | detail | theme [name|next|reset]]', immediate: true })
339 if (config.isDebug) {
340 await $.tool.register({
341 name: 'hud_debug',
342 description: 'claude-hud mod diagnostics: the stdin it built, its last error and refresh time.',
343 inputSchema: { type: 'object', properties: {} },
344 })
345 }
346 // Usage the session already had (a resumed one): alerts now, not after the first measurement.
347 const usage = await $.session.usage().catch(() => null)
348 if (usage) {
349 await alert($, gaugesOf(usage), config.contextAlerts, config.usageAlerts)
350 }
351 $.clock.every(TICK_MS, () => void refresh())
352 void refresh()
353 // Remote Control turns on and off outside the turn's events (`/remote-control`,
354 // `--remote-control` connecting): a cheap read of the session file, a redraw on a change.
355 $.clock.every(RC_POLL_MS, async () => {
356 const bridge = await remoteControl(io, await $.session.id()).catch(() => live.bridgeSessionId)
357 if (bridge !== live.bridgeSessionId) {
358 // A new bridge, or none: whoever wrote over the old one is not known to be there.
359 await update($, remotes, all => all.filter(r => r.surface !== BRIDGE))
360 schedule()
361 }
362 })
363
364 const result = await next(e)
365 await migrateStore(prefsOf($), STORE_MOVES)
366 return result
367 })
368
369 on('command.run', { command: 'hud' }, async ($, e) => {
370 const [verb = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
371 if (verb === 'theme') {
372 const list = THEMES.map(t => `${t.name}${t.isNerdFont ? '*' : ''} ${t.sample}`).join(' · ')
373 const names = THEMES.map(t => t.name).join(', ')
374 const at = THEMES.indexOf(live.theme)
375 if (!arg) {
376 // The engine's dialog offers the next four; "Other" takes any name. Dismissed, or no one to ask (-p): the list.
377 const offered = [1, 2, 3, 4].map(i => THEMES[(at + i) % THEMES.length]!.name)
378 const answer = await $.ui.ask(m('theme.ask', { name: live.theme.name, list: names }), { options: offered, header: 'HUD theme' }).catch(() => null)
379 if (answer === null) return { text: m('theme.list', { name: live.theme.name, list }) }
380 const asked = findTheme(answer.trim().split(/\s+/)[0]?.toLowerCase())
381 if (!asked) return { text: m('theme.unknown', { name: answer.trim(), list: names }) }
382 return setTheme($, asked, schedule)
383 }
384 // `reset`: the option's default, claude-hud's own look.
385 const picked = arg === 'next' ? THEMES[(at + 1) % THEMES.length] : arg === 'reset' ? THEMES[0] : findTheme(arg)
386 if (!picked) return { text: m('theme.unknown', { name: arg, list: names }) }
387 return setTheme($, picked, schedule)
388 }
389 if (verb === 'detail') {
390 if ((await $.ui.panes()).some(p => p.id === PANE)) {
391 await $.ui.close({ id: PANE })
392 return { text: m('pane.closed') }
393 }
394 await $.ui.open({ id: PANE, title: m('pane.title') })
395 return { text: m('pane.opened') }
396 }
397 const hidden = await setHidden($, verb, schedule)
398
399 return { text: m(hidden ? 'cmd.hidden' : 'cmd.shown') }
400 })
401
402 // A button in the prompt footer: what `/hud` alone does. Mode labels other plugins add stay beside it.
403 if (config.hasFooterButton) {
404 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
405 const hidden = await read($, isHidden)
406 const below = await next(e)
407 const { Box, Button } = $.ui.resolve(e)
408 return (
409 <Box flexDirection="row" alignItems="center" gap={1}>
410 <Button key="hud-toggle" plain dimColor={hidden} label="HUD" onPress={() => setHidden($, '', schedule)} />
411 {below}
412 </Box>
413 )
414 })
415 }
416
417 on('session.attach', async ($, e, next) => {
418 if (e.surface !== 'terminal') {
419 await update($, remotes, all => [...all.filter(r => r.id !== e.clientId), { id: e.clientId, surface: e.surface }])
420 }
421 schedule()
422
423 return next(e)
424 })
425
426 // Remote Control's clients are seen by what they send.
427 on('prompt.submit', async ($, e, next) => {
428 if (await read($, isPicking)) await update($, isPicking, () => false)
429 if (await sawBridge($, e.origin)) schedule()
430 return next(e)
431 })
432 on('command.run', async ($, e, next) => {
433 if (await sawBridge($, e.origin)) schedule()
434 if (e.command !== 'model') return next(e)
435 // /model: the last step's model is the old one; the session's is shown until the next step.
436 const result = await next(e)
437 await update($, steps, step => ({ ...step, model: null }))
438 schedule()
439 return result
440 })
441
442 // Remote Control's prompts reach the session as deliveries before they are a prompt.
443 on('session.receive', async ($, e, next) => {
444 if (await sawBridge($, e.origin)) schedule()
445 return next(e)
446 })
447
448 // Usage pushed by the engine: the alerts at once, and the gauges redrawn.
449 on('session.measure', async ($, e, next) => {
450 await alert($, gaugesOf(e), config.contextAlerts, config.usageAlerts)
451 schedule()
452 return next(e)
453 })
454
455 // A compaction empties the context: the gauge drops now, not at the next tick.
456 on('session.compact', async ($, e, next) => {
457 const result = await next(e)
458 if (e.agentId === undefined) schedule()
459 return result
460 })
461
462 // spinner's pet came, went or changed: whether it fits is decided again.
463 on('state.set', { plugin: 'spinner', key: 'dock' }, async ($, e, next) => {
464 const result = await next(e)
465 schedule()
466 return result
467 })
468
469 // A click on the pet drawn here: spinner counts it as a pat.
470 on('ui.message', async ($, e, next) => {
471 if ((e.data as { pat?: unknown } | null)?.pat === true) await update($, petPats, n => n + 1)
472 return next(e)
473 })
474
475 on('session.detach', async ($, e, next) => {
476 await update($, remotes, all => all.filter(r => r.id !== e.clientId))
477 schedule()
478
479 return next(e)
480 })
481
482 on('tool.call', { tool: /^mcp__hud__hud_debug$/ }, async () => {
483 const text = JSON.stringify(
484 { lines: live.lastLines, stdin: live.lastStdin, bridgeSessionId: live.bridgeSessionId, sessionFile: live.sessionFile, error: live.lastError, refreshMs: live.refreshMs, columns: live.columns, facts: factsSummary() },
485 null,
486 2,
487 )
488
489 return { result: text, text }
490 })
491
492 on('tool.call', async ($, e, next) => {
493 schedule()
494 const started = Date.now()
495 const ran = await next(e)
496 const ms = Date.now() - started
497 const failed = 'deny' in ran ? ran.deny !== undefined : ran.isError === true
498 await update($, tools, all => {
499 const was = all[e.tool] ?? { count: 0, totalMs: 0, errors: 0 }
500 return { ...all, [e.tool]: { count: was.count + 1, totalMs: was.totalMs + ms, errors: was.errors + (failed ? 1 : 0) } }
501 })
502 schedule()
503
504 return ran
505 })
506
507 on('turn.step', async function* ($, e, next) {
508 const started = Date.now()
509 const result = yield* next(e)
510 const elapsed = Date.now() - started
511 const usage = result.usage
512 await update($, steps, step =>
513 e.agentId
514 ? { ...step, apiDurationMs: step.apiDurationMs + elapsed }
515 : {
516 model: e.model,
517 effort: e.effort === undefined ? step.effort : String(e.effort),
518 apiDurationMs: step.apiDurationMs + elapsed,
519 currentUsage: usage
520 ? {
521 input_tokens: usage.input_tokens,
522 output_tokens: usage.output_tokens,
523 cache_creation_input_tokens: usage.cache_creation_input_tokens,
524 cache_read_input_tokens: usage.cache_read_input_tokens,
525 }
526 : step.currentUsage,
527 // The request's start is when the main thread's prompt cache was last used.
528 lastRequestAt: started,
529 },
530 )
531 schedule()
532
533 return result
534 })
535
536 // What the turn about to run starts from, for its row in `/hud detail`.
537 on('turn.start', async ($, e, next) => {
538 const usage = await $.session.usage().catch(() => null)
539 await update($, turnStart, () => ({ usd: usage?.cost?.usd ?? null, tokens: usage?.context.tokens ?? null }))
540 return next(e)
541 })
542
543 on('turn.complete', async ($, e, next) => {
544 schedule()
545 if (e.agentId !== undefined) return next(e)
546 const start = await read($, turnStart)
547 if (start) {
548 const usage = await $.session.usage().catch(() => null)
549 const usd = usage?.cost?.usd
550 const tokens = usage?.context.tokens
551 await update($, turnStart, () => null)
552 await update($, turnLog, log => [
553 ...log.slice(-(TURN_LOG_SIZE - 1)),
554 {
555 n: (log[log.length - 1]?.n ?? 0) + 1,
556 durationMs: e.durationMs,
557 usd: usd !== undefined && start.usd !== null ? usd - start.usd : null,
558 tokens: tokens !== undefined && start.tokens !== null ? tokens - start.tokens : null,
559 },
560 ])
561 }
562 if (e.isAborted) return next(e)
563
564 if (config.notifyMs > 0 && e.durationMs >= config.notifyMs && e.reason === 'answer') {
565 $.ui.toast(m('turn.done', { d: formatDuration(e.durationMs) }))
566 if (config.hasChime) void $.audio.play({ base64: chimeWav(), mime: 'audio/wav' }).catch(() => {})
567 }
568 const count = await update($, turns, n => n + 1)
569 // While the model keeps a task list with work left, the list already says what it is doing
570 // (Claude Code draws it, and so do todo-bar and the todos line): no fork, and an older line steps aside.
571 const todos = live.transcriptPath ? transcriptData(live.transcriptPath)?.todos ?? [] : []
572 const hasPlan = todos.some(t => t.status !== 'completed')
573 if (hasPlan && (await read($, summary)) !== null) {
574 await update($, summary, () => null)
575 schedule()
576 }
577 if (config.summaryEvery > 0 && !hasPlan && !isSummarizing && (count === 1 || count % config.summaryEvery === 0)) {
578 isSummarizing = true
579 // Not awaited: the fork reads the conversation from the prompt cache while the person reads the answer.
580 void $.model
581 .fork({ prompt: summaryPrompt() })
582 .then(async reply => {
583 const line = reply.isAnswered ? cleanSummary(reply.text) : null
584 if (line) {
585 await update($, summary, () => line)
586 schedule()
587 }
588 })
589 .catch(() => {})
590 .finally(() => {
591 isSummarizing = false
592 })
593 }
594
595 return next(e)
596 })
597
598 const trackWidth = (columns: number | undefined) => {
599 if (columns && columns !== live.columns) {
600 live.columns = columns
601 schedule()
602 }
603 }
604
605 if (config.position === 'above') {
606 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
607 on('prompt.edit', async ($, e, next) => {
608 const box = await next(e)
609 const isOpen = isPickerOpen(box.text, box.cursor)
610 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
611 return box
612 })
613
614 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
615 // Two cells in, as the engine indents the lines under the prompt.
616 trackWidth(e.props.bodyColumns - 2)
617 const rows = await read($, lines)
618 if (e.props.hasSurvey || rows.length === 0 || (await read($, isHidden)) || (await read($, isPicking))) {
619 return next(e)
620 }
621
622 return drawRows($.ui.resolve(e), rows, await next(e), 2)
623 })
624 } else {
625 // Under the prompt, where the statusline sat: the HUD, then the engine's hint line.
626 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
627 trackWidth(e.viewport?.columns)
628 const rows = await read($, lines)
629 if (rows.length === 0 || (await read($, isHidden))) {
630 return next(e)
631 }
632 const ui = $.ui.resolve(e)
633 const pet = (await read($, isPetDocked)) ? await read($, petDock) : null
634 if (!pet || !('Client' in ui)) return drawRows(ui, rows, await next(e))
635
636 const { Box } = ui
637 return (
638 <Box flexDirection="row">
639 <Box flexDirection="column" flexGrow={1} flexShrink={0}>
640 {drawRows(ui, rows, await next(e))}
641 </Box>
642 {drawPet(ui, pet, 'pet')}
643 </Box>
644 )
645 })
646 }
647
648 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
649 // Read so the pane redraws with the HUD: the transcript's agents and todos change with it.
650 await read($, lines)
651 const transcript = live.transcriptPath ? transcriptData(live.transcriptPath) : null
652 const now = await $.clock.now()
653 return drawPane($.ui.resolve(e), {
654 tools: await read($, tools),
655 turns: await read($, turnLog),
656 agents: transcript?.agents ?? [],
657 todos: transcript?.todos ?? [],
658 history: await read($, history),
659 today: localDay(now),
660 budgetUsd: config.budgetUsd,
661 now: Date.now(),
662 })
663 })
664}
665hooks/shims/globals.ts 54 lines1// The two Node globals claude-hud reaches for: `process` and `Buffer`.
2// Filled from the session at start (see register.tsx).
3export const processShim = {
4 env: {} as Record<string, string | undefined>,
5 platform: 'darwin',
6 pid: 0,
7 argv: ['node', 'claude-hud'] as string[],
8 execPath: '',
9 stdout: { columns: undefined as number | undefined, isTTY: false },
10 stderr: { columns: undefined as number | undefined, isTTY: false },
11 stdin: undefined,
12 cwdPath: '/',
13 cwd(): string {
14 return processShim.cwdPath
15 },
16}
17
18type ByteString = Uint8Array & { toString(encoding?: string): string }
19
20function withText(bytes: Uint8Array): ByteString {
21 const out = bytes as ByteString
22 out.toString = () => new TextDecoder().decode(bytes)
23 return out
24}
25
26const BufferShim = {
27 from(value: string | ArrayLike<number>): ByteString {
28 return withText(typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value))
29 },
30 alloc(size: number): ByteString {
31 return withText(new Uint8Array(size))
32 },
33 byteLength(value: string): number {
34 return new TextEncoder().encode(value).length
35 },
36 concat(list: Uint8Array[]): ByteString {
37 const out = new Uint8Array(list.reduce((n, c) => n + c.length, 0))
38 let at = 0
39 for (const c of list) {
40 out.set(c, at)
41 at += c.length
42 }
43 return withText(out)
44 },
45}
46
47const g = globalThis as Record<string, unknown>
48g.process = processShim
49g.Buffer = BufferShim
50if (typeof g.console === 'undefined') {
51 const quiet = () => {}
52 g.console = { log: quiet, error: quiet, warn: quiet, info: quiet, debug: quiet }
53}
54hooks/config.ts 66 lines1// hud's options (plugin.json `userConfig`), read once into a typed config.
2// The HUD's language and layout stay claude-hud's own config file's.
3import type { PluginOptions } from 'claude-code'
4
5import { parseThresholds } from './extras.js'
6import { count, flag, oneOf, text } from './kit/options.js'
7import { findTheme, THEMES, type Theme } from './themes.js'
8
9export type Config = {
10 isVisible: boolean
11 /** A HUD button in the prompt footer. */
12 hasFooterButton: boolean
13 position: 'above' | 'below'
14 theme: Theme
15 hasMascot: boolean
16 /** claude-hud's `--extra-cmd`. */
17 extraCmd: string
18 /** Registers `mcp__hud__hud_debug`. */
19 isDebug: boolean
20 /** A turn at least this long toasts when it ends; 0 is off. */
21 notifyMs: number
22 hasChime: boolean
23 contextAlerts: number[]
24 usageAlerts: number[]
25 hasForecast: boolean
26 budgetUsd: number
27 hasHistory: boolean
28 /** Summarize after the first turn and every this many; 0 is off. */
29 summaryEvery: number
30 /** Show the tokens left before auto-compaction from this percent of the way there; 0 is off. */
31 compactWarnPercent: number
32 /** Once the prompt cache has expired, show what the next message re-caches from this many context tokens; 0 is off. */
33 coldCacheTokens: number
34 /** Show the last turn's context growth once it reaches this many tokens; 0 off. */
35 turnGrowthTokens: number
36 gitDirtyWarn: number
37 gitAheadWarn: number
38 hasAgents: boolean
39}
40
41export function readConfig(options: PluginOptions): Config {
42 return {
43 isVisible: flag(options.visible, true),
44 hasFooterButton: flag(options.footerButton, true),
45 position: oneOf(options.position, ['above', 'below'], 'above'),
46 theme: findTheme(options.theme) ?? THEMES[0]!,
47 hasMascot: flag(options.showMascot, true),
48 extraCmd: text(options.extraCmd),
49 isDebug: flag(options.debug, false),
50 notifyMs: count(options.notifyAfterSeconds, 0, { min: 0 }) * 1000,
51 hasChime: flag(options.notifySound, true),
52 contextAlerts: parseThresholds(text(options.contextAlerts)),
53 usageAlerts: parseThresholds(text(options.usageAlerts)),
54 hasForecast: flag(options.showForecast, true),
55 budgetUsd: count(options.dailyBudgetUsd, 0, { min: 0 }),
56 hasHistory: flag(options.showHistory, false),
57 summaryEvery: count(options.summaryEveryTurns, 5, { min: 0, isInteger: true }),
58 compactWarnPercent: count(options.compactWarnPercent, 60, { min: 0 }),
59 coldCacheTokens: count(options.coldCacheTokens, 20_000, { min: 0, isInteger: true }),
60 turnGrowthTokens: count(options.turnGrowthTokens, 20_000, { min: 0, isInteger: true }),
61 gitDirtyWarn: count(options.gitDirtyWarn, 20, { min: 0 }),
62 gitAheadWarn: count(options.gitAheadWarn, 5, { min: 0 }),
63 hasAgents: flag(options.showAgents, false),
64 }
65}
66hooks/draw.tsx 135 lines1// The HUD's drawings, over the elements a render hook resolved: its rows
2// (above or below the prompt) and the `/hud detail` pane.
3import type { Elements, RenderElement } from 'claude-code'
4
5import type { HudLine, ToolStats, TurnCost } from '../types'
6import { formatDuration, lastDays, sparkline, streak } from './extras.js'
7import type { AgentEntry, TodoItem } from './hud/types.js'
8import { formatTokens } from './hud/utils/format.js'
9import { m, money } from './i18n.js'
10
11type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Link'>
12
13/** The HUD's rows over whatever the engine (or another mod) draws in the same place. */
14export function drawRows(ui: Ui, rows: HudLine[], rest: RenderElement, indent = 0): RenderElement {
15 const { Box, Text, Link } = ui
16
17 return (
18 <Box flexDirection="column">
19 {rows.map((row, i) => (
20 // A row of sibling Texts, not nested ones: a nested Text drops dimColor.
21 // claude-hud fits its own rows; the extras row wraps when it runs long.
22 <Box key={`l${i}`} flexDirection="row" flexWrap="wrap" paddingLeft={indent}>
23 {row.map((span, j) => {
24 const text = (
25 <Text
26 key={`s${j}`}
27 color={span.color}
28 backgroundColor={span.backgroundColor}
29 bold={span.bold}
30 dimColor={span.dimColor}
31 italic={span.italic}
32 underline={span.underline}
33 strikethrough={span.strikethrough}
34 inverse={span.inverse}
35 >
36 {span.text}
37 </Text>
38 )
39 // claude-hud's https links (a GitHub branch) stay clickable.
40 return span.href ? (
41 <Link key={`s${j}`} href={span.href}>
42 {text}
43 </Link>
44 ) : (
45 text
46 )
47 })}
48 </Box>
49 ))}
50 {rest}
51 </Box>
52 )
53}
54
55export type PaneData = {
56 tools: ToolStats
57 /** The last finished turns, oldest first. */
58 turns: readonly TurnCost[]
59 agents: readonly AgentEntry[]
60 todos: readonly TodoItem[]
61 /** Spend per day, `YYYY-MM-DD` → USD. */
62 history: Record<string, number>
63 today: string
64 budgetUsd: number
65 now: number
66}
67
68/** `/hud detail`: each tool's calls and time, the last turns, subagents, todos, and the spend. */
69export function drawPane(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, data: PaneData): RenderElement {
70 const { Box, Text } = ui
71 const stats = Object.entries(data.tools).sort((a, b) => b[1].totalMs - a[1].totalMs)
72 const agents = data.agents.slice(-8)
73 const week = lastDays(data.history, data.today, 7)
74 const heading = (text: string) => <Text bold color="cyan">{text}</Text>
75 const todaySpent = money(data.history[data.today] ?? 0) + (data.budgetUsd > 0 ? `/${money(data.budgetUsd)}` : '')
76 const days7 = streak(data.history, data.today)
77 const spendLine = [
78 m('spend.today', { spent: todaySpent }),
79 `${m('spend.week', { spent: money(week.reduce((a, b) => a + b, 0)) })} ${sparkline(week)}`,
80 ...(days7 > 1 ? [m('streak', { n: days7 })] : []),
81 ].join(' · ')
82
83 return (
84 <Box flexDirection="column">
85 {heading(m('pane.tools'))}
86 {stats.length === 0 && <Text dimColor>{m('pane.noTools')}</Text>}
87 {stats.slice(0, 12).map(([name, s]) => (
88 <Box key={`t-${name}`} flexDirection="row" columnGap={1}>
89 <Text>{name}</Text>
90 <Text dimColor>
91 {m('pane.toolStats', { count: s.count, total: formatDuration(s.totalMs), avg: formatDuration(s.totalMs / s.count) })}
92 </Text>
93 {s.errors > 0 ? <Text color="red">{m('pane.failed', { n: s.errors })}</Text> : null}
94 </Box>
95 ))}
96 <Text> </Text>
97 {heading(m('pane.turns'))}
98 {data.turns.length === 0 && <Text dimColor>{m('pane.none')}</Text>}
99 {[...data.turns].reverse().map(t => (
100 <Text key={`turn-${t.n}`} dimColor>
101 {m('pane.turnRow', {
102 n: t.n,
103 time: formatDuration(t.durationMs),
104 cost: t.usd === null ? '—' : money(t.usd),
105 tokens: t.tokens === null ? '—' : `${t.tokens < 0 ? '−' : '+'}${formatTokens(Math.abs(t.tokens))}`,
106 })}
107 </Text>
108 ))}
109 <Text> </Text>
110 {heading(m('pane.agents'))}
111 {agents.length === 0 && <Text dimColor>{m('pane.none')}</Text>}
112 {agents.map(a => (
113 <Box key={`a-${a.id}`} flexDirection="row" columnGap={1}>
114 <Text color={a.status === 'running' ? 'yellow' : 'green'}>{a.status === 'running' ? '◐' : '✓'}</Text>
115 <Text>{a.type}</Text>
116 <Text dimColor wrap="truncate-end">
117 {a.description ?? ''} {formatDuration((a.endTime?.getTime() ?? data.now) - a.startTime.getTime())}
118 </Text>
119 </Box>
120 ))}
121 <Text> </Text>
122 {heading(m('pane.todos'))}
123 {data.todos.length === 0 && <Text dimColor>{m('pane.none')}</Text>}
124 {data.todos.map((t, i) => (
125 <Text key={`d-${i}`} dimColor={t.status === 'completed'} color={t.status === 'in_progress' ? 'yellow' : undefined}>
126 {t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'} {t.content}
127 </Text>
128 ))}
129 <Text> </Text>
130 {heading(m('pane.spend'))}
131 <Text>{spendLine}</Text>
132 </Box>
133 )
134}
135hooks/extras.ts 359 lines1// What the mod adds to claude-hud's lines: alerts, a usage forecast, today's
2// spend against a budget, the spend history, a git nag and the task summary.
3// Pure helpers; register.tsx feeds them from `$` and draws the row.
4import type { HudLine, TurnCost } from '../types'
5import { textWidth } from './hud/render/ansi.js'
6import { formatTokens } from './hud/utils/format.js'
7import { m, money } from './i18n.js'
8
9/** "80, 90" → [80, 90]: whole percents in 1-100, ascending; empty turns alerts off. */
10export function parseThresholds(spec: string): number[] {
11 const values = spec
12 .split(/[\s,]+/)
13 .map(s => Number.parseInt(s, 10))
14 .filter(n => Number.isFinite(n) && n >= 1 && n <= 100)
15 return [...new Set(values)].sort((a, b) => a - b)
16}
17
18// A threshold fires again only once the percent has dropped this far below it
19// (a /compact, a window reset), so the tick does not repeat a toast.
20const REARM_BELOW = 5
21
22/**
23 * The thresholds `percent` stands at or past, given those already fired; `alert`
24 * is the highest newly crossed one (null when none). Fired ones the percent
25 * dropped well below are re-armed.
26 */
27export function crossThresholds(
28 percent: number | null | undefined,
29 thresholds: number[],
30 fired: number[],
31): { alert: number | null; fired: number[] } {
32 if (percent === null || percent === undefined || !Number.isFinite(percent)) return { alert: null, fired }
33 const kept = fired.filter(t => percent >= t - REARM_BELOW)
34 const fresh = thresholds.filter(t => percent >= t && !kept.includes(t))
35 return { alert: fresh.length > 0 ? fresh[fresh.length - 1]! : null, fired: [...kept, ...fresh].sort((a, b) => a - b) }
36}
37
38// Below this much usage a linear projection is noise; claude-hud's pace uses the same floor.
39const MIN_USED_PERCENT = 10
40
41/**
42 * When a rate-limit window runs out at the rate used so far (ms), or null when
43 * it lasts until its reset (or the data cannot say).
44 */
45export function exhaustAt(
46 percent: number | null | undefined,
47 resetsAtSec: number | null | undefined,
48 windowMs: number,
49 now: number,
50): number | null {
51 if (percent === null || percent === undefined || !resetsAtSec || percent < MIN_USED_PERCENT) return null
52 if (percent >= 100) return now
53 const resetsAt = resetsAtSec * 1000
54 const elapsed = windowMs - (resetsAt - now)
55 if (elapsed <= 0 || resetsAt <= now) return null
56 const at = now + ((100 - percent) / percent) * elapsed
57 return at < resetsAt ? at : null
58}
59
60/** Readings of one rate-limit window: `[ms, percent]`, oldest first, for the window that resets at `resetsAt`. */
61export type Samples = { resetsAt: number; points: [number, number][] }
62
63/** How far back the recent pace looks, and the least span it needs to mean anything. */
64const PACE_LOOKBACK_MS = 60 * 60_000
65const PACE_MIN_SPAN_MS = 10 * 60_000
66
67/** `samples` with a reading at `now` added: a new window starts over, readings past the lookback leave. */
68export function addSample(samples: Samples | undefined, percent: number, resetsAtSec: number, now: number): Samples {
69 const resetsAt = resetsAtSec * 1000
70 const kept = samples && samples.resetsAt === resetsAt ? samples.points.filter(([t]) => now - t <= PACE_LOOKBACK_MS) : []
71 return { resetsAt, points: [...kept, [now, percent]] }
72}
73
74/**
75 * When a window runs out at the pace of the last hour (ms), or null when that
76 * pace lasts until the reset. With under ten minutes of readings, the pace
77 * since the window began (exhaustAt) stands in.
78 */
79export function paceAt(samples: Samples | undefined, percent: number | null | undefined, resetsAtSec: number | null | undefined, windowMs: number, now: number): number | null {
80 const first = samples?.points[0]
81 if (!samples || !first || !resetsAtSec || samples.resetsAt !== resetsAtSec * 1000 || now - first[0] < PACE_MIN_SPAN_MS) {
82 return exhaustAt(percent, resetsAtSec, windowMs, now)
83 }
84 if (percent === null || percent === undefined || percent < MIN_USED_PERCENT) return null
85 if (percent >= 100) return now
86 const rate = (percent - first[1]) / (now - first[0])
87 if (rate <= 0) return null
88 const at = now + (100 - percent) / rate
89 return at < samples.resetsAt ? at : null
90}
91
92const pad = (n: number) => String(n).padStart(2, '0')
93
94/** "14:05" in the machine's time zone. */
95export function clockTime(ms: number): string {
96 const d = new Date(ms)
97 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
98}
99
100/** "2026-10-02" in the machine's time zone. */
101export function localDay(ms: number): string {
102 const d = new Date(ms)
103 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
104}
105
106/** The day `n` days after `day` (negative: before). */
107export function addDays(day: string, n: number): string {
108 const [y, m, d] = day.split('-').map(Number)
109 return localDay(new Date(y!, m! - 1, d! + n, 12).getTime())
110}
111
112/** "45s", "2m05s", "1h03m". */
113export function formatDuration(ms: number): string {
114 const s = Math.max(0, Math.round(ms / 1000))
115 if (s < 60) return `${s}s`
116 if (s < 3600) return `${Math.floor(s / 60)}m${pad(s % 60)}s`
117 return `${Math.floor(s / 3600)}h${pad(Math.floor((s % 3600) / 60))}m`
118}
119
120const BLOCKS = '▁▂▃▄▅▆▇█'
121
122/** One block per value, scaled to the largest; a zero is the lowest block. */
123export function sparkline(values: number[]): string {
124 const max = Math.max(0, ...values)
125 return values
126 .map(v => (max <= 0 || v <= 0 ? BLOCKS[0] : BLOCKS[Math.min(7, Math.max(1, Math.round((v / max) * 7)))]))
127 .join('')
128}
129
130/** The last turn's context growth and the recent turns', once the last grew it by at least `min` tokens. */
131export function growthOf(log: readonly TurnCost[], min: number): { last: number; recent: number[] } | null {
132 const last = log[log.length - 1]?.tokens ?? null
133 if (min <= 0 || last === null || last < min) return null
134 return { last, recent: log.map(t => Math.max(0, t.tokens ?? 0)) }
135}
136
137/** Spend per day, `YYYY-MM-DD` → USD. */
138export type History = Record<string, number>
139
140/** The last `days` days' spend, oldest first, ending today. */
141export function lastDays(history: History, today: string, days: number): number[] {
142 return Array.from({ length: days }, (_, i) => history[addDays(today, i - days + 1)] ?? 0)
143}
144
145/** Consecutive days with spend, ending today (or yesterday, before today's first spend). */
146export function streak(history: History, today: string): number {
147 let day = (history[today] ?? 0) > 0 ? today : addDays(today, -1)
148 let count = 0
149 while ((history[day] ?? 0) > 0) {
150 count++
151 day = addDays(day, -1)
152 }
153 return count
154}
155
156/** Days older than `keepDays` before today leave the history. */
157export function pruneHistory(history: History, today: string, keepDays: number): History {
158 const oldest = addDays(today, -keepDays)
159 return Object.fromEntries(Object.entries(history).filter(([day]) => day > oldest))
160}
161
162/**
163 * `git status --porcelain=v2 --branch` (lines, or `-z` records) → changed paths
164 * and commits ahead of upstream.
165 */
166export function parseGitStatus(stdout: string): { dirty: number; ahead: number } {
167 const isZ = stdout.includes('\0')
168 const records = stdout.split(isZ ? '\0' : '\n')
169 let dirty = 0
170 let ahead = 0
171 for (let i = 0; i < records.length; i++) {
172 const record = records[i]!
173 if (!record) continue
174 if (record.startsWith('#')) {
175 const ab = /^# branch\.ab \+(\d+) -\d+/.exec(record)
176 if (ab) ahead = Number(ab[1])
177 } else {
178 dirty++
179 // Under -z a rename or copy is followed by its original path, a record of its own.
180 if (isZ && record.startsWith('2 ')) i++
181 }
182 }
183 return { dirty, ahead }
184}
185
186/** "Today $3.20/$10.00 ▓▓▓░░░░░", red once past the budget, yellow from 80%. */
187export function budgetSpans(todayUsd: number, budgetUsd: number, width = 8): HudLine {
188 const ratio = budgetUsd > 0 ? todayUsd / budgetUsd : 0
189 const filled = Math.min(width, Math.round(ratio * width))
190 const color = ratio >= 1 ? 'red' : ratio >= 0.8 ? 'yellow' : 'green'
191 const spans: HudLine = [{ text: `${m('spend.today', { spent: `${money(todayUsd)}/${money(budgetUsd)}` })} `, dimColor: ratio < 0.8 }]
192 if (filled > 0) spans.push({ text: '▓'.repeat(filled), color })
193 if (filled < width) spans.push({ text: '░'.repeat(width - filled), dimColor: true })
194 return spans
195}
196
197export type ExtrasInput = {
198 summary: string | null
199 /** Rate-limit windows that run out before they reset: label and when. */
200 exhaust: { label: string; at: number }[]
201 todayUsd: number | null
202 budgetUsd: number
203 /** The last 7 days' spend, oldest first, and the streak; null hides the history. */
204 week: { values: number[]; streak: number } | null
205 git: { dirty: number; ahead: number } | null
206 /** Tokens left before auto-compaction runs, once the context is far enough in; null hides it. */
207 compactLeft?: number | null
208 /** The context tokens the next message writes to the cache again, once it has expired; null hides it. */
209 coldCache?: number | null
210 /** How far the last turn grew the context, and the recent turns' growth, oldest first; null hides it. */
211 growth?: { last: number; recent: number[] } | null
212 gitDirtyWarn: number
213 gitAheadWarn: number
214 /** The row's width; parts that do not fit leave it, least important first. */
215 columns?: number
216 /** The theme's glyphs and colors for the row; claude-hud's look when absent. */
217 style?: ExtrasStyle
218 /** The theme mascot's face, first in the row. */
219 mascot?: string | null
220}
221
222export type ExtrasStyle = {
223 summary?: string
224 warning?: string
225 summaryColor?: string
226 forecastColor?: string
227 weekColor?: string
228 warningColor?: string
229 mascotColor?: string
230}
231
232const SEPARATOR = ' │ '
233
234const lineWidth = (spans: HudLine) => spans.reduce((sum, span) => sum + textWidth(span.text), 0)
235
236/** The row under claude-hud's lines; empty when there is nothing to say. */
237export function extrasLine(x: ExtrasInput): HudLine {
238 // Kept in display order; `rank` is what goes last when the row is too wide.
239 const parts: { spans: HudLine; rank: number }[] = []
240 const style = x.style ?? {}
241 if (x.mascot) parts.push({ spans: [{ text: x.mascot, color: style.mascotColor ?? style.summaryColor ?? 'cyan', bold: true }], rank: -1 })
242 if (x.summary) parts.push({ spans: [{ text: `${style.summary ?? '✎'} ${x.summary}`, color: style.summaryColor ?? 'cyan' }], rank: 3 })
243 for (const { label, at } of x.exhaust) {
244 parts.push({ spans: [{ text: m('forecast', { label, time: clockTime(at) }), color: style.forecastColor ?? 'magenta' }], rank: 2 })
245 }
246 if (typeof x.compactLeft === 'number') {
247 parts.push({ spans: [{ text: m('compact.left', { tokens: formatTokens(Math.max(0, x.compactLeft)) }), color: style.forecastColor ?? 'magenta' }], rank: 2 })
248 }
249 if (typeof x.coldCache === 'number') {
250 parts.push({ spans: [{ text: m('cache.cold', { tokens: formatTokens(x.coldCache) }), color: style.warningColor ?? 'yellow' }], rank: 2 })
251 }
252 if (x.growth) {
253 const color = style.forecastColor ?? 'magenta'
254 parts.push({
255 spans: [
256 { text: `${m('turn.growth', { tokens: formatTokens(x.growth.last) })} `, color },
257 { text: sparkline(x.growth.recent), color },
258 ],
259 rank: 1,
260 })
261 }
262 if (x.budgetUsd > 0 && x.todayUsd !== null) parts.push({ spans: budgetSpans(x.todayUsd, x.budgetUsd), rank: 1 })
263 if (x.week && x.week.values.some(v => v > 0)) {
264 parts.push({
265 spans: [
266 { text: `${m('week')} `, dimColor: true },
267 { text: sparkline(x.week.values), color: style.weekColor ?? 'blue' },
268 ...(x.week.streak > 1 ? [{ text: ` ${m('streak', { n: x.week.streak })}`, dimColor: true }] : []),
269 ],
270 rank: 0,
271 })
272 }
273 const nags: string[] = []
274 if (x.git && x.gitDirtyWarn > 0 && x.git.dirty >= x.gitDirtyWarn) nags.push(m('git.dirty', { n: x.git.dirty }))
275 if (x.git && x.gitAheadWarn > 0 && x.git.ahead >= x.gitAheadWarn) nags.push(m('git.ahead', { n: x.git.ahead }))
276 if (nags.length > 0) parts.push({ spans: [{ text: `${style.warning ?? '⚠'} ${nags.join(' · ')}`, color: style.warningColor ?? 'yellow' }], rank: 4 })
277
278 const join = (kept: typeof parts) =>
279 kept.flatMap((part, i) => (i === 0 ? part.spans : [{ text: SEPARATOR, dimColor: true }, ...part.spans]))
280 const kept = [...parts]
281 while (x.columns && kept.length > 1 && lineWidth(join(kept)) > x.columns) {
282 const lowest = Math.min(...kept.map(p => p.rank))
283 kept.splice(kept.findIndex(p => p.rank === lowest), 1)
284 }
285 return join(kept)
286}
287
288/** claude-hud's ` │ ` and ` | ` between elements, dimmed like the extras row's. */
289export function dimSeparators(row: HudLine): HudLine {
290 return row.flatMap(span => {
291 if (span.dimColor || !/ [│|] /.test(span.text)) return [span]
292 return span.text
293 .split(/( [│|] )/)
294 .flatMap((text, i) => (!text ? [] : i % 2 === 1 ? [{ text, dimColor: true }] : [{ ...span, text }]))
295 })
296}
297
298/** The extras row joins claude-hud's last row when both fit in `columns`, else goes under it. */
299export function appendExtras(rows: HudLine[], extra: HudLine, columns: number | undefined): HudLine[] {
300 if (extra.length === 0) return rows
301 const last = rows[rows.length - 1]
302 if (last && columns && lineWidth(last) + textWidth(SEPARATOR) + lineWidth(extra) <= columns) {
303 return [...rows.slice(0, -1), [...last, { text: SEPARATOR, dimColor: true }, ...extra]]
304 }
305 return [...rows, extra]
306}
307
308const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
309
310function base64(bytes: Uint8Array): string {
311 let out = ''
312 for (let i = 0; i < bytes.length; i += 3) {
313 const [a, b = 0, c = 0] = [bytes[i]!, bytes[i + 1], bytes[i + 2]]
314 const n = (a << 16) | (b << 8) | c
315 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
316 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
317 out += i + 2 < bytes.length ? B64[n & 63]! : '='
318 }
319 return out
320}
321
322let chime: string | null = null
323
324/** A short two-note chime, as a base64 16-bit mono WAV: the turn-done sound. */
325export function chimeWav(): string {
326 if (chime) return chime
327 const rate = 22_050
328 const notes = [
329 { hz: 880, ms: 110 },
330 { hz: 1320, ms: 170 },
331 ]
332 const samples = notes.flatMap(({ hz, ms }) => {
333 const count = Math.round((rate * ms) / 1000)
334 return Array.from({ length: count }, (_, i) => {
335 const fade = Math.min(1, i / 200, (count - i) / 600)
336 return Math.round(Math.sin((2 * Math.PI * hz * i) / rate) * fade * 0.35 * 32767)
337 })
338 })
339 const data = samples.length * 2
340 const buf = new DataView(new ArrayBuffer(44 + data))
341 const text = (at: number, s: string) => [...s].forEach((ch, i) => buf.setUint8(at + i, ch.charCodeAt(0)))
342 text(0, 'RIFF')
343 buf.setUint32(4, 36 + data, true)
344 text(8, 'WAVE')
345 text(12, 'fmt ')
346 buf.setUint32(16, 16, true)
347 buf.setUint16(20, 1, true)
348 buf.setUint16(22, 1, true)
349 buf.setUint32(24, rate, true)
350 buf.setUint32(28, rate * 2, true)
351 buf.setUint16(32, 2, true)
352 buf.setUint16(34, 16, true)
353 text(36, 'data')
354 buf.setUint32(40, data, true)
355 samples.forEach((s, i) => buf.setInt16(44 + i * 2, s, true))
356 chime = base64(new Uint8Array(buf.buffer))
357 return chime
358}
359hooks/hud/config.ts 546 lines1import * as fs from '../shims/fs.js';
2import * as path from '../shims/path.js';
3import * as os from '../shims/os.js';
4import { expandHomeDirPrefix, getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
5import { createDebug } from './debug.js';
6import type { Language } from './i18n/types.js';
7import { MAX_TERMINAL_WIDTH } from './utils/terminal.js';
8import { sanitizeDisplayText } from './utils/sanitize.js';
9
10const debug = createDebug('config');
11const MAX_CONFIG_FILE_BYTES = 64 * 1024;
12const MAX_CONFIG_NESTING_DEPTH = 8;
13const UNSAFE_CONFIG_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
14
15const LANGUAGES = ['en', 'zh', 'zh-Hans', 'zh-Hant', 'zh-TW', 'ja', 'ko', 'es', 'fr', 'de', 'pt', 'pt-BR', 'ru'] as const satisfies readonly Language[];
16const LINE_LAYOUTS = ['compact', 'expanded'] as const;
17const PATH_LEVELS = [1, 2, 3, 'full'] as const;
18const CONTEXT_VALUE_MODES = ['percent', 'tokens', 'remaining', 'both'] as const;
19const USAGE_VALUE_MODES = ['percent', 'remaining'] as const;
20const GIT_BRANCH_OVERFLOW_MODES = ['truncate', 'wrap'] as const;
21// full: display name as-is; compact: drop the context-window suffix; short: also drop "Claude ".
22const MODEL_FORMATS = ['full', 'compact', 'short'] as const;
23const MODEL_SOURCES = ['auto', 'stdin', 'transcript'] as const;
24const EFFORT_FORMATS = ['full', 'symbol', 'text'] as const;
25const TIME_FORMATS = ['relative', 'absolute', 'both', 'elapsed', 'elapsedAndAbsolute'] as const;
26const HOUR_CYCLES = ['auto', 'h11', 'h12', 'h23', 'h24'] as const;
27const CUSTOM_LINE_POSITIONS = ['first', 'last'] as const;
28const ADDED_DIRS_LAYOUTS = ['inline', 'line'] as const;
29const COLOR_NAMES = ['dim', 'red', 'green', 'yellow', 'magenta', 'cyan', 'brightBlue', 'brightMagenta'] as const;
30
31const ELEMENTS = [
32 'project',
33 'addedDirs',
34 'context',
35 'usage',
36 'promptCache',
37 'cacheHitRate',
38 'memory',
39 'environment',
40 'tools',
41 'skills',
42 'mcp',
43 'agents',
44 'todos',
45 'sessionTime',
46] as const;
47
48// Orderable segments of the first line, shared by the expanded and compact layouts.
49const FIRST_LINE_SEGMENTS = [
50 'model',
51 'project',
52 'advisor',
53 'sessionName',
54 'version',
55 'extra',
56 'duration',
57 'cost',
58 'speed',
59 'auth',
60] as const;
61
62export type LineLayoutType = typeof LINE_LAYOUTS[number];
63export type PathLevels = typeof PATH_LEVELS[number];
64export type ContextValueMode = typeof CONTEXT_VALUE_MODES[number];
65export type UsageValueMode = typeof USAGE_VALUE_MODES[number];
66export type GitBranchOverflowMode = typeof GIT_BRANCH_OVERFLOW_MODES[number];
67export type ModelFormatMode = typeof MODEL_FORMATS[number];
68export type EffortFormatMode = typeof EFFORT_FORMATS[number];
69export type TimeFormatMode = typeof TIME_FORMATS[number];
70export type HourCycleMode = typeof HOUR_CYCLES[number];
71export type CustomLinePosition = typeof CUSTOM_LINE_POSITIONS[number];
72export type AddedDirsLayout = typeof ADDED_DIRS_LAYOUTS[number];
73export type HudColorName = typeof COLOR_NAMES[number];
74export type HudElement = typeof ELEMENTS[number];
75export type FirstLineSegment = typeof FIRST_LINE_SEGMENTS[number];
76
77/** A named preset, a 256-color index (0-255), or a #rrggbb hex string. */
78export type HudColorValue = HudColorName | number | string;
79
80export interface HudColorOverrides {
81 context: HudColorValue;
82 usage: HudColorValue;
83 warning: HudColorValue;
84 usageWarning: HudColorValue;
85 critical: HudColorValue;
86 model: HudColorValue;
87 project: HudColorValue;
88 git: HudColorValue;
89 gitBranch: HudColorValue;
90 label: HudColorValue;
91 custom: HudColorValue;
92 barFilled: string;
93 barEmpty: string;
94}
95
96export const DEFAULT_ELEMENT_ORDER: HudElement[] = [...ELEMENTS];
97export const DEFAULT_MERGE_GROUPS: HudElement[][] = [['context', 'usage']];
98// Empty keeps each renderer's native order until the user moves a segment.
99export const DEFAULT_PROJECT_LINE_ORDER: FirstLineSegment[] = [];
100
101export interface HudConfig {
102 language: Language;
103 lineLayout: LineLayoutType;
104 showSeparators: boolean;
105 pathLevels: PathLevels;
106 maxWidth: number | null;
107 forceMaxWidth: boolean;
108 elementOrder: HudElement[];
109 projectLineOrder: FirstLineSegment[];
110 gitStatus: {
111 enabled: boolean;
112 showDirty: boolean;
113 showAheadBehind: boolean;
114 showFileStats: boolean;
115 showWorktree: boolean;
116 branchOverflow: GitBranchOverflowMode;
117 pushWarningThreshold: number;
118 pushCriticalThreshold: number;
119 };
120 jjStatus: {
121 enabled: boolean;
122 showDirty: boolean;
123 showConflicts: boolean;
124 };
125 display: {
126 showModel: boolean;
127 showProject: boolean;
128 showAddedDirs: boolean;
129 addedDirsLayout: AddedDirsLayout;
130 showContextBar: boolean;
131 contextValue: ContextValueMode;
132 showConfigCounts: boolean;
133 showCost: boolean;
134 // Also show cost for routed providers (Bedrock/Vertex), which showCost hides.
135 showRoutedCost: boolean;
136 showDailyCost: boolean;
137 showWeeklyCost: boolean;
138 showDuration: boolean;
139 showSpeed: boolean;
140 showTokenBreakdown: boolean;
141 showUsage: boolean;
142 usageValue: UsageValueMode;
143 usageBarEnabled: boolean;
144 showResetLabel: boolean;
145 usageCompact: boolean;
146 showModelScopedUsage: boolean;
147 usagePace: boolean;
148 showTools: boolean;
149 showSkills: boolean;
150 showMcp: boolean;
151 toolNameMaxLength: number;
152 toolsMaxVisible: number;
153 skillsMaxVisible: number;
154 showAgents: boolean;
155 showTodos: boolean;
156 showSessionName: boolean;
157 showAuth: boolean;
158 showAuthUser: boolean;
159 // Max characters of the account name (0 = full).
160 authUserLength: number;
161 showClaudeCodeVersion: boolean;
162 showEffortLevel: boolean;
163 effortFormat: EffortFormatMode;
164 showMemoryUsage: boolean;
165 showPromptCache: boolean;
166 showCacheHitRate: boolean;
167 showSessionTokens: boolean;
168 showOutputStyle: boolean;
169 showSessionStartDate: boolean;
170 showLastResponseAt: boolean;
171 showCompactions: boolean;
172 mergeGroups: HudElement[][];
173 // Elements pushed to the right edge of a merged line, when it fits and the width is known.
174 rightAlign: HudElement[];
175 contextWarningThreshold: number;
176 contextCriticalThreshold: number;
177 usageThreshold: number;
178 sevenDayThreshold: number;
179 environmentThreshold: number;
180 externalUsagePath: string;
181 externalUsageWritePath: string;
182 externalUsageFreshnessMs: number;
183 modelFormat: ModelFormatMode;
184 modelOverride: string;
185 // auto: transcript model for non-Claude (proxied) models; stdin/transcript: always that source.
186 modelSource: typeof MODEL_SOURCES[number];
187 showProvider: boolean;
188 providerName: string;
189 customLine: string;
190 customLinePosition: CustomLinePosition;
191 timeFormat: TimeFormatMode;
192 hourCycle: HourCycleMode;
193 showClockSeconds: boolean;
194 showAdvisor: boolean;
195 advisorOverride: string;
196 autoCompactWindow: number | null;
197 };
198 colors: HudColorOverrides;
199}
200
201export const DEFAULT_CONFIG: HudConfig = {
202 language: 'en',
203 lineLayout: 'expanded',
204 showSeparators: false,
205 pathLevels: 1,
206 maxWidth: null,
207 forceMaxWidth: false,
208 elementOrder: [...DEFAULT_ELEMENT_ORDER],
209 projectLineOrder: [...DEFAULT_PROJECT_LINE_ORDER],
210 gitStatus: {
211 enabled: true,
212 showDirty: true,
213 showAheadBehind: false,
214 showFileStats: false,
215 showWorktree: false,
216 branchOverflow: 'truncate',
217 pushWarningThreshold: 0,
218 pushCriticalThreshold: 0,
219 },
220 jjStatus: {
221 enabled: false,
222 showDirty: true,
223 showConflicts: true,
224 },
225 display: {
226 showModel: true,
227 showProject: true,
228 showAddedDirs: true,
229 addedDirsLayout: 'inline',
230 showContextBar: true,
231 contextValue: 'percent',
232 showConfigCounts: false,
233 showCost: false,
234 showRoutedCost: false,
235 showDailyCost: false,
236 showWeeklyCost: false,
237 showDuration: false,
238 showSpeed: false,
239 showTokenBreakdown: true,
240 showUsage: true,
241 usageValue: 'percent',
242 usageBarEnabled: true,
243 showResetLabel: true,
244 usageCompact: false,
245 showModelScopedUsage: true,
246 usagePace: false,
247 showTools: false,
248 showSkills: false,
249 showMcp: false,
250 toolNameMaxLength: 0,
251 toolsMaxVisible: 4,
252 skillsMaxVisible: 4,
253 showAgents: false,
254 showTodos: false,
255 showSessionName: false,
256 showAuth: false,
257 showAuthUser: false,
258 authUserLength: 8,
259 showClaudeCodeVersion: false,
260 showEffortLevel: false,
261 effortFormat: 'full',
262 showMemoryUsage: false,
263 showPromptCache: false,
264 showCacheHitRate: false,
265 showSessionTokens: false,
266 showOutputStyle: false,
267 showSessionStartDate: false,
268 showLastResponseAt: false,
269 showCompactions: false,
270 mergeGroups: DEFAULT_MERGE_GROUPS.map(group => [...group]),
271 rightAlign: [],
272 contextWarningThreshold: 70,
273 contextCriticalThreshold: 85,
274 usageThreshold: 0,
275 sevenDayThreshold: 80,
276 environmentThreshold: 0,
277 externalUsagePath: '',
278 externalUsageWritePath: '',
279 externalUsageFreshnessMs: 300000,
280 modelFormat: 'full',
281 modelOverride: '',
282 modelSource: 'stdin',
283 showProvider: false,
284 providerName: '',
285 customLine: '',
286 customLinePosition: 'last',
287 timeFormat: 'relative',
288 hourCycle: 'auto',
289 showClockSeconds: false,
290 showAdvisor: false,
291 advisorOverride: '',
292 autoCompactWindow: null,
293 },
294 colors: {
295 context: 'green',
296 usage: 'brightBlue',
297 warning: 'yellow',
298 usageWarning: 'brightMagenta',
299 critical: 'red',
300 model: 'cyan',
301 project: 'yellow',
302 git: 'magenta',
303 gitBranch: 'cyan',
304 label: 'dim',
305 custom: 208,
306 barFilled: '█',
307 barEmpty: '░',
308 },
309};
310
311export function getConfigPath(): string {
312 return path.join(getHudPluginDir(os.homedir()), 'config.json');
313}
314
315// Lives outside plugins/, which users often symlink across several CLAUDE_CONFIG_DIRs,
316// so it stays per-directory and can override the shared config.
317export function getConfigOverridePath(): string {
318 return path.join(getClaudeConfigDir(os.homedir()), 'claude-hud.json');
319}
320
321// A rule maps a raw user value to a valid one, or to the fallback (the default).
322type Rule = (value: unknown, fallback: any) => unknown;
323
324const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
325
326const oneOf = (allowed: readonly unknown[]): Rule => (value, fallback) => (
327 allowed.includes(value) ? value : fallback
328);
329const clamp = (min: number, max: number): Rule => (value, fallback) => (
330 isNumber(value) ? Math.max(min, Math.min(max, value)) : fallback
331);
332const floorAtLeastZero: Rule = (value, fallback) => (isNumber(value) ? Math.max(0, Math.floor(value)) : fallback);
333const count: Rule = (value, fallback) => (Number.isInteger(value) && (value as number) >= 0 ? value : fallback);
334const text = (maxLength: number): Rule => (value, fallback) => (
335 typeof value === 'string' ? sanitizeDisplayText(value).slice(0, maxLength) : fallback
336);
337
338// Keeps known names once each, in order. An empty result falls back only when `nonEmpty`.
339const names = (known: readonly unknown[], nonEmpty: boolean): Rule => (value, fallback) => {
340 if (!Array.isArray(value)) return fallback;
341 const kept = [...new Set(value.filter(item => known.includes(item)))];
342 return kept.length > 0 || !nonEmpty ? kept : fallback;
343};
344
345// Groups need two or more known elements, and an element joins at most one group.
346const mergeGroups: Rule = (value, fallback) => {
347 if (!Array.isArray(value)) return fallback;
348 if (value.length === 0) return [];
349 const used = new Set<unknown>();
350 const groups: unknown[][] = [];
351 for (const group of value) {
352 if (!Array.isArray(group)) continue;
353 const members = [...new Set(group.filter(item => (ELEMENTS as readonly unknown[]).includes(item) && !used.has(item)))];
354 if (members.length < 2) continue;
355 members.forEach(member => used.add(member));
356 groups.push(members);
357 }
358 return groups.length > 0 ? groups : fallback;
359};
360
361const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
362const color: Rule = (value, fallback) => (
363 COLOR_NAMES.includes(value as HudColorName)
364 || (Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 255)
365 || (typeof value === 'string' && HEX_COLOR.test(value))
366 ? value
367 : fallback
368);
369
370// Exactly one visible grapheme: no controls, format, variation, separator, or unassigned code points.
371const INVISIBLE_CODEPOINT = /[\p{Cc}\p{Cf}\p{Variation_Selector}\p{Zl}\p{Zp}\p{Cn}]/u;
372const barChar: Rule = (value, fallback) => {
373 if (typeof value !== 'string' || value.length === 0) return fallback;
374 const graphemes = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)];
375 return graphemes.length === 1 && !INVISIBLE_CODEPOINT.test(value) ? value : fallback;
376};
377
378// Expands a leading ~ and ${VAR}; unset variables are left as written.
379const usagePath: Rule = (value) => (
380 typeof value === 'string'
381 ? expandHomeDirPrefix(value.trim(), os.homedir())
382 .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => process.env[name] ?? match)
383 : ''
384);
385
386// Booleans need no rule: a default of type boolean accepts only booleans.
387const RULES: Record<string, Rule> = {
388 'language': oneOf(LANGUAGES),
389 'lineLayout': oneOf(LINE_LAYOUTS),
390 'pathLevels': oneOf(PATH_LEVELS),
391 'maxWidth': (value) => (isNumber(value) && value > 0 ? Math.min(Math.floor(value), MAX_TERMINAL_WIDTH) : null),
392 'elementOrder': names(ELEMENTS, true),
393 'projectLineOrder': names(FIRST_LINE_SEGMENTS, false),
394 'gitStatus.branchOverflow': oneOf(GIT_BRANCH_OVERFLOW_MODES),
395 'gitStatus.pushWarningThreshold': floorAtLeastZero,
396 'gitStatus.pushCriticalThreshold': floorAtLeastZero,
397 'display.addedDirsLayout': oneOf(ADDED_DIRS_LAYOUTS),
398 'display.contextValue': oneOf(CONTEXT_VALUE_MODES),
399 'display.usageValue': oneOf(USAGE_VALUE_MODES),
400 'display.toolNameMaxLength': count,
401 'display.toolsMaxVisible': count,
402 'display.skillsMaxVisible': count,
403 'display.authUserLength': count,
404 'display.effortFormat': oneOf(EFFORT_FORMATS),
405 'display.mergeGroups': mergeGroups,
406 'display.rightAlign': names(ELEMENTS, false),
407 'display.contextWarningThreshold': clamp(0, 100),
408 'display.contextCriticalThreshold': clamp(0, 100),
409 'display.usageThreshold': clamp(0, 100),
410 'display.sevenDayThreshold': clamp(0, 100),
411 'display.environmentThreshold': clamp(0, 100),
412 'display.externalUsagePath': usagePath,
413 'display.externalUsageWritePath': usagePath,
414 'display.externalUsageFreshnessMs': floorAtLeastZero,
415 'display.modelFormat': oneOf(MODEL_FORMATS),
416 'display.modelOverride': text(80),
417 'display.modelSource': oneOf(MODEL_SOURCES),
418 'display.providerName': text(40),
419 'display.customLine': text(80),
420 'display.customLinePosition': oneOf(CUSTOM_LINE_POSITIONS),
421 'display.timeFormat': oneOf(TIME_FORMATS),
422 'display.hourCycle': oneOf(HOUR_CYCLES),
423 'display.advisorOverride': text(80),
424 'display.autoCompactWindow': (value) => (Number.isInteger(value) && (value as number) > 0 ? value : null),
425 'colors.barFilled': barChar,
426 'colors.barEmpty': barChar,
427 'colors.*': color,
428};
429
430function isPlainObject(value: unknown): value is Record<string, unknown> {
431 return typeof value === 'object' && value !== null && !Array.isArray(value);
432}
433
434// Walks the defaults, so unknown user keys are dropped and every key is validated.
435function normalize(defaults: Record<string, unknown>, input: unknown, prefix = ''): Record<string, unknown> {
436 const source = isPlainObject(input) ? input : {};
437 const result: Record<string, unknown> = {};
438 for (const [key, fallback] of Object.entries(defaults)) {
439 const keyPath = prefix + key;
440 const rule = RULES[keyPath] ?? RULES[`${prefix}*`];
441 const value = source[key];
442 if (rule) {
443 result[key] = rule(value, fallback);
444 } else if (isPlainObject(fallback)) {
445 result[key] = normalize(fallback, value, `${keyPath}.`);
446 } else {
447 result[key] = typeof fallback === 'boolean' && typeof value === 'boolean' ? value : fallback;
448 }
449 }
450 return result;
451}
452
453// v0.0.x wrote `layout: "default" | "separators"`; some third-party tools write an object.
454function migrateLegacyLayout(config: Record<string, unknown>): Record<string, unknown> {
455 if (!('layout' in config) || 'lineLayout' in config) return config;
456 const { layout, ...rest } = config;
457 if (typeof layout === 'string') {
458 return { ...rest, lineLayout: 'compact', showSeparators: layout === 'separators' };
459 }
460 if (isPlainObject(layout)) {
461 const { lineLayout, showSeparators, pathLevels } = layout;
462 return {
463 ...rest,
464 ...(typeof lineLayout === 'string' && { lineLayout }),
465 ...(typeof showSeparators === 'boolean' && { showSeparators }),
466 ...((typeof pathLevels === 'number' || pathLevels === 'full') && { pathLevels }),
467 };
468 }
469 return rest;
470}
471
472export function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
473 const migrated = migrateLegacyLayout(userConfig as Record<string, unknown>);
474 const defaults = structuredClone(DEFAULT_CONFIG) as unknown as Record<string, unknown>;
475 return normalize(defaults, migrated) as unknown as HudConfig;
476}
477
478function hasSafeConfigShape(value: unknown, depth = 0): boolean {
479 if (depth > MAX_CONFIG_NESTING_DEPTH) return false;
480 if (Array.isArray(value)) return value.every(item => hasSafeConfigShape(item, depth + 1));
481 if (!isPlainObject(value)) return true;
482 return Object.entries(value).every(([key, child]) => (
483 !UNSAFE_CONFIG_KEYS.has(key) && hasSafeConfigShape(child, depth + 1)
484 ));
485}
486
487// Sections merge key by key; arrays and scalars replace the base value.
488function mergeOverrides(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
489 const result = Object.assign(Object.create(null), base) as Record<string, unknown>;
490 for (const [key, value] of Object.entries(override)) {
491 const current = result[key];
492 result[key] = isPlainObject(current) && isPlainObject(value) ? mergeOverrides(current, value) : value;
493 }
494 return result;
495}
496
497// Checks and reads through one descriptor so a swapped or growing file can't slip past either guard.
498function readConfigFile(configPath: string): Record<string, unknown> | null {
499 try {
500 // O_NOFOLLOW rejects symlinks on POSIX; it is undefined on Windows.
501 const fd = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
502 try {
503 if (!fs.fstatSync(fd).isFile()) {
504 debug('Ignoring %s: not a regular file', configPath);
505 return null;
506 }
507 // hud mod: one bounded whole read; O_NOFOLLOW becomes an lstat check.
508 if (fs.lstatSync(configPath).isSymbolicLink()) {
509 debug('Ignoring %s: not a regular file', configPath);
510 return null;
511 }
512 const content = fs.readFileSync(fd, 'utf-8');
513 if (new TextEncoder().encode(content).length > MAX_CONFIG_FILE_BYTES) {
514 debug('Ignoring %s: larger than %d bytes', configPath, MAX_CONFIG_FILE_BYTES);
515 return null;
516 }
517 const parsed: unknown = JSON.parse(content);
518 if (!isPlainObject(parsed) || !hasSafeConfigShape(parsed)) {
519 debug('Ignoring %s: not a bounded JSON object without unsafe keys', configPath);
520 return null;
521 }
522 return parsed;
523 } finally {
524 fs.closeSync(fd);
525 }
526 } catch (err) {
527 if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
528 debug('Ignoring %s:', configPath, err instanceof Error ? err.message : err);
529 }
530 return null;
531 }
532}
533
534// The mod's own options laid over the loaded config (the mod, not upstream).
535let configPatch: (config: HudConfig) => HudConfig = (config) => config;
536
537export function setConfigPatch(patch: (config: HudConfig) => HudConfig): void {
538 configPatch = patch;
539}
540
541export async function loadConfig(): Promise<HudConfig> {
542 const base = readConfigFile(getConfigPath()) ?? {};
543 const override = readConfigFile(getConfigOverridePath());
544 return configPatch(mergeConfig((override ? mergeOverrides(base, override) : base) as Partial<HudConfig>));
545}
546hooks/hud/i18n/index.ts 77 lines1import type { Language, MessageKey, Messages } from "./types.js";
2import { en } from "./en.js";
3import { zhHans } from "./zh-Hans.js";
4import { zhHant } from "./zh-Hant.js";
5import { ja } from "./ja.js";
6import { ko } from "./ko.js";
7import { es } from "./es.js";
8import { fr } from "./fr.js";
9import { de } from "./de.js";
10import { ptBR } from "./pt-BR.js";
11import { ru } from "./ru.js";
12
13export type { Language, MessageKey, Messages };
14
15export type CanonicalLanguage = "en" | "zh-Hans" | "zh-Hant" | "ja" | "ko" | "es" | "fr" | "de" | "pt-BR" | "ru";
16
17const locales: Record<CanonicalLanguage, Messages> = {
18 en,
19 "zh-Hans": zhHans,
20 "zh-Hant": zhHant,
21 ja,
22 ko,
23 es,
24 fr,
25 de,
26 "pt-BR": ptBR,
27 ru,
28};
29
30// Resolve short language tags to canonical BCP 47 forms.
31// Based on CLDR likely subtags: zh → zh-Hans-CN
32// https://www.unicode.org/cldr/charts/latest/supplemental/likely_subtags.html
33const CANONICAL: Record<Language, CanonicalLanguage> = {
34 "en": "en",
35 "zh": "zh-Hans",
36 "zh-Hans": "zh-Hans",
37 "zh-Hant": "zh-Hant",
38 "zh-TW": "zh-Hant",
39 "ja": "ja",
40 "ko": "ko",
41 "es": "es",
42 "fr": "fr",
43 "de": "de",
44 "pt": "pt-BR",
45 "pt-BR": "pt-BR",
46 "ru": "ru",
47};
48
49let currentLanguage: Language = "en";
50
51export function setLanguage(lang: Language): void {
52 currentLanguage = lang;
53}
54
55// https://www.rfc-editor.org/info/bcp47
56export function getCanonicalLanguage(): CanonicalLanguage {
57 return CANONICAL[currentLanguage] ?? "en";
58}
59
60// https://www.unicode.org/reports/tr11/
61export function isCjkLanguage(): boolean {
62 const canon = getCanonicalLanguage();
63 return canon === "zh-Hans" || canon === "zh-Hant" || canon === "ja" || canon === "ko";
64}
65
66export function t(key: MessageKey): string {
67 const canon = getCanonicalLanguage();
68 return locales[canon]?.[key] ?? locales.en[key] ?? key;
69}
70
71// Minimal named-placeholder interpolation. Layout that varies by language
72// (spacing, affix position) lives in each locale's pattern string rather than in
73// render code. Unknown placeholders render as empty string (kept lenient).
74export function interpolate(pattern: string, params: Record<string, string | number>): string {
75 return pattern.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ""));
76}
77hooks/hud/transcript.ts 386 lines1import * as fs from '../shims/fs.js';
2import * as readline from '../shims/readline.js';
3import { createDebug } from './debug.js';
4import type { AgentEntry, SessionTokenUsage, TodoItem, ToolEntry, TranscriptData } from './types.js';
5import { sanitizeDisplayText } from './utils/sanitize.js';
6import { sanitizeTranscriptModel } from './model-source.js';
7
8const debug = createDebug('transcript');
9
10const TOOLS_KEPT = 20;
11const AGENTS_KEPT = 10;
12const NAME_MAX_LEN = 64;
13const ADVISOR_MODEL_MAX_LEN = 64;
14const MESSAGE_ID_MAX_LEN = 128;
15const MESSAGE_IDS_MAX = 4096;
16const MCP_ERRORS_MAX = 64;
17const MCP_TOOL = /^mcp__(.+?)__(.+)$/;
18// Claude Code's /effort output; anchored so prose quoting it can't flip ultracode.
19const EFFORT_COMMAND = /^<local-command-stdout>Set effort level to (\w+)/;
20
21interface Usage {
22 input_tokens?: unknown;
23 output_tokens?: unknown;
24 cache_creation_input_tokens?: unknown;
25 cache_read_input_tokens?: unknown;
26}
27
28interface Block {
29 type?: string;
30 id?: string;
31 name?: string;
32 input?: Record<string, unknown>;
33 tool_use_id?: string;
34 is_error?: boolean;
35}
36
37interface Entry {
38 type?: string;
39 subtype?: string;
40 operation?: string;
41 content?: unknown;
42 timestamp?: string;
43 isSidechain?: boolean;
44 advisorModel?: unknown;
45 message?: { id?: unknown; model?: unknown; content?: Block[] | string; usage?: Usage };
46 toolUseResult?: { resolvedModel?: unknown; isAsync?: unknown; status?: unknown };
47 compactMetadata?: { postTokens?: unknown };
48 attachment?: { type?: string };
49}
50
51const emptyTranscript = (): TranscriptData => ({ tools: [], skills: [], mcpServers: [], mcpErrors: [], agents: [], todos: [] });
52
53const ZERO_USAGE: SessionTokenUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
54const USAGE_FIELDS = Object.keys(ZERO_USAGE) as (keyof SessionTokenUsage)[];
55
56const count = (value: unknown): number =>
57 typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
58
59function addUsage(total: SessionTokenUsage, usage: SessionTokenUsage): void {
60 for (const field of USAGE_FIELDS) total[field] += usage[field];
61}
62
63function name(value: unknown): string | undefined {
64 if (typeof value !== 'string') return undefined;
65 const text = sanitizeDisplayText(value).trim();
66 if (!text) return undefined;
67 return text.length <= NAME_MAX_LEN ? text : `${text.slice(0, NAME_MAX_LEN - 1)}…`;
68}
69
70const mcpServer = (toolName: string): string | undefined => name(MCP_TOOL.exec(toolName)?.[1]);
71
72// Tool inputs are written by the model, so their text is untrusted terminal input.
73const text = (value: unknown): string | undefined =>
74 typeof value === 'string' ? sanitizeDisplayText(value) || undefined : undefined;
75
76function toolTarget(toolName: string, input: Record<string, unknown> | undefined): string | undefined {
77 if (!input) return undefined;
78 switch (toolName) {
79 case 'Read':
80 case 'Write':
81 case 'Edit':
82 return text(input.file_path ?? input.path);
83 case 'Glob':
84 case 'Grep':
85 return text(input.pattern);
86 case 'Skill':
87 return name(input.skill);
88 case 'Bash': {
89 const command = text(input.command)?.replace(/\s+/g, ' ').trim();
90 if (!command) return undefined;
91 return command.length > 30 ? `${command.slice(0, 30).trimEnd()}...` : command;
92 }
93 }
94 return undefined;
95}
96
97function taskStatus(status: unknown): TodoItem['status'] | null {
98 switch (status) {
99 case 'pending':
100 case 'not_started':
101 return 'pending';
102 case 'in_progress':
103 case 'running':
104 return 'in_progress';
105 case 'completed':
106 case 'complete':
107 case 'done':
108 return 'completed';
109 default:
110 return null;
111 }
112}
113
114function toTodo(value: unknown): TodoItem[] {
115 const todo = value as { content?: unknown; status?: unknown } | null;
116 const content = text(todo?.content);
117 const status = taskStatus(todo?.status);
118 return content && status ? [{ content, status }] : [];
119}
120
121export class Parser {
122 private tools = new Map<string, ToolEntry>();
123 private agents = new Map<string, AgentEntry>();
124 private skills = new Set<string>();
125 private mcpServers = new Set<string>();
126 private mcpErrors = new Set<string>();
127 private todos: TodoItem[] = [];
128 private taskIndex = new Map<string, number>();
129 private agentCompletions = new Map<string, Date>();
130 // Claude Code logs one API response several times, sometimes non-adjacently, so usage
131 // is the per-field max per message id. Ids evicted to bound memory settle into `settled`.
132 private usageById = new Map<string, SessionTokenUsage>();
133 private settled: SessionTokenUsage = { ...ZERO_USAGE };
134 private lastIdlessUsage: string | undefined;
135 private data: TranscriptData = { ...emptyTranscript(), compactionCount: 0 };
136
137 line(raw: string): void {
138 let entry: Entry | null = null;
139 try {
140 entry = raw.trim() ? JSON.parse(raw) : null;
141 } catch {
142 // Malformed lines are skipped.
143 }
144 if (!entry || typeof entry !== 'object') {
145 this.lastIdlessUsage = undefined;
146 return;
147 }
148
149 const time = entry.timestamp ? new Date(entry.timestamp) : null;
150 const at = time && !Number.isNaN(time.getTime()) ? time : null;
151 if (at && !this.data.sessionStart) this.data.sessionStart = at;
152
153 if (entry.type === 'assistant') {
154 this.assistant(entry, at);
155 } else {
156 this.lastIdlessUsage = undefined;
157 }
158 if (entry.type === 'user' && typeof entry.message?.content === 'string') {
159 const effort = EFFORT_COMMAND.exec(entry.message.content);
160 if (effort) this.data.ultracodeActive = effort[1].toLowerCase() === 'ultracode';
161 }
162 if (entry.type === 'attachment') {
163 if (entry.attachment?.type === 'ultra_effort_enter') this.data.ultracodeActive = true;
164 if (entry.attachment?.type === 'ultra_effort_exit') this.data.ultracodeActive = false;
165 }
166 if (entry.type === 'system' && entry.subtype === 'compact_boundary' && at) {
167 this.data.compactionCount = (this.data.compactionCount ?? 0) + 1;
168 const post = entry.compactMetadata?.postTokens;
169 this.data.contextTokens = typeof post === 'number' && Number.isFinite(post) && post >= 0 ? Math.trunc(post) : undefined;
170 }
171 // A background agent's tool_result lands at launch; its completion is this enqueue.
172 if (entry.type === 'queue-operation' && entry.operation === 'enqueue' && typeof entry.content === 'string' && at) {
173 const toolUseId = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(entry.content)?.[1];
174 if (toolUseId && /<task-id>[^<]+<\/task-id>/.test(entry.content)) this.agentCompletions.set(toolUseId, at);
175 }
176
177 if (Array.isArray(entry.message?.content)) {
178 for (const block of entry.message.content) {
179 if (block?.type === 'tool_use' && block.id && block.name) this.toolUse(block, at ?? new Date());
180 if (block?.type === 'tool_result' && block.tool_use_id) this.toolResult(block, entry, at ?? new Date());
181 }
182 }
183 }
184
185 private assistant(entry: Entry, at: Date | null): void {
186 if (at) this.data.lastAssistantResponseAt = at;
187 if (typeof entry.advisorModel === 'string' && entry.advisorModel) {
188 this.data.advisorModel = entry.advisorModel.slice(0, ADVISOR_MODEL_MAX_LEN);
189 }
190 const model = sanitizeTranscriptModel(entry.message?.model);
191 // Claude Code writes '<synthetic>' on assistant records it generates locally.
192 if (model && model !== '<synthetic>') this.data.lastAssistantModel = model;
193
194 const raw = entry.message?.usage;
195 if (!raw) {
196 this.lastIdlessUsage = undefined;
197 return;
198 }
199 const usage: SessionTokenUsage = {
200 inputTokens: count(raw.input_tokens),
201 outputTokens: count(raw.output_tokens),
202 cacheCreationTokens: count(raw.cache_creation_input_tokens),
203 cacheReadTokens: count(raw.cache_read_input_tokens),
204 };
205 if (entry.isSidechain !== true) {
206 this.data.contextTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens;
207 }
208
209 const id = entry.message?.id;
210 if (typeof id === 'string' && id && id.length <= MESSAGE_ID_MAX_LEN) {
211 this.lastIdlessUsage = undefined;
212 const previous = this.usageById.get(id);
213 this.usageById.set(id, previous ? maxUsage(previous, usage) : usage);
214 if (this.usageById.size > MESSAGE_IDS_MAX) {
215 const [oldestId, oldest] = this.usageById.entries().next().value as [string, SessionTokenUsage];
216 this.usageById.delete(oldestId);
217 addUsage(this.settled, oldest);
218 }
219 return;
220 }
221 // Without an id, only an identical record right after the previous one is a duplicate.
222 const fingerprint = JSON.stringify(usage);
223 if (fingerprint !== this.lastIdlessUsage) addUsage(this.settled, usage);
224 this.lastIdlessUsage = fingerprint;
225 }
226
227 private toolUse(block: Block, at: Date): void {
228 const toolName = block.name as string;
229 const input = block.input;
230 const skill = toolName === 'Skill' ? name(input?.skill) : undefined;
231 if (skill) this.skills.add(skill);
232 const server = mcpServer(toolName);
233 if (server) this.mcpServers.add(server);
234
235 if (toolName === 'Task' || toolName === 'Agent') {
236 this.agents.set(block.id as string, {
237 id: block.id as string,
238 type: (input?.subagent_type as string) ?? 'agent',
239 model: sanitizeTranscriptModel(input?.model),
240 description: (input?.description as string) ?? undefined,
241 status: 'running',
242 startTime: at,
243 background: input?.run_in_background === true,
244 });
245 } else if (toolName === 'TodoWrite') {
246 if (Array.isArray(input?.todos)) this.replaceTodos(input.todos.flatMap(toTodo));
247 } else if (toolName === 'TaskCreate') {
248 const content = text(input?.subject) ?? text(input?.description);
249 this.todos.push({ content: content ?? 'Untitled task', status: taskStatus(input?.status) ?? 'pending' });
250 const taskId = typeof input?.taskId === 'string' || typeof input?.taskId === 'number' ? String(input.taskId) : block.id;
251 if (taskId) this.taskIndex.set(taskId, this.todos.length - 1);
252 } else if (toolName === 'TaskUpdate') {
253 const todo = this.todos[this.findTask(input?.taskId) ?? -1];
254 if (!todo) return;
255 const status = taskStatus(input?.status);
256 if (status) todo.status = status;
257 const content = text(input?.subject) ?? text(input?.description);
258 if (content) todo.content = content;
259 } else {
260 this.tools.set(block.id as string, {
261 id: block.id as string,
262 name: toolName,
263 target: toolTarget(toolName, input),
264 status: 'running',
265 startTime: at,
266 });
267 }
268 }
269
270 private toolResult(block: Block, entry: Entry, at: Date): void {
271 const id = block.tool_use_id as string;
272 const tool = this.tools.get(id);
273 if (tool) {
274 tool.status = block.is_error ? 'error' : 'completed';
275 tool.endTime = at;
276 const server = mcpServer(tool.name);
277 if (server && block.is_error) {
278 this.mcpErrors.add(server);
279 if (this.mcpErrors.size > MCP_ERRORS_MAX) this.mcpErrors.delete(this.mcpErrors.values().next().value as string);
280 } else if (server) {
281 this.mcpErrors.delete(server);
282 }
283 }
284
285 const agent = this.agents.get(id);
286 if (agent) {
287 // resolvedModel is what the subagent actually ran on, so it beats the caller's alias.
288 agent.model = sanitizeTranscriptModel(entry.toolUseResult?.resolvedModel) ?? agent.model;
289 if (entry.toolUseResult?.isAsync === true || entry.toolUseResult?.status === 'async_launched') {
290 agent.background = true;
291 }
292 if (!agent.background) agent.endTime = at;
293 }
294 }
295
296 // TodoWrite replaces the list; TaskCreate ids follow their todo by content, in order,
297 // so duplicate-content todos each keep their own id.
298 private replaceTodos(next: TodoItem[]): void {
299 const idsByContent = new Map<string, string[]>();
300 for (const [taskId, index] of [...this.taskIndex].sort((a, b) => a[1] - b[1])) {
301 const content = this.todos[index]?.content;
302 if (content === undefined) continue;
303 idsByContent.set(content, [...(idsByContent.get(content) ?? []), taskId]);
304 }
305 this.todos = [...next];
306 this.taskIndex.clear();
307 this.todos.forEach((todo, index) => {
308 const taskId = idsByContent.get(todo.content)?.shift();
309 if (taskId) this.taskIndex.set(taskId, index);
310 });
311 }
312
313 // TaskUpdate names a task by the id TaskCreate returned, or by its 1-based position.
314 private findTask(taskId: unknown): number | null {
315 if (typeof taskId !== 'string' && typeof taskId !== 'number') return null;
316 const key = String(taskId);
317 const mapped = this.taskIndex.get(key);
318 if (mapped !== undefined) return mapped;
319 const position = /^\d+$/.test(key) ? Number(key) - 1 : -1;
320 return position >= 0 && position < this.todos.length ? position : null;
321 }
322
323 finish(): TranscriptData {
324 for (const [id, endTime] of this.agentCompletions) {
325 const agent = this.agents.get(id);
326 if (agent?.background) agent.endTime = endTime;
327 }
328 for (const agent of this.agents.values()) {
329 if (agent.endTime) agent.status = 'completed';
330 }
331 const sessionTokens = { ...this.settled };
332 for (const usage of this.usageById.values()) addUsage(sessionTokens, usage);
333 return {
334 ...this.data,
335 tools: [...this.tools.values()].slice(-TOOLS_KEPT),
336 agents: [...this.agents.values()].slice(-AGENTS_KEPT),
337 skills: [...this.skills],
338 mcpServers: [...this.mcpServers],
339 mcpErrors: [...this.mcpErrors],
340 todos: this.todos,
341 sessionTokens,
342 };
343 }
344}
345
346function maxUsage(a: SessionTokenUsage, b: SessionTokenUsage): SessionTokenUsage {
347 return {
348 inputTokens: Math.max(a.inputTokens, b.inputTokens),
349 outputTokens: Math.max(a.outputTokens, b.outputTokens),
350 cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens),
351 cacheReadTokens: Math.max(a.cacheReadTokens, b.cacheReadTokens),
352 };
353}
354
355// hud mod: the mod parses the transcript incrementally (only appended lines,
356// one Parser kept per transcript) and answers here; the stream read below
357// stays as upstream wrote it for when no provider is set.
358let transcriptProvider: ((transcriptPath: string) => Promise<TranscriptData | null>) | null = null;
359
360export function setTranscriptProvider(provider: (transcriptPath: string) => Promise<TranscriptData | null>): void {
361 transcriptProvider = provider;
362}
363
364export async function parseTranscript(transcriptPath: string): Promise<TranscriptData> {
365 if (transcriptProvider) {
366 const provided = await transcriptProvider(transcriptPath);
367 if (provided) return provided;
368 }
369 try {
370 if (!transcriptPath || !fs.statSync(transcriptPath).isFile()) return emptyTranscript();
371 } catch {
372 return emptyTranscript();
373 }
374 const parser = new Parser();
375 try {
376 const input = fs.createReadStream(transcriptPath);
377 for await (const raw of readline.createInterface({ input, crlfDelay: Infinity })) {
378 parser.line(raw);
379 }
380 } catch (err) {
381 // A read cut short still renders what was parsed.
382 debug('Transcript read failed:', err instanceof Error ? err.message : err);
383 }
384 return parser.finish();
385}
386hooks/hud/types.ts 203 lines1import type { HudConfig } from './config.js';
2import type { GitRepoIdentity, GitStatus } from './git.js';
3import type { AuthInfo } from './auth.js';
4import type { CostTotals } from './daily-cost.js';
5
6// The statusline payload Claude Code writes to stdin (code.claude.com/docs/en/statusline).
7export interface StdinData {
8 session_id?: string;
9 session_name?: string;
10 version?: string;
11 transcript_path?: string;
12 cwd?: string;
13 workspace?: {
14 current_dir?: string;
15 project_dir?: string;
16 added_dirs?: string[];
17 git_worktree?: string;
18 repo?: GitRepoIdentity;
19 } | null;
20 model?: {
21 id?: string;
22 display_name?: string;
23 };
24 output_style?: { name?: string };
25 context_window?: {
26 context_window_size?: number;
27 total_input_tokens?: number | null;
28 total_output_tokens?: number | null;
29 current_usage?: {
30 input_tokens?: number;
31 output_tokens?: number;
32 cache_creation_input_tokens?: number;
33 cache_read_input_tokens?: number;
34 } | null;
35 used_percentage?: number | null;
36 remaining_percentage?: number | null;
37 };
38 cost?: {
39 total_cost_usd?: number | null;
40 total_duration_ms?: number | null;
41 total_api_duration_ms?: number | null;
42 total_lines_added?: number | null;
43 total_lines_removed?: number | null;
44 } | null;
45 rate_limits?: {
46 five_hour?: RateLimitWindow | null;
47 seven_day?: RateLimitWindow | null;
48 spend_limit?: RateLimitWindow | null;
49 } | null;
50 // hud mod: the model-scoped weekly windows (e.g. Fable), as the external snapshot's model_scoped.
51 model_scoped?: ExternalUsageSnapshot['model_scoped'];
52 prompt_cache?: {
53 warm?: boolean;
54 caching_observed?: boolean;
55 ttl?: string;
56 expires_at?: number | null;
57 hit_ratio?: number | null;
58 } | null;
59 effort?: { level?: string } | null;
60 worktree?: { name?: string; path?: string; branch?: string } | null;
61}
62
63interface RateLimitWindow {
64 used_percentage?: number | null;
65 resets_at?: number | null;
66}
67
68export interface ToolEntry {
69 id: string;
70 name: string;
71 target?: string;
72 status: 'running' | 'completed' | 'error';
73 startTime: Date;
74 endTime?: Date;
75}
76
77export interface AgentEntry {
78 id: string;
79 type: string;
80 model?: string;
81 description?: string;
82 status: 'running' | 'completed';
83 startTime: Date;
84 endTime?: Date;
85 background?: boolean;
86}
87
88export interface TodoItem {
89 content: string;
90 status: 'pending' | 'in_progress' | 'completed';
91}
92
93export interface UsageData {
94 fiveHour: number | null; // 0-100 percentage, null if unavailable
95 sevenDay: number | null; // 0-100 percentage, null if unavailable
96 fiveHourResetAt: Date | null;
97 sevenDayResetAt: Date | null;
98 balanceLabel?: string | null; // optional raw balance text (e.g. "¥6.35")
99 // Model-scoped weekly windows (e.g. Fable), from the external usage snapshot.
100 scopedWindows?: ScopedUsageWindow[];
101}
102
103/** One model-scoped weekly quota window (e.g. label "Fable", used percent 0-100). */
104export interface ScopedUsageWindow {
105 label: string;
106 percent: number | null;
107 resetAt: Date | null;
108}
109
110export interface ExternalUsageSnapshot {
111 five_hour?: {
112 used_percentage?: number | null;
113 resets_at?: string | number | null;
114 } | null;
115 seven_day?: {
116 used_percentage?: number | null;
117 resets_at?: string | number | null;
118 } | null;
119 updated_at?: string | number | null;
120 balance_label?: string | null;
121 // Model-scoped weekly windows (e.g. Fable), in the shape of Claude Code's /usage data.
122 model_scoped?: Array<{
123 display_name?: string | null;
124 utilization?: number | null;
125 resets_at?: string | null;
126 }> | null;
127}
128
129export interface MemoryInfo {
130 totalBytes: number;
131 usedBytes: number;
132 freeBytes: number;
133 usedPercent: number;
134}
135
136/** Check if usage limit is reached (either window at 100%) */
137export function isLimitReached(data: UsageData): boolean {
138 return data.fiveHour === 100 || data.sevenDay === 100;
139}
140
141export interface SessionTokenUsage {
142 inputTokens: number;
143 outputTokens: number;
144 cacheCreationTokens: number;
145 cacheReadTokens: number;
146}
147
148export interface TranscriptData {
149 tools: ToolEntry[];
150 skills: string[];
151 mcpServers: string[];
152 /**
153 * MCP servers whose latest observed tool result is an error, derived
154 * from `mcp__<server>__<tool>` results carrying is_error. Distinct from
155 * mcpServers, which is a plain activity list.
156 */
157 mcpErrors: string[];
158 agents: AgentEntry[];
159 todos: TodoItem[];
160 sessionStart?: Date;
161 // Last assistant response of any kind, subagents included. Drives the
162 // last-response element.
163 lastAssistantResponseAt?: Date;
164 sessionTokens?: SessionTokenUsage;
165 // Number of compact_boundary entries (manual /compact or auto compaction)
166 // with a valid timestamp seen in the transcript.
167 compactionCount?: number;
168 // Tokens in the main conversation's context as of its last request, or the
169 // post-compaction size after a compact boundary.
170 contextTokens?: number;
171 // Advisor model ID for the current session, captured from the top-level
172 // `advisorModel` field that Claude Code stamps onto every assistant record
173 // after `/advisor` is set (e.g. "claude-opus-4-7"). undefined when /advisor
174 // is off or no assistant turn has happened yet.
175 advisorModel?: string;
176 // Current ultracode effort state from the most recent transcript signal
177 // (`ultra_effort_enter`/`ultra_effort_exit` attachment or `/effort` output).
178 // undefined when ultracode was never entered this session.
179 ultracodeActive?: boolean;
180 // Model ID from the most recent assistant message's `message.model` field.
181 // This reflects what the API actually served — may differ from stdin.model
182 // when a proxy (e.g. cc-switch) routes to a different model. Transcript
183 // parsing sanitizes terminal controls and caps the retained value at 80 chars.
184 lastAssistantModel?: string;
185}
186
187export interface RenderContext {
188 stdin: StdinData;
189 transcript: TranscriptData;
190 claudeMdCount: number;
191 rulesCount: number;
192 mcpCount: number;
193 hooksCount: number;
194 costTotals: CostTotals | null;
195 outputSpeed: number | null;
196 gitStatus: GitStatus | null;
197 usageData: UsageData | null;
198 memoryUsage: MemoryInfo | null;
199 config: HudConfig;
200 extraLabel: string | null;
201 authInfo?: AuthInfo | null;
202}
203hooks/hud/usage-pace.ts 98 lines1import type { HudConfig } from './config.js';
2import type { ScopedUsageWindow, UsageData } from './types.js';
3
4export type UsagePace = 'normal' | 'warning' | 'critical';
5
6export const FIVE_HOUR_WINDOW_MS = 5 * 60 * 60 * 1000;
7export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
8
9// Below this much usage a linear projection is noise (3% five minutes in
10// "projects" 180%), so pace stays normal.
11const MIN_USED_PERCENT = 10;
12// Amber once the current rate would end the window at 90% of the limit or more.
13const WARNING_PROJECTED_PERCENT = 90;
14// Red once the current rate would exhaust the limit before the window resets.
15const CRITICAL_PROJECTED_PERCENT = 100;
16
17/**
18 * Grades how fast a rate-limit window is being consumed by projecting the
19 * used percentage linearly to the window's reset. Returns null when there is
20 * no usable rate: missing data, a reset already past, or a reset at least a
21 * full window away (no time elapsed).
22 */
23export function getUsagePace(
24 percent: number | null,
25 resetAt: Date | null,
26 windowMs: number,
27 now: number = Date.now(),
28): UsagePace | null {
29 if (percent === null || !resetAt) {
30 return null;
31 }
32
33 const remainingMs = resetAt.getTime() - now;
34 if (!Number.isFinite(remainingMs) || remainingMs <= 0 || remainingMs >= windowMs) {
35 return null;
36 }
37
38 if (percent < MIN_USED_PERCENT) {
39 return 'normal';
40 }
41
42 const elapsedFraction = (windowMs - remainingMs) / windowMs;
43 const projected = percent / elapsedFraction;
44 if (projected > CRITICAL_PROJECTED_PERCENT) {
45 return 'critical';
46 }
47 if (projected >= WARNING_PROJECTED_PERCENT) {
48 return 'warning';
49 }
50 return 'normal';
51}
52
53/** True when pace should draw attention (amber or red). */
54export function isPaceAlert(pace: UsagePace | null): boolean {
55 return pace === 'warning' || pace === 'critical';
56}
57
58/** Pace of every usage window, plus the display rules pace imposes. */
59export interface UsagePaces {
60 fiveHour: UsagePace | null;
61 sevenDay: UsagePace | null;
62 /** Parallel to the scoped windows passed in. */
63 scoped: Array<UsagePace | null>;
64 /** Some window is at amber/red pace: show usage despite `usageThreshold`. */
65 alert: boolean;
66 /** Show the weekly window: at/above `sevenDayThreshold`, or at amber/red pace. */
67 showSevenDay: boolean;
68}
69
70/**
71 * Grades every window's pace (all null unless `display.usagePace` is on) and
72 * resolves the visibility rules both usage renderers share.
73 */
74export function resolveUsagePaces(
75 usage: UsageData,
76 scopedWindows: ScopedUsageWindow[],
77 display: Partial<HudConfig['display']> | undefined,
78 now: number = Date.now(),
79): UsagePaces {
80 const enabled = display?.usagePace === true;
81 const paceOf = (percent: number | null, resetAt: Date | null, windowMs: number): UsagePace | null =>
82 enabled ? getUsagePace(percent, resetAt, windowMs, now) : null;
83
84 const fiveHour = paceOf(usage.fiveHour, usage.fiveHourResetAt, FIVE_HOUR_WINDOW_MS);
85 const sevenDay = paceOf(usage.sevenDay, usage.sevenDayResetAt, SEVEN_DAY_WINDOW_MS);
86 const scoped = scopedWindows.map((w) => paceOf(w.percent, w.resetAt, SEVEN_DAY_WINDOW_MS));
87 const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
88
89 return {
90 fiveHour,
91 sevenDay,
92 scoped,
93 alert: [fiveHour, sevenDay, ...scoped].some(isPaceAlert),
94 showSevenDay: usage.sevenDay !== null
95 && (usage.sevenDay >= sevenDayThreshold || isPaceAlert(sevenDay)),
96 };
97}
98hooks/i18n.ts 595 lines1// The mod's own strings (what it adds to claude-hud's lines), in the language
2// claude-hud is set to (`language` in its config), so one setting covers the HUD.
3import { type CanonicalLanguage, getCanonicalLanguage, interpolate } from './hud/i18n/index.js'
4
5/** A string, or its plural forms by `Intl.PluralRules` category (`other` required). */
6type Text = string | { one?: string; few?: string; many?: string; other: string }
7
8type Key =
9 | 'rc.label'
10 | 'rc.attached'
11 | 'rc.connected'
12 | 'surface.mobile'
13 | 'surface.desktop'
14 | 'forecast'
15 | 'limit.fiveHour'
16 | 'limit.sevenDay'
17 | 'limit.scoped'
18 | 'week'
19 | 'streak'
20 | 'git.dirty'
21 | 'git.ahead'
22 | 'compact.left'
23 | 'cache.cold'
24 | 'turn.growth'
25 | 'alert.context'
26 | 'alert.fiveHour'
27 | 'alert.sevenDay'
28 | 'alert.scoped'
29 | 'turn.done'
30 | 'cmd.description'
31 | 'theme.set'
32 | 'theme.list'
33 | 'theme.unknown'
34 | 'theme.ask'
35 | 'cmd.hidden'
36 | 'cmd.shown'
37 | 'pane.title'
38 | 'pane.opened'
39 | 'pane.closed'
40 | 'pane.tools'
41 | 'pane.noTools'
42 | 'pane.toolStats'
43 | 'pane.failed'
44 | 'pane.agents'
45 | 'pane.none'
46 | 'pane.todos'
47 | 'pane.spend'
48 | 'pane.turns'
49 | 'pane.turnRow'
50 | 'spend.today'
51 | 'spend.week'
52 | 'summary.language'
53
54// French (and German, Spanish, Russian) set a percent sign off with a space; French
55// also a colon. A no-break space keeps the sign on its number.
56const NB = ' '
57
58export const MESSAGES: Record<CanonicalLanguage, Record<Key, Text>> = {
59 en: {
60 'rc.label': '⇄ Remote Control',
61 'rc.attached': 'connected: {who}',
62 'rc.connected': 'connected',
63 'surface.mobile': 'phone',
64 'surface.desktop': 'web/desktop',
65 forecast: '{label} runs out ≈{time} at this pace',
66 'limit.fiveHour': '5-hour limit',
67 'limit.sevenDay': '7-day limit',
68 'limit.scoped': '{name} weekly limit',
69 week: '7d',
70 streak: '{n}-day streak',
71 'git.dirty': { one: '{n} uncommitted change', other: '{n} uncommitted changes' },
72 'git.ahead': { one: '{n} unpushed commit', other: '{n} unpushed commits' },
73 'compact.left': '{tokens} to auto-compact',
74 'cache.cold': 'cache cold: next message re-caches {tokens}',
75 'turn.growth': 'last turn +{tokens}',
76 'alert.context': 'Context is {p}% full — consider /compact',
77 'alert.fiveHour': '5-hour limit at {p}%',
78 'alert.sevenDay': '7-day limit at {p}%',
79 'alert.scoped': '{name} weekly limit at {p}%',
80 'turn.done': '✓ Done in {d}',
81 'cmd.description': 'Show or hide the claude-hud band; detail opens the details pane; theme switches its look',
82 'theme.set': 'HUD theme: {name}',
83 'theme.list': 'HUD themes (now {name}): {list}. /hud theme <name>, next or reset; * needs a Nerd Font',
84 'theme.unknown': 'No theme named {name}. Themes: {list}',
85 'theme.ask': 'Which HUD theme? (now {name}; Other: type any of {list})',
86 'cmd.hidden': 'claude-hud band hidden',
87 'cmd.shown': 'claude-hud band shown',
88 'pane.title': 'HUD details',
89 'pane.opened': 'HUD details pane opened (/hud detail closes it)',
90 'pane.closed': 'HUD details pane closed',
91 'pane.tools': 'Time by tool',
92 'pane.noTools': 'No tool calls in this session yet',
93 'pane.toolStats': '×{count} · {total} total · {avg} avg',
94 'pane.failed': '{n} failed',
95 'pane.agents': 'Subagents',
96 'pane.none': 'None',
97 'pane.todos': 'Todos',
98 'pane.spend': 'Spend',
99 'pane.turns': 'Recent turns',
100 'pane.turnRow': '#{n} · {time} · {cost} · context {tokens}',
101 'spend.today': 'Today {spent}',
102 'spend.week': '7 days {spent}',
103 'summary.language': 'English',
104 },
105 'zh-Hans': {
106 'rc.label': '⇄ 远程控制',
107 'rc.attached': '已连接 {who}',
108 'rc.connected': '已连接',
109 'surface.mobile': '手机',
110 'surface.desktop': '网页/桌面',
111 forecast: '{label}按当前速度 ≈{time} 用完',
112 'limit.fiveHour': '5 小时额度',
113 'limit.sevenDay': '7 天额度',
114 'limit.scoped': '{name} 周额度',
115 week: '7 天',
116 streak: '连续 {n} 天',
117 'git.dirty': '{n} 个改动未提交',
118 'git.ahead': '{n} 个提交未推送',
119 'compact.left': '距自动压缩 {tokens}',
120 'cache.cold': '缓存已过期,下条消息重写 {tokens}',
121 'turn.growth': '上一轮 +{tokens}',
122 'alert.context': '上下文已用 {p}%,可以考虑 /compact',
123 'alert.fiveHour': '5 小时额度已用 {p}%',
124 'alert.sevenDay': '7 天额度已用 {p}%',
125 'alert.scoped': '{name} 周额度已用 {p}%',
126 'turn.done': '✓ 本轮完成,用时 {d}',
127 'cmd.description': '显示 / 隐藏 claude-hud 横条;detail 打开详情面板;theme 切换主题',
128 'theme.set': 'HUD 主题:{name}',
129 'theme.list': 'HUD 主题(当前 {name}):{list}。/hud theme <名称>、next 或 reset;带 * 的需要 Nerd Font',
130 'theme.unknown': '没有名为 {name} 的主题。可选:{list}',
131 'theme.ask': '换哪套 HUD 主题?(当前 {name};也可在 Other 里输入:{list})',
132 'cmd.hidden': 'claude-hud 横条已隐藏',
133 'cmd.shown': 'claude-hud 横条已显示',
134 'pane.title': 'HUD 详情',
135 'pane.opened': 'HUD 详情面板已打开(/hud detail 关闭)',
136 'pane.closed': 'HUD 详情面板已关闭',
137 'pane.tools': '工具耗时',
138 'pane.noTools': '本会话还没有工具调用',
139 'pane.toolStats': '×{count} 共 {total} 均 {avg}',
140 'pane.failed': '失败 {n}',
141 'pane.agents': '子代理',
142 'pane.none': '无',
143 'pane.todos': '待办',
144 'pane.spend': '花费',
145 'pane.turns': '最近几轮',
146 'pane.turnRow': '#{n} · {time} · {cost} · 上下文 {tokens}',
147 'spend.today': '今日 {spent}',
148 'spend.week': '7 天 {spent}',
149 'summary.language': 'Simplified Chinese',
150 },
151 'zh-Hant': {
152 'rc.label': '⇄ 遠端控制',
153 'rc.attached': '已連線 {who}',
154 'rc.connected': '已連線',
155 'surface.mobile': '手機',
156 'surface.desktop': '網頁/桌面',
157 forecast: '{label}依目前速度 ≈{time} 用完',
158 'limit.fiveHour': '5 小時額度',
159 'limit.sevenDay': '7 天額度',
160 'limit.scoped': '{name} 週額度',
161 week: '7 天',
162 streak: '連續 {n} 天',
163 'git.dirty': '{n} 個變更尚未提交',
164 'git.ahead': '{n} 個提交尚未推送',
165 'compact.left': '距自動壓縮 {tokens}',
166 'cache.cold': '快取已過期,下則訊息重寫 {tokens}',
167 'turn.growth': '上一輪 +{tokens}',
168 'alert.context': '上下文已使用 {p}%,可以考慮執行 /compact',
169 'alert.fiveHour': '5 小時額度已使用 {p}%',
170 'alert.sevenDay': '7 天額度已使用 {p}%',
171 'alert.scoped': '{name} 週額度已使用 {p}%',
172 'turn.done': '✓ 本輪完成,耗時 {d}',
173 'cmd.description': '顯示/隱藏 claude-hud 橫條;detail 開啟詳細資訊面板;theme 切換主題',
174 'theme.set': 'HUD 主題:{name}',
175 'theme.list': 'HUD 主題(目前 {name}):{list}。/hud theme <名稱>、next 或 reset;帶 * 的需要 Nerd Font',
176 'theme.unknown': '沒有名為 {name} 的主題。可選:{list}',
177 'theme.ask': '換哪套 HUD 主題?(目前 {name};也可在 Other 輸入:{list})',
178 'cmd.hidden': 'claude-hud 橫條已隱藏',
179 'cmd.shown': 'claude-hud 橫條已顯示',
180 'pane.title': 'HUD 詳細資訊',
181 'pane.opened': 'HUD 詳細資訊面板已開啟(/hud detail 可關閉)',
182 'pane.closed': 'HUD 詳細資訊面板已關閉',
183 'pane.tools': '工具耗時',
184 'pane.noTools': '本工作階段尚無工具呼叫',
185 'pane.toolStats': '×{count} 共 {total} 平均 {avg}',
186 'pane.failed': '失敗 {n}',
187 'pane.agents': '子代理程式',
188 'pane.none': '無',
189 'pane.todos': '待辦事項',
190 'pane.spend': '花費',
191 'pane.turns': '最近幾輪',
192 'pane.turnRow': '#{n} · {time} · {cost} · 上下文 {tokens}',
193 'spend.today': '今日 {spent}',
194 'spend.week': '7 天 {spent}',
195 'summary.language': 'Traditional Chinese as used in Taiwan',
196 },
197 ja: {
198 'rc.label': '⇄ リモートコントロール',
199 'rc.attached': '接続中: {who}',
200 'rc.connected': '接続中',
201 'surface.mobile': 'スマホ',
202 'surface.desktop': 'Web/デスクトップ',
203 forecast: '{label}は今のペースだと ≈{time} に使い切ります',
204 'limit.fiveHour': '5時間枠',
205 'limit.sevenDay': '7日間枠',
206 'limit.scoped': '{name} 週間枠',
207 week: '7日間',
208 streak: '{n}日連続',
209 'git.dirty': '未コミットの変更 {n} 件',
210 'git.ahead': '未プッシュのコミット {n} 件',
211 'compact.left': '自動圧縮まで {tokens}',
212 'cache.cold': 'キャッシュ切れ:次の送信で {tokens} を再キャッシュ',
213 'turn.growth': '直前のターン +{tokens}',
214 'alert.context': 'コンテキストの使用率が {p}% に達しました。/compact の実行を検討してください',
215 'alert.fiveHour': '5時間枠の使用率が {p}% に達しました',
216 'alert.sevenDay': '7日間枠の使用率が {p}% に達しました',
217 'alert.scoped': '{name} 週間枠の使用率が {p}% に達しました',
218 'turn.done': '✓ 応答完了({d})',
219 'cmd.description': 'claude-hud の表示を切り替えます。detail で詳細パネル、theme でテーマを切り替えます',
220 'theme.set': 'HUD テーマ:{name}',
221 'theme.list': 'HUD テーマ(現在 {name}):{list}。/hud theme <名前>、next、reset。* は Nerd Font が必要',
222 'theme.unknown': '{name} というテーマはありません。テーマ:{list}',
223 'theme.ask': 'どの HUD テーマにしますか?(現在 {name}。Other に入力も可:{list})',
224 'cmd.hidden': 'claude-hud を非表示にしました',
225 'cmd.shown': 'claude-hud を表示しました',
226 'pane.title': 'HUD 詳細',
227 'pane.opened': 'HUD 詳細パネルを開きました(/hud detail で閉じます)',
228 'pane.closed': 'HUD 詳細パネルを閉じました',
229 'pane.tools': 'ツール別の所要時間',
230 'pane.noTools': 'このセッションではまだツールが呼び出されていません',
231 'pane.toolStats': '×{count} 合計 {total} 平均 {avg}',
232 'pane.failed': '失敗 {n}',
233 'pane.agents': 'サブエージェント',
234 'pane.none': 'なし',
235 'pane.todos': 'ToDo',
236 'pane.spend': 'コスト',
237 'pane.turns': '最近のターン',
238 'pane.turnRow': '#{n} · {time} · {cost} · コンテキスト {tokens}',
239 'spend.today': '今日 {spent}',
240 'spend.week': '7日間 {spent}',
241 'summary.language': 'Japanese',
242 },
243 ko: {
244 'rc.label': '⇄ 원격 제어',
245 'rc.attached': '연결됨: {who}',
246 'rc.connected': '연결됨',
247 'surface.mobile': '휴대폰',
248 'surface.desktop': '웹/데스크톱',
249 forecast: '{label}: 현재 속도면 ≈{time}에 소진',
250 'limit.fiveHour': '5시간 한도',
251 'limit.sevenDay': '7일 한도',
252 'limit.scoped': '{name} 주간 한도',
253 week: '7일',
254 streak: '{n}일 연속',
255 'git.dirty': '커밋하지 않은 변경 {n}개',
256 'git.ahead': '푸시하지 않은 커밋 {n}개',
257 'compact.left': '자동 압축까지 {tokens}',
258 'cache.cold': '캐시 만료: 다음 메시지가 {tokens} 재캐시',
259 'turn.growth': '직전 턴 +{tokens}',
260 'alert.context': '컨텍스트 사용량이 {p}%에 도달했습니다. /compact 실행을 고려해 보세요',
261 'alert.fiveHour': '5시간 한도의 {p}%를 사용했습니다',
262 'alert.sevenDay': '7일 한도의 {p}%를 사용했습니다',
263 'alert.scoped': '{name} 주간 한도의 {p}%를 사용했습니다',
264 'turn.done': '✓ 응답 완료 ({d})',
265 'cmd.description': 'claude-hud 표시/숨기기. detail을 붙이면 상세 패널을 열고, theme으로 테마를 바꿉니다',
266 'theme.set': 'HUD 테마: {name}',
267 'theme.list': 'HUD 테마 (현재 {name}): {list}. /hud theme <이름>, next, reset. *는 Nerd Font 필요',
268 'theme.unknown': '{name} 테마가 없습니다. 테마: {list}',
269 'theme.ask': '어떤 HUD 테마로 바꿀까요? (현재 {name}, Other에 입력 가능: {list})',
270 'cmd.hidden': 'claude-hud를 숨겼습니다',
271 'cmd.shown': 'claude-hud를 표시했습니다',
272 'pane.title': 'HUD 상세',
273 'pane.opened': 'HUD 상세 패널을 열었습니다(/hud detail로 닫기)',
274 'pane.closed': 'HUD 상세 패널을 닫았습니다',
275 'pane.tools': '도구별 소요 시간',
276 'pane.noTools': '이 세션에서는 아직 도구 호출이 없습니다',
277 'pane.toolStats': '×{count} · 합계 {total} · 평균 {avg}',
278 'pane.failed': '실패 {n}',
279 'pane.agents': '하위 에이전트',
280 'pane.none': '없음',
281 'pane.todos': '할 일',
282 'pane.spend': '비용',
283 'pane.turns': '최근 턴',
284 'pane.turnRow': '#{n} · {time} · {cost} · 컨텍스트 {tokens}',
285 'spend.today': '오늘 {spent}',
286 'spend.week': '7일 {spent}',
287 'summary.language': 'Korean',
288 },
289 es: {
290 'rc.label': '⇄ Control remoto',
291 'rc.attached': 'conectado: {who}',
292 'rc.connected': 'conectado',
293 'surface.mobile': 'teléfono',
294 'surface.desktop': 'web/escritorio',
295 forecast: '{label}: a este ritmo se agota hacia las {time}',
296 'limit.fiveHour': 'Límite de 5 h',
297 'limit.sevenDay': 'Límite de 7 días',
298 'limit.scoped': 'Límite semanal de {name}',
299 week: '7 días',
300 streak: { one: '{n} día seguido', other: '{n} días seguidos' },
301 'git.dirty': { one: '{n} cambio sin confirmar', other: '{n} cambios sin confirmar' },
302 'git.ahead': { one: '{n} commit sin enviar', other: '{n} commits sin enviar' },
303 'compact.left': '{tokens} hasta la compactación',
304 'cache.cold': 'caché fría: el próximo mensaje recachea {tokens}',
305 'turn.growth': 'último turno +{tokens}',
306 'alert.context': `Contexto al {p}${NB}%: conviene ejecutar /compact`,
307 'alert.fiveHour': `Límite de 5 horas al {p}${NB}%`,
308 'alert.sevenDay': `Límite de 7 días al {p}${NB}%`,
309 'alert.scoped': `Límite semanal de {name} al {p}${NB}%`,
310 'turn.done': '✓ Respuesta lista en {d}',
311 'cmd.description': 'Muestra u oculta la barra de claude-hud; detail abre el panel de detalles; theme cambia el tema',
312 'theme.set': 'Tema del HUD: {name}',
313 'theme.list': 'Temas del HUD (ahora {name}): {list}. /hud theme <nombre>, next o reset; * requiere una Nerd Font',
314 'theme.unknown': 'No hay ningún tema llamado {name}. Temas: {list}',
315 'theme.ask': '¿Qué tema de HUD? (ahora {name}; en Other puedes escribir: {list})',
316 'cmd.hidden': 'Barra de claude-hud oculta',
317 'cmd.shown': 'Barra de claude-hud visible',
318 'pane.title': 'Detalles del HUD',
319 'pane.opened': 'Panel de detalles del HUD abierto (/hud detail lo cierra)',
320 'pane.closed': 'Panel de detalles del HUD cerrado',
321 'pane.tools': 'Tiempo por herramienta',
322 'pane.noTools': 'Todavía no hay llamadas a herramientas en esta sesión',
323 'pane.toolStats': '×{count} · total {total} · promedio {avg}',
324 'pane.failed': { one: '{n} error', other: '{n} errores' },
325 'pane.agents': 'Subagentes',
326 'pane.none': 'Ninguno',
327 'pane.todos': 'Tareas',
328 'pane.spend': 'Gasto',
329 'pane.turns': 'Últimos turnos',
330 'pane.turnRow': '#{n} · {time} · {cost} · contexto {tokens}',
331 'spend.today': 'Hoy: {spent}',
332 'spend.week': '7 días: {spent}',
333 'summary.language': 'Spanish',
334 },
335 fr: {
336 'rc.label': '⇄ Contrôle à distance',
337 'rc.attached': `connecté${NB}: {who}`,
338 'rc.connected': 'connecté',
339 'surface.mobile': 'téléphone',
340 'surface.desktop': 'web/bureau',
341 forecast: `{label}${NB}: épuisée vers {time} à ce rythme`,
342 'limit.fiveHour': 'Limite de 5 h',
343 'limit.sevenDay': 'Limite de 7 jours',
344 'limit.scoped': 'Limite hebdomadaire {name}',
345 week: '7 jours',
346 streak: { one: '{n} jour d’affilée', other: '{n} jours d’affilée' },
347 'git.dirty': { one: '{n} modification non commitée', other: '{n} modifications non commitées' },
348 'git.ahead': { one: '{n} commit non poussé', other: '{n} commits non poussés' },
349 'compact.left': '{tokens} avant la compaction',
350 'cache.cold': 'cache froid : le prochain message recache {tokens}',
351 'turn.growth': 'dernier tour +{tokens}',
352 'alert.context': `Contexte rempli à {p}${NB}%${NB}: pensez à /compact`,
353 'alert.fiveHour': `Limite de 5 heures utilisée à {p}${NB}%`,
354 'alert.sevenDay': `Limite de 7 jours utilisée à {p}${NB}%`,
355 'alert.scoped': `Limite hebdomadaire {name} utilisée à {p}${NB}%`,
356 'turn.done': '✓ Réponse prête en {d}',
357 'cmd.description': `Affiche ou masque la barre claude-hud${NB}; detail ouvre le panneau de détails${NB}; theme change le thème`,
358 'theme.set': `Thème du HUD${NB}: {name}`,
359 'theme.list': `Thèmes du HUD (actuel${NB}: {name})${NB}: {list}. /hud theme <nom>, next ou reset${NB}; * demande une Nerd Font`,
360 'theme.unknown': `Aucun thème nommé {name}. Thèmes${NB}: {list}`,
361 'theme.ask': `Quel thème pour le HUD${NB}? (actuel${NB}: {name}${NB}; dans Other, tapez${NB}: {list})`,
362 'cmd.hidden': 'Barre claude-hud masquée',
363 'cmd.shown': 'Barre claude-hud affichée',
364 'pane.title': 'Détails du HUD',
365 'pane.opened': 'Panneau de détails du HUD ouvert (/hud detail pour le fermer)',
366 'pane.closed': 'Panneau de détails du HUD fermé',
367 'pane.tools': 'Temps par outil',
368 'pane.noTools': 'Aucun appel d’outil dans cette session pour l’instant',
369 'pane.toolStats': '×{count} · total {total} · moyenne {avg}',
370 'pane.failed': { one: '{n} échec', other: '{n} échecs' },
371 'pane.agents': 'Sous-agents',
372 'pane.none': 'Aucun',
373 'pane.todos': 'Tâches',
374 'pane.spend': 'Dépenses',
375 'pane.turns': 'Derniers tours',
376 'pane.turnRow': '#{n} · {time} · {cost} · contexte {tokens}',
377 'spend.today': `Aujourd’hui${NB}: {spent}`,
378 'spend.week': `7 jours${NB}: {spent}`,
379 'summary.language': 'French',
380 },
381 de: {
382 'rc.label': '⇄ Fernsteuerung',
383 'rc.attached': 'verbunden: {who}',
384 'rc.connected': 'verbunden',
385 'surface.mobile': 'Smartphone',
386 'surface.desktop': 'Web/Desktop',
387 forecast: '{label}: bei diesem Tempo ≈{time} aufgebraucht',
388 'limit.fiveHour': '5-Stunden-Limit',
389 'limit.sevenDay': '7-Tage-Limit',
390 'limit.scoped': '{name}-Wochenlimit',
391 week: '7 Tage',
392 streak: { one: '{n} Tag in Folge', other: '{n} Tage in Folge' },
393 'git.dirty': { one: '{n} nicht committete Änderung', other: '{n} nicht committete Änderungen' },
394 'git.ahead': { one: '{n} nicht gepushter Commit', other: '{n} nicht gepushte Commits' },
395 'compact.left': '{tokens} bis zur Komprimierung',
396 'cache.cold': 'Cache kalt: nächste Nachricht cacht {tokens} neu',
397 'turn.growth': 'letzte Runde +{tokens}',
398 'alert.context': `Kontext zu {p}${NB}% belegt – /compact empfohlen`,
399 'alert.fiveHour': `5-Stunden-Limit zu {p}${NB}% ausgeschöpft`,
400 'alert.sevenDay': `7-Tage-Limit zu {p}${NB}% ausgeschöpft`,
401 'alert.scoped': `{name}-Wochenlimit zu {p}${NB}% ausgeschöpft`,
402 'turn.done': '✓ Antwort fertig nach {d}',
403 'cmd.description': 'claude-hud-Leiste ein- oder ausblenden; detail öffnet den Detailbereich; theme wechselt das Design',
404 'theme.set': 'HUD-Design: {name}',
405 'theme.list': 'HUD-Designs (aktiv: {name}): {list}. /hud theme <Name>, next oder reset; * braucht eine Nerd Font',
406 'theme.unknown': 'Kein Design namens {name}. Designs: {list}',
407 'theme.ask': 'Welches HUD-Design? (aktuell {name}; unter Other eintippen: {list})',
408 'cmd.hidden': 'claude-hud-Leiste ausgeblendet',
409 'cmd.shown': 'claude-hud-Leiste eingeblendet',
410 'pane.title': 'HUD-Details',
411 'pane.opened': 'HUD-Detailbereich geöffnet (/hud detail schließt ihn)',
412 'pane.closed': 'HUD-Detailbereich geschlossen',
413 'pane.tools': 'Zeit pro Tool',
414 'pane.noTools': 'Noch keine Tool-Aufrufe in dieser Sitzung',
415 'pane.toolStats': '×{count} · gesamt {total} · Ø {avg}',
416 'pane.failed': '{n} fehlgeschlagen',
417 'pane.agents': 'Subagenten',
418 'pane.none': 'Keine',
419 'pane.todos': 'Aufgaben',
420 'pane.spend': 'Ausgaben',
421 'pane.turns': 'Letzte Runden',
422 'pane.turnRow': '#{n} · {time} · {cost} · Kontext {tokens}',
423 'spend.today': 'Heute: {spent}',
424 'spend.week': '7 Tage: {spent}',
425 'summary.language': 'German',
426 },
427 'pt-BR': {
428 'rc.label': '⇄ Controle remoto',
429 'rc.attached': 'conectado: {who}',
430 'rc.connected': 'conectado',
431 'surface.mobile': 'celular',
432 'surface.desktop': 'web/desktop',
433 forecast: '{label}: no ritmo atual, esgota por volta das {time}',
434 'limit.fiveHour': 'Limite de 5 h',
435 'limit.sevenDay': 'Limite de 7 dias',
436 'limit.scoped': 'Limite semanal do {name}',
437 week: '7 dias',
438 streak: { one: '{n} dia seguido', other: '{n} dias seguidos' },
439 'git.dirty': { one: '{n} alteração sem commit', other: '{n} alterações sem commit' },
440 'git.ahead': { one: '{n} commit sem push', other: '{n} commits sem push' },
441 'compact.left': '{tokens} até a compactação',
442 'cache.cold': 'cache frio: a próxima mensagem recacheia {tokens}',
443 'turn.growth': 'último turno +{tokens}',
444 'alert.context': 'Contexto em {p}%: considere usar /compact',
445 'alert.fiveHour': 'Limite de 5 horas em {p}%',
446 'alert.sevenDay': 'Limite de 7 dias em {p}%',
447 'alert.scoped': 'Limite semanal do {name} em {p}%',
448 'turn.done': '✓ Resposta pronta em {d}',
449 'cmd.description': 'Mostra ou oculta a barra do claude-hud; detail abre o painel de detalhes; theme troca o tema',
450 'theme.set': 'Tema do HUD: {name}',
451 'theme.list': 'Temas do HUD (atual: {name}): {list}. /hud theme <nome>, next ou reset; * precisa de uma Nerd Font',
452 'theme.unknown': 'Nenhum tema chamado {name}. Temas: {list}',
453 'theme.ask': 'Qual tema do HUD? (agora {name}; em Other digite: {list})',
454 'cmd.hidden': 'Barra do claude-hud oculta',
455 'cmd.shown': 'Barra do claude-hud visível',
456 'pane.title': 'Detalhes do HUD',
457 'pane.opened': 'Painel de detalhes do HUD aberto (/hud detail fecha)',
458 'pane.closed': 'Painel de detalhes do HUD fechado',
459 'pane.tools': 'Tempo por ferramenta',
460 'pane.noTools': 'Nenhuma chamada de ferramenta nesta sessão ainda',
461 'pane.toolStats': '×{count} · total {total} · média {avg}',
462 'pane.failed': { one: '{n} falha', other: '{n} falhas' },
463 'pane.agents': 'Subagentes',
464 'pane.none': 'Nenhum',
465 'pane.todos': 'Tarefas',
466 'pane.spend': 'Gastos',
467 'pane.turns': 'Últimos turnos',
468 'pane.turnRow': '#{n} · {time} · {cost} · contexto {tokens}',
469 'spend.today': 'Hoje: {spent}',
470 'spend.week': '7 dias: {spent}',
471 'summary.language': 'Brazilian Portuguese',
472 },
473 ru: {
474 'rc.label': '⇄ Удалённое управление',
475 'rc.attached': 'подключено: {who}',
476 'rc.connected': 'подключено',
477 'surface.mobile': 'телефон',
478 'surface.desktop': 'веб/компьютер',
479 forecast: '{label}: при текущем темпе будет исчерпан около {time}',
480 'limit.fiveHour': 'Лимит на 5 ч',
481 'limit.sevenDay': 'Лимит на 7 дн.',
482 'limit.scoped': 'Недельный лимит {name}',
483 week: '7 дн.',
484 streak: { one: '{n} день подряд', few: '{n} дня подряд', many: '{n} дней подряд', other: '{n} дня подряд' },
485 'git.dirty': {
486 one: '{n} незакоммиченное изменение',
487 few: '{n} незакоммиченных изменения',
488 many: '{n} незакоммиченных изменений',
489 other: '{n} незакоммиченного изменения',
490 },
491 'git.ahead': {
492 one: '{n} неотправленный коммит',
493 few: '{n} неотправленных коммита',
494 many: '{n} неотправленных коммитов',
495 other: '{n} неотправленного коммита',
496 },
497 'compact.left': '{tokens} до автосжатия',
498 'cache.cold': 'кэш остыл: следующее сообщение перекэширует {tokens}',
499 'turn.growth': 'прошлый ход +{tokens}',
500 'alert.context': `Контекст заполнен на {p}${NB}% — стоит выполнить /compact`,
501 'alert.fiveHour': `Лимит на 5 часов израсходован на {p}${NB}%`,
502 'alert.sevenDay': `Лимит на 7 дней израсходован на {p}${NB}%`,
503 'alert.scoped': `Недельный лимит {name} израсходован на {p}${NB}%`,
504 'turn.done': '✓ Ответ готов за {d}',
505 'cmd.description': 'Показать или скрыть панель claude-hud; detail открывает панель подробностей; theme меняет тему',
506 'theme.set': 'Тема HUD: {name}',
507 'theme.list': 'Темы HUD (сейчас {name}): {list}. /hud theme <имя>, next или reset; * нужен Nerd Font',
508 'theme.unknown': 'Темы {name} нет. Темы: {list}',
509 'theme.ask': 'Какую тему HUD выбрать? (сейчас {name}; в Other можно ввести: {list})',
510 'cmd.hidden': 'Панель claude-hud скрыта',
511 'cmd.shown': 'Панель claude-hud показана',
512 'pane.title': 'Подробности HUD',
513 'pane.opened': 'Панель подробностей HUD открыта (/hud detail закрывает её)',
514 'pane.closed': 'Панель подробностей HUD закрыта',
515 'pane.tools': 'Время по инструментам',
516 'pane.noTools': 'В этой сессии ещё не было вызовов инструментов',
517 'pane.toolStats': '×{count} · всего {total} · в среднем {avg}',
518 'pane.failed': 'ошибок: {n}',
519 'pane.agents': 'Субагенты',
520 'pane.none': 'Нет',
521 'pane.todos': 'Задачи',
522 'pane.spend': 'Расходы',
523 'pane.turns': 'Последние ходы',
524 'pane.turnRow': '#{n} · {time} · {cost} · контекст {tokens}',
525 'spend.today': 'Сегодня: {spent}',
526 'spend.week': '7 дн.: {spent}',
527 'summary.language': 'Russian',
528 },
529}
530
531// The BCP 47 tag `Intl` formats and pluralizes with, per language.
532const INTL_TAG: Record<CanonicalLanguage, string> = {
533 en: 'en-US',
534 'zh-Hans': 'zh-CN',
535 'zh-Hant': 'zh-TW',
536 ja: 'ja-JP',
537 ko: 'ko-KR',
538 es: 'es',
539 fr: 'fr-FR',
540 de: 'de-DE',
541 'pt-BR': 'pt-BR',
542 ru: 'ru-RU',
543}
544
545/** The language the HUD draws in: claude-hud's, as its last config load set it. */
546export function language(): CanonicalLanguage {
547 return getCanonicalLanguage()
548}
549
550/** Whether the language sets text in CJK characters (two columns each). */
551export function isCjk(lang: CanonicalLanguage = language()): boolean {
552 return lang === 'zh-Hans' || lang === 'zh-Hant' || lang === 'ja' || lang === 'ko'
553}
554
555function pluralCategory(lang: CanonicalLanguage, n: number): string {
556 try {
557 return new Intl.PluralRules(INTL_TAG[lang]).select(n)
558 } catch {
559 return n === 1 ? 'one' : 'other'
560 }
561}
562
563/** The message `key`, its `{placeholders}` filled; `params.n` picks a plural form. */
564export function m(key: Key, params: Record<string, string | number> = {}, lang: CanonicalLanguage = language()): string {
565 const text = MESSAGES[lang][key] ?? MESSAGES.en[key]
566 const pattern =
567 typeof text === 'string'
568 ? text
569 : ((text as Record<string, string | undefined>)[typeof params.n === 'number' ? pluralCategory(lang, params.n) : 'other'] ??
570 text.other)
571 return interpolate(pattern, params)
572}
573
574/** "$3.20", "3,20 $", "US$ 3,20": a USD amount as the language writes it. */
575export function money(usd: number, lang: CanonicalLanguage = language()): string {
576 try {
577 return new Intl.NumberFormat(INTL_TAG[lang], {
578 style: 'currency',
579 currency: 'USD',
580 currencyDisplay: 'narrowSymbol',
581 }).format(usd)
582 } catch {
583 return `$${usd.toFixed(2)}`
584 }
585}
586
587/** The one-line task summary's prompt: the language by name and a length to keep to. */
588export function summaryPrompt(lang: CanonicalLanguage = language()): string {
589 const budget = isCjk(lang) ? 'at most 25 characters' : 'at most 8 words'
590 return (
591 `In ${m('summary.language', {}, lang)}, state in one line (${budget}) the task this session is working on right now. ` +
592 'Output only that line: no quotes, no prefix, no closing punctuation.'
593 )
594}
595hooks/kit/band.tsx 31 lines1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16 const before = text.slice(0, cursor)
17 return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21 const { Box } = ui
22 return (
23 <Box flexDirection="column">
24 <Box flexDirection="column" paddingLeft={2}>
25 {mine}
26 </Box>
27 {below}
28 </Box>
29 )
30}
31