SLOPSHOPPER

hud

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…

newpanebandspinnerguardcommand
★ 5v0.11.2MITupdated 2026-10-09hoobnn/hoobnn-agent-mods/claude-code/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hud ⎿ hud: claude-hud band hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts HUD ⟨Claude Code's own drawing⟩
README

hud:Claude Code 状态栏 HUD

简体中文 · English

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

hud:neon 主题

功能

  • claude-hud 原有的信息都在:模型和推理强度、项目和 git 分支及改动、上下文和用量进度条、正在跑的工具、子代理和待办。
  • 提前预警:上下文或额度到你设定的百分比时弹提示;按当前速度估算额度何时用完(5 小时额度按最近一小时的速度算);Fable 这类按模型单独计的周额度也显示出来,同样预警和预测;距自动压缩还剩多少 token;提示缓存过期后,提醒下一条消息要重新写入多少 token;某一轮让上下文涨得特别多时,显示涨了多少和最近几轮的迷你柱状图(如 上一轮 +98k ▂▁█),一眼看出是哪一轮把上下文撑大的;未提交的改动或未推送的提交太多时提醒。
  • 花费:今天的花费对比每日预算,近 7 天花费的迷你折线图。
  • 一行任务摘要,以及 /hud detail 详情面板:每个工具的调用次数和耗时、最近几轮的用时、花费和上下文增长、子代理、待办、今天和本周的花费。
  • 12 套主题:科技霓虹、彩虹渐变、emoji、带颜文字看板娘的动漫风(樱花、猫咪、机甲、热血)、Tokyo Night、黑客帝国、Nerd Font 和 powerline。
  • 长任务结束提醒(macOS 可加提示音),以及远程控制的连接状态。

安装

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 选项,以后的会话也会沿用。

主题风格
classicclaude-hud 原样
neon赛博朋克:霓虹真彩色,⬢ ◆ ◈ ⚡,▰▱ 进度条, ❯ 分隔
rainbow每个元素一种颜色,进度条和模型名是彩虹渐变
emoji🤖 📂 🌿 🧠 ⚡ 📅 ⏳ ✅
sakura樱花粉,🌸 🎀 🍡 💗,✿ 进度条,颜文字看板娘 (◕‿◕)♡
kawaii马卡龙色,「Opus」,●○ 进度条,猫咪看板娘 ฅ^•ω•^ฅ
mecha紫、绿、橙,UNIT·Opus◤,SYNC / PWR 仪表,机器人看板娘 [•_•]
shonen红橙金,🔥 ⭐ 🍥 💥,渐变进度条,热血看板娘 (ง •̀_•́)ง
tokyo-nightTokyo Night 配色,图标克制
matrix黑底绿字,▮▯ 进度条, ┊ 分隔
nerdNerd Font 图标(需要 Nerd Font)
powerlineNerd 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 开关按钮开
positionabove 放在输入框上方,below 放在下方原状态栏的位置above
theme主题classic
showMascot动漫风主题显示颜文字看板娘开
contextAlerts上下文到这些百分比时提醒,逗号分隔,如 80,90;留空关闭空
usageAlerts5 小时或 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 自己已经会列出,默认关)关
extraCmdclaude-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 回复和任务摘要也一起跟随。

同系列 mod

hoobnn-agent-mods 里还有任务进度条(todo-bar)、回合回执(receipt)、运行动画和宠物(spinner)、Tailscale 节点状态(ts-band)和一言(hitokoto),可以搭配使用。

开发

和 claude-hud 的差异、mod 自行推算的字段、目录结构和从上游同步的步骤,见 英文 README。

Source 85 files
hooks/register.tsx 665 lines
1// 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}
665
hooks/shims/globals.ts 54 lines
1// 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}
54
hooks/config.ts 66 lines
1// 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}
66
hooks/draw.tsx 135 lines
1// 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}
135
hooks/extras.ts 359 lines
1// 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}
359
hooks/hud/config.ts 546 lines
1import * 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}
546
hooks/hud/i18n/index.ts 77 lines
1import 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}
77
hooks/hud/transcript.ts 386 lines
1import * 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}
386
hooks/hud/types.ts 203 lines
1import 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}
203
hooks/hud/usage-pace.ts 98 lines
1import 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}
98
hooks/i18n.ts 595 lines
1// 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}
595
hooks/kit/band.tsx 31 lines
1// 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