HUD completo (modelo, projeto, git, contexto, uso, ferramentas, agentes, todos, alertas, orçamento, histórico, resumo, painel de detalhes e temas) mais a linha…

Português · English
A Claude Code mod that gathers, in a bar above (or below) the prompt, what matters at a glance: model, project, git, context, usage, tools, subagents and todos. On top of that it adds alerts, a usage forecast, a daily budget, a one-line task summary, a detail pane and twelve themes you switch live.
It is based on the hud mod (hoobnn), which rebuilds claude-hud 0.10.0 (Jarrod Watts) as a Claude Code mod. What this plugin adds is the synapse line.

Do not use it together with the original
hudplugin: both bars would show at once. Disablehud.
The last line of the bar shows the context's weather and the size in MB of what is sent to the model, and how the context has been evolving:
☂ Rain 12 MB 134k / 1m ▁▂▂▃▃▄▅ (en)
☂ Chuva 12 MB 134k / 1m ▁▂▂▃▃▄▅ (pt-BR)
| Part | Meaning |
|---|---|
☂ Rain | Weather (the "forecast"): ☀ Clear below 25%, ☁ Cloudy from 25%, ☂ Rain from 50%, ↯ Storm from 75% and ! Compact soon from 90% |
12 MB | Size in MB of the context and attachments (the messages sent to the API), colored by the same weather. The reference is the 32 MB limit |
134k / 1m | Tokens used / context window |
▁▂▃… | Chart of the last 12 turns |
turnGrowthTokens).enabled option turns only this line on and off. It follows the HUD's language (see Language): Portuguese for pt-BR and English otherwise./synapse detail for per-tool times, the last turns' cost and context growth, subagents and todos./plugin marketplace add fabioivan/claude-code-mods
/plugin install synapse-rate-limit@fabioivan-mods
/reload-plugins
Or from the command line:
claude plugin marketplace add fabioivan/claude-code-mods
claude plugin install synapse-rate-limit@fabioivan-mods
It reads claude-hud's own config files, so an existing claude-hud setup carries over. /synapse toggles the bar and /synapse theme picks a theme.
| Command | Effect |
|---|---|
/synapse | Shows or hides the bar |
/synapse on / /synapse off | Shows / hides it explicitly |
/synapse detail | Opens or closes the detail pane: each tool's calls, total and average time and failures; the last 8 turns with their time, cost and context growth; subagents; todos; today's and the week's spend |
/synapse theme | Asks which theme |
/synapse theme <name> / next / reset | Switches the theme, cycles to the next, or goes back to classic |
The command was /hud in the original plugin. /synapse runs mid-turn too, and the HUD button in the prompt footer does what /synapse alone does.
claude-hud's files. ~/.claude/plugins/claude-hud/config.json and ~/.claude/claude-hud.json.
Mod options (/config, or pluginConfigs.synapse-rate-limit.options in settings):
| Option | Default | Description |
|---|---|---|
language | auto | The HUD's language: auto (Claude Code's), en or pt-BR (see Language) |
enabled | true | The synapse line (weather, MB, tokens, chart) |
visible | true | Show the HUD. /synapse, /synapse on, /synapse off and the footer button change the option, which is kept across sessions |
footerButton | true | The HUD button in the prompt footer |
position | above | above (a band above the prompt) or below (beside the hint line, where the statusline sat); the desktop app always draws above |
theme | classic | Theme (see below) |
showMascot | true | The anime themes' mascot |
extraCmd | empty | claude-hud's --extra-cmd: a shell command whose output becomes a label (needs CLAUDE_HUD_ALLOW_EXTRA_CMD=1) |
debug | false | Registers the mcp__synapse-rate-limit__synapse_debug tool |
notifyAfterSeconds | 0 | Toast when a turn runs at least this long; 0 turns it off |
notifySound | true | A chime with the turn-done toast (macOS) |
contextAlerts | empty | Context percentages that raise a toast, e.g. 80,90 |
usageAlerts | empty | Percentages of the 5-hour, 7-day or model-scoped weekly limits that raise a toast |
showForecast | true | Forecast of when a usage limit runs out |
dailyBudgetUsd | 0 | Daily budget in USD; 0 turns it off |
showHistory | false | The last 7 days' spend as a sparkline, with the streak of days in use |
summaryEveryTurns | 5 | Summarize the task every N turns; 0 turns it off |
compactWarnPercent | 60 | Show the tokens left before auto-compaction from this percent; 0 turns it off |
coldCacheTokens | 20000 | Expired-cache warning from this context size; 0 turns it off |
turnGrowthTokens | 20000 | Show the context growth when a turn grows it by at least this much; 0 turns it off |
gitDirtyWarn | 20 | Warn on this many changed, uncommitted paths; 0 turns it off |
gitAheadWarn | 5 | Warn on this many unpushed commits; 0 turns it off |
showAgents | false | Subagent lines (Claude Code already lists running subagents, with their time and tokens; the detail pane still lists them) |
One language applies to the whole HUD: claude-hud's lines, what the mod adds (alerts, the extras row, the detail pane, /synapse messages, the task summary) and the synapse line. The language option picks it:
| Value | Effect |
|---|---|
auto (default) | Follows Claude Code's own language (language in settings.json): Portugues, português, pt-BR or Brazilian Portuguese give Portuguese; English or en give English. Any other language, or none, falls back to claude-hud's language |
en | English |
pt-BR | Brazilian Portuguese |
settings.json is read again on each refresh of the bar, so changing Claude Code's language changes the HUD without a restart.auto, claude-hud's language (en, zh-Hans, zh-Hant, ja, ko, es, fr, de, pt-BR, ru) still applies to the HUD. The synapse line only has Portuguese and English text, and uses English for the other languages./config) and the /synapse command's description stay fixed: a plugin's manifest is not translated.theme (in /config, default classic: claude-hud's own look) or /synapse theme <name> live. /synapse theme alone asks which in a dialog (the next four offered, any other typed under Other; dismissed, or under -p, it lists them with a sample), /synapse theme next cycles, /synapse theme reset goes back to classic. The command writes the theme option, so /config shows it and it is kept across sessions.
| Theme | Look |
|---|---|
classic | claude-hud as it ships |
neon | cyberpunk: neon truecolor, ⬢ ◆ ◈ ⚡, ▰▱ bars, ❯ separators |
rainbow | a hue per element, filled bar cells and the model name along a rainbow gradient |
emoji | 🤖 📂 🌿 🧠 ⚡ 📅 ⏳ ✅ |
sakura | pastel pink, 🌸 🎀 🍡 💗, ✿ bars, a kaomoji mascot (◕‿◕)♡ |
kawaii | pastel, 「Opus」, ●○ bars, a cat mascot ฅ^•ω•^ฅ |
mecha | purple, green and orange, UNIT·Opus◤, SYNC / PWR gauges, a robot mascot [•_•] |
shonen | red-orange-gold, 🔥 ⭐ 🍥 💥, gradient bars, a mascot (ง •̀_•́)ง |
tokyo-night | the Tokyo Night palette, quiet glyphs |
matrix | green on black, ▮▯ bars, ┊ separators |
nerd | Nerd Font symbols (needs a Nerd Font) |
powerline | Nerd Font symbols on powerline segments (needs a Nerd Font) |
Every theme on the same sample session: assets/themes/gallery.png; one still per theme in assets/themes/<theme>.png.
colors; a color set in claude-hud's own config (off its default) stays.showMascot, on): the anime themes put a face first in the extras row: calm, busy while a tool runs, worried from 70% context (or 90% quota), panicking from 85%, knocked out when a limit is reached. │ ; powerline adds 2 cells to a row. Emoji are default-presentation ones only (no U+FE0F).[Model | Provider] badge (Bedrock, Vertex) whole by its leading [; themes that drop the brackets lose that, so at a narrow width such a badge can wrap at | .Claude Code's statusline stdin carries these; the mod API does not, so the mod works them out:
prompt_cache: the clock restarts at the last main-thread request (from turn.step, else the last main-thread response in the transcript) and runs for the TTL the last cache write used (1h when it wrote the 1-hour tier, else 5m). hit_ratio is cache-read input over all main-thread input, across the session.model_scoped (the model-scoped weekly limits, such as Fable's): they come from Claude Code's own cache of its usage endpoint, cachedUsageUtilization in .claude.json (read again only when the file changes, nothing once it is over an hour old, as Claude Code's own reader). No request is made.session_name: the transcript's /rename title, else its generated title, else its slug.workspace.repo: parsed from $.session.repo()'s remote URL.output_style: outputStyle from settings.current_usage is the engine's context total, uncached, and the effort is effortLevel from settings; both arrive with the first turn.step and are kept in session state across reloads.total_api_duration_ms counts the requests seen since the mod was enabled in the session. │ ⇄ Remote Control at the end of the first line while the session's Remote Control is on, linked to the session on claude.ai, then the attached clients by surface (connected: phone · web/desktop×2). The Claude app and claude.ai raise no session.attach, so a prompt or command arriving over Remote Control marks connected until the bridge changes. The bridge is in ~/.claude/sessions/<pid>.json and is read every 3 s; the bar is redrawn on a change.⚠ git warning last. Each part shows only when it has something to say:✎ the task in one line: a $.model.fork of the conversation (served from the prompt cache) after the first turn and every summaryEveryTurns turns (default 5; 0 off). Skipped while the transcript holds a task list with work left (the list already says what the model is doing), and an older line steps aside meanwhile.showForecast): when the 5-hour, 7-day or a model-scoped weekly limit runs out, if that comes before it resets: the 5-hour limit at the last hour's pace once the session has ten minutes of readings, the weekly ones at the rate since their window began.42k to auto-compact), once the context is compactWarnPercent of the way there (default 60; 0 off). The threshold is Claude Code's own ($.session.usage({ breakdown: 'summary' })), read again when the context window changes.cache cold: next message re-caches 120k): once a cache the session used has expired, the context the next message writes to it again, when that is at least coldCacheTokens (default 20000; 0 off).last turn +98k ▂▁█): when the last turn grew the context by at least turnGrowthTokens (default 20000; 0 off), by how much, then a sparkline of the last 8 turns' growth (a compaction counts as none), so the turn that filled the window stands out.dailyBudgetUsd (0 off), from claude-hud's daily-cost ledger; yellow from 80%, red past it.showHistory, off by default); the spend is kept in the mod's store for 60 days either way.⚠ uncommitted paths at or past gitDirtyWarn (default 20) and unpushed commits at or past gitAheadWarn (default 5); 0 turns either off.contextAlerts (e.g. 80,90), and the 5-hour, 7-day or a model-scoped weekly limit each of usageAlerts; once per threshold, again only after the gauge drops 5 points below it (a /compact, a reset).notifyAfterSeconds or longer (default 0, off; e.g. 60) ends with a toast and, with notifySound, a short chime (macOS).showAgents)./model (showing the new model before its first step). │ and | separators are dimmed; a running tool's file shows relative to the session directory (◐ Read src/a.ts); the session duration is ⏱ 12m and the prompt cache is shown without the emoji-width ⏱️.file:// links (the project path): a Link takes https only, so the text is kept and the link dropped. https links (a GitHub branch) stay clickable.worktree (a --worktree session's name, path and branch): not in the mod API.hud/synapse command instead of /hud; the debug tool is synapse_debug.synapse-rate-limit name, so they do not clash with hud.enabled option.language option (auto, en, pt-BR), which follows Claude Code's language.plugins/claude-hud-mod, the same directory as the original hud, so the two share the daily-cost ledger.claude plugin validate .
claude plugin test .
claude --plugin-dir /path/to/synapse-rate-limit
hooks/register.tsx: the hooks, and everything that calls $ (the engine follows $ only within this file): the session's start, the turn's events, /synapse, the refresh loop, alerts, spend and summary, and the render hooks. The other modules get closures over $ (Io, SessionApi).hooks/synapse-row.ts: builds the synapse line.hooks/synapse-format.ts: the weather (with its English and Portuguese text), MB and token formatting, the chart.hooks/language.ts: resolves the HUD's language (the mod's option, Claude Code's language, or claude-hud's).hooks/config.ts: the mod's options, read once into a typed Config.hooks/stdin.ts: builds the statusline stdin claude-hud expects from the session (usage, settings, repo, turn steps) and the transcript; the host facts claude-hud reads (env, platform, memory).hooks/render.ts: one pass: claude-hud's lines, the Remote Control label, today's spend; the git counts for the warning.hooks/remote.ts: Remote Control's bridge (from sessions/<pid>.json) and its label.hooks/summary.ts: the task summary's reply, cleaned to one line.hooks/draw.tsx: the rows (above or below the prompt) and the /synapse detail pane, over the elements a render hook resolved.hooks/live.ts: what the module keeps between passes outside $.state, and the theme in use.hooks/kit/: option readers and /config writes.hooks/transcript-feed.ts: reads the transcript once and incrementally (only appended lines) for the whole mod.hooks/hud/: claude-hud's src/ (MIT, see LICENSE.claude-hud), kept close to upstream, with local changes: main(source) takes the stdin from the mod; lines go to a sink instead of console.log; seven more locales (ja, ko, es, fr, de, pt-BR, ru); setConfigPatch lays the mod's options over the loaded config; git runs through $.process.run; and render/theme.ts carries the themes' glyphs.hooks/shims/: the Node APIs claude-hud imports, over $.hooks/ansi.ts, hooks/i18n.ts, hooks/themes.ts, hooks/extras.ts: SGR escapes to styled spans, the mod's own strings in every language, the themes, and the helpers for what the mod adds.Copy the new src/ of claude-hud over hooks/hud/ (minus windows-git-worker.ts), re-point node:* imports at ../shims/*.js (node:fs/promises at fs_promises.js), re-apply the changes listed above (route any new hardcoded glyph through render/theme.ts), then run claude plugin validate . and claude plugin test ..
MIT. The code in hooks/hud/ comes from claude-hud (Jarrod Watts), also under MIT: see LICENSE.claude-hud. The conversion to a mod is by hud (hoobnn).
hooks/register.tsx 731 lines1// claude-hud as a mod. claude-hud's own source (hooks/hud, MIT, see
2// LICENSE.claude-hud) renders the lines; this module wires the engine's
3// events to it and draws its output above or below the prompt. The work sits
4// beside it: stdin.ts builds the statusline's stdin, render.ts runs a pass,
5// remote.ts follows Remote Control, draw.tsx draws. Whatever calls `$` stays
6// in this file (the engine follows `$` only within the hooks module's own
7// file); the other modules get closures over it (`Io`, `SessionApi`).
8import './shims/globals.js'
9
10import { atom, 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 { resolveLanguage } from './language.js'
25import { isPickerOpen } from './kit/band.js'
26import { cellWidth, dockFits, drawPet } from './kit/pet.js'
27import { keptRows, migrateStore, persist, switchArg } from './kit/prefs.js'
28import type { Prefs } from './kit/prefs.js'
29import { fitColumns, live, useTheme } from './live.js'
30import { BRIDGE, remoteControl } from './remote.js'
31import { gitCounts, renderHud } from './render.js'
32import { factsSummary, type Io, runWithFacts, setIo } from './shims/host.js'
33import { loadHostFacts, type SessionApi } from './stdin.js'
34import { cleanSummary } from './summary.js'
35import { MAX_TURNS, bytesPercent } from './synapse-format.js'
36import { contextRow } from './synapse-row.js'
37import type { ContextView } from './synapse-row.js'
38import { applyPalette, applyTheme, findTheme, moodOf, THEMES } from './themes.js'
39import type { Theme } from './themes.js'
40import { transcriptData } from './transcript-feed.js'
41
42const lines = atom({ plugin: 'synapse-rate-limit', key: 'lines' } as const, [] as HudLine[])
43// The session's mirror of the `visible` row, so `/synapse` shows at once.
44const isHidden = atom({ plugin: 'synapse-rate-limit', key: 'isHidden' } as const, false)
45// Session state, so a reload keeps what earlier requests reported.
46const steps = atom({ plugin: 'synapse-rate-limit', 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: 'synapse-rate-limit', key: 'remotes' } as const, [] as Remote[])
55const summary = atom({ plugin: 'synapse-rate-limit', key: 'summary' } as const, null as string | null)
56const turns = atom({ plugin: 'synapse-rate-limit', key: 'turns' } as const, 0)
57const fired = atom({ plugin: 'synapse-rate-limit', key: 'fired' } as const, { context: [], fiveHour: [], sevenDay: [] } as Fired)
58const history = atom({ plugin: 'synapse-rate-limit', key: 'history' } as const, {} as Record<string, number>)
59const tools = atom({ plugin: 'synapse-rate-limit', key: 'tools' } as const, {} as ToolStats)
60const turnLog = atom({ plugin: 'synapse-rate-limit', key: 'turnLog' } as const, [] as TurnCost[])
61const turnStart = atom({ plugin: 'synapse-rate-limit', key: 'turnStart' } as const, null as { usd: number | null; tokens: number | null } | null)
62/** Turns `/synapse 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: 'synapse-rate-limit', key: 'dock' } as const, false)
67const petPats = atom({ plugin: 'synapse-rate-limit', key: 'petPats' } as const, 0)
68// True while a picker is open above the band (see kit/band.tsx).
69const isPicking = atom({ plugin: 'synapse-rate-limit', key: 'isPicking' } as const, false)
70
71const PANE = 'synapse-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: `synapse-rate-limit.${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/** `/synapse [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 = await update($, isHidden, v => switchArg(verb, v))
153 if (hidden !== was) await persist(prefsOf($), 'visible', !hidden)
154 schedule()
155 return hidden
156}
157
158// Before 0.8 a theme picked with `/synapse theme` was kept in the store; it is the `theme` row now.
159const STORE_MOVES = { theme: (kept: unknown) => (findTheme(kept) ? (['theme', findTheme(kept)!.name] as const) : null) }
160
161export const register: Register = (on, options) => {
162 const config = readConfig(options)
163 useTheme(config.theme)
164 setConfigPatch(hud => {
165 // O idioma: a opção do mod, ou o do Claude Code em `auto`, ou o que o claude-hud já tinha.
166 const themed = { ...applyPalette(hud, live.theme), language: resolveLanguage(config.language, live.claudeLanguage, hud.language) }
167 // Claude Code lists running subagents itself, with their time and tokens; claude-hud's
168 // agent lines would repeat them, so they show only when asked for.
169 return config.hasAgents ? themed : { ...themed, display: { ...themed.display, showAgents: false } }
170 })
171 let isSummarizing = false
172 // Set in session.start: everything that outlives one dispatch calls the
173 // engine through these closures.
174 let refresh: () => Promise<void> = async () => {}
175 let after: (ms: number, fn: () => void) => void = () => {}
176 let isRunning = false
177 let isQueued = false
178 let isScheduled = false
179 let isStarted = false
180 // Where auto-compaction runs (null: off), for the window it was read for: it
181 // moves only with the window, so a model switch reads it again.
182 let compactAt: { window: number; at: number | null } | null = null
183 // The 5-hour window's readings this session: its forecast follows the last hour's pace.
184 let fiveHour: Samples | undefined
185 // A linha de contexto: tamanho do contexto/anexos (lido ao fim de cada turno), e os últimos turnos.
186 let sizeBytes = 0
187 let sizePercent = 0
188 let percentLog: number[] = []
189 // Aplica o tamanho medido do próximo request (mensagens + anexos inline). É medido também antes
190 // de cada passo e no envio do prompt: o request que estoura o limite é o que ainda não fechou um turno.
191 const applySize = (messages: unknown) => {
192 if (!config.hasContextLine || !Array.isArray(messages)) return
193 let bytes = 0
194 for (const msg of messages) bytes += JSON.stringify(msg).length
195 if (bytes === sizeBytes) return
196 sizeBytes = bytes
197 sizePercent = bytesPercent(bytes)
198 schedule()
199 }
200
201 const schedule = () => {
202 // A draw can come before session.start: nothing to schedule on yet, and
203 // session.start refreshes anyway.
204 if (isScheduled || !isStarted) return
205 isScheduled = true
206 after(DEBOUNCE_MS, () => {
207 isScheduled = false
208 void refresh()
209 })
210 }
211
212 on('session.start', async ($, e, next) => {
213 const io: Io = {
214 stat: path => $.fs.stat(path, { resolve: true }),
215 read: path => $.fs.read(path),
216 list: path => $.fs.list(path),
217 write: (path, text) => $.fs.write(path, text),
218 run: (argv, init) => $.process.run(argv, init),
219 }
220 const session: SessionApi = {
221 info: async () => {
222 const [id, cwd, root, model, usage, version, settings, step, repo] = await Promise.all([
223 $.session.id(),
224 $.session.cwd(),
225 $.session.root(),
226 $.session.model(),
227 $.session.usage(),
228 $.session.version(),
229 $.settings.read().catch(() => ({})),
230 read($, steps),
231 $.session.repo().catch(() => null),
232 ])
233 live.claudeLanguage = (settings as { language?: unknown }).language
234 return { id, cwd, root, model, usage, version, settings: settings as Record<string, unknown>, step, repo }
235 },
236 // A value from before 0.4.3 (bare ids) is dropped rather than misread.
237 remotes: async () => (await read($, remotes)).filter(r => typeof r === 'object' && r !== null),
238 exists: path => $.fs.exists(path),
239 }
240 setIo(io)
241 after = (ms, fn) => void $.clock.after(ms, fn)
242 isStarted = true
243 refresh = async () => {
244 if (isRunning) {
245 isQueued = true
246 return
247 }
248 isRunning = true
249 const started = Date.now()
250 try {
251 const { rows, stdin, todayUsd } = await renderHud(io, session)
252 const window = stdin.context_window?.context_window_size ?? 0
253 if (config.compactWarnPercent > 0 && window > 0 && compactAt?.window !== window) {
254 const breakdown = (await $.session.usage({ breakdown: 'summary' }).catch(() => null))?.context.breakdown
255 compactAt = { window, at: breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null }
256 }
257 const tokens = stdin.context_window?.total_input_tokens ?? 0
258 const compactLeft =
259 compactAt?.at && tokens >= (compactAt.at * config.compactWarnPercent) / 100 ? compactAt.at - tokens : null
260 const pet = config.position === 'below' ? await read($, petDock) : null
261 const now = await $.clock.now()
262 if (todayUsd !== null) await recordSpend(now, todayUsd)
263 const days = await read($, history)
264 const today = localDay(now)
265 const limits = stdin.rate_limits
266 const five = limits?.five_hour
267 if (five && typeof five.used_percentage === 'number' && five.resets_at) {
268 fiveHour = addSample(fiveHour, five.used_percentage, five.resets_at, now)
269 }
270 const scoped = (stdin.model_scoped ?? []).flatMap(w =>
271 w.display_name && typeof w.utilization === 'number'
272 ? [{ name: w.display_name, percent: w.utilization, resetsAt: w.resets_at ? Math.floor(Date.parse(w.resets_at) / 1000) : null }]
273 : [],
274 )
275 await alertScoped($, scoped, config.usageAlerts)
276 // The 7-day window keeps the pace since it began: an hour of work says little about a week.
277 const exhaust = config.hasForecast
278 ? [
279 { label: m('limit.fiveHour'), at: paceAt(fiveHour, five?.used_percentage, five?.resets_at, FIVE_HOUR_WINDOW_MS, now) },
280 { label: m('limit.sevenDay'), at: exhaustAt(limits?.seven_day?.used_percentage, limits?.seven_day?.resets_at, SEVEN_DAY_WINDOW_MS, now) },
281 ...scoped.map(w => ({ label: m('limit.scoped', { name: w.name }), at: exhaustAt(w.percent, w.resetsAt, SEVEN_DAY_WINDOW_MS, now) })),
282 ].flatMap(({ label, at }) => (at === null ? [] : [{ label, at }]))
283 : []
284 const extra = extrasLine({
285 summary: await read($, summary),
286 exhaust,
287 todayUsd,
288 budgetUsd: config.budgetUsd,
289 week: config.hasHistory ? { values: lastDays(days, today, 7), streak: streak(days, today) } : null,
290 compactLeft,
291 coldCache: coldCacheOf(stdin, config.coldCacheTokens),
292 growth: growthOf(await read($, turnLog), config.turnGrowthTokens),
293 git: config.gitDirtyWarn > 0 || config.gitAheadWarn > 0 ? await gitCounts(io, stdin.cwd ?? '') : null,
294 gitDirtyWarn: config.gitDirtyWarn,
295 gitAheadWarn: config.gitAheadWarn,
296 columns: fitColumns(),
297 style: live.theme.extras,
298 // spinner's pet stands in for the theme's mascot below the prompt.
299 mascot: config.hasMascot && live.theme.mascot && !pet ? live.theme.mascot[mood(stdin)] : null,
300 })
301 const themed = applyTheme(appendExtras(rows, extra, fitColumns()), live.theme)
302 const ctxTokens = stdin.context_window?.total_input_tokens
303 const ctxPercent = stdin.context_window?.used_percentage ?? (window && ctxTokens !== undefined ? (ctxTokens / window) * 100 : 0)
304 const all =
305 config.hasContextLine && ctxTokens !== undefined
306 ? [
307 ...themed,
308 contextRow({
309 percent: Math.round(Math.max(ctxPercent, sizePercent)),
310 bytes: sizeBytes,
311 tokens: ctxTokens,
312 window,
313 history: percentLog,
314 } satisfies ContextView),
315 ]
316 : themed
317 // The pet moves in beside the rows when it fits, and out when it does not.
318 const widest = Math.max(0, ...all.map(row => row.reduce((sum, span) => sum + cellWidth(span.text), 0)))
319 const wasDocked = await read($, isPetDocked)
320 const isDocked = pet !== null && !(await read($, isHidden)) && live.columns !== undefined && dockFits(widest, live.columns, pet.width, wasDocked)
321 if (isDocked !== wasDocked) await update($, isPetDocked, () => isDocked)
322 live.lastLines = all.map(row => row.map(span => span.text).join(''))
323 live.lastError = null
324 await update($, lines, () => all)
325 } catch (err) {
326 live.lastError = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err)
327 } finally {
328 live.refreshMs = Date.now() - started
329 isRunning = false
330 }
331 if (isQueued) {
332 isQueued = false
333 void refresh()
334 }
335 }
336
337 // The mascot's mood: the quota, the context, whether a tool is running.
338 const mood = (stdin: StdinData) => {
339 const limits = stdin.rate_limits
340 const usage = Math.max(limits?.five_hour?.used_percentage ?? 0, limits?.seven_day?.used_percentage ?? 0)
341 const tools = live.transcriptPath ? transcriptData(live.transcriptPath)?.tools ?? [] : []
342 return moodOf(stdin.context_window?.used_percentage, usage, tools.some(t => t.status === 'running'))
343 }
344 // Today's spend into the history, and the store, which other sessions share.
345 const recordSpend = async (now: number, todayUsd: number) => {
346 const today = localDay(now)
347 const usd = Math.round(todayUsd * 100) / 100
348 if ((await read($, history))[today] === usd) return
349 const stored = ((await $.store.get('history')) ?? {}) as Record<string, number>
350 const merged = pruneHistory({ ...stored, [today]: usd }, today, HISTORY_DAYS)
351 await $.store.set('history', merged)
352 await update($, history, () => merged)
353 }
354
355 const kept = await $.store.get('history')
356 if (kept && typeof kept === 'object') {
357 const today = localDay(await $.clock.now())
358 await update($, history, () => pruneHistory(kept as Record<string, number>, today, HISTORY_DAYS))
359 }
360
361 const keptTheme = findTheme((await keptRows(prefsOf($), STORE_MOVES)).theme)
362 if (keptTheme) useTheme(keptTheme)
363 await update($, isHidden, () => !config.isVisible)
364
365 live.claudeLanguage = ((await $.settings.read().catch(() => ({}))) as { language?: unknown }).language
366 await loadHostFacts(io, config.extraCmd)
367 // claude-hud sets its language in each pass; the command's description is read before the first.
368 await runWithFacts(async () => setLanguage((await loadConfig()).language))
369 await $.command.register({ name: 'synapse', description: m('cmd.description'), argumentHint: '[off|on | detail | theme [name|next|reset]]', immediate: true })
370 if (config.isDebug) {
371 await $.tool.register({
372 name: 'synapse_debug',
373 description: 'claude-hud mod diagnostics: the stdin it built, its last error and refresh time.',
374 inputSchema: { type: 'object', properties: {} },
375 })
376 }
377 // Usage the session already had (a resumed one): alerts now, not after the first measurement.
378 const usage = await $.session.usage().catch(() => null)
379 if (usage) {
380 await alert($, gaugesOf(usage), config.contextAlerts, config.usageAlerts)
381 }
382 $.clock.every(TICK_MS, () => void refresh())
383 void refresh()
384 // Remote Control turns on and off outside the turn's events (`/remote-control`,
385 // `--remote-control` connecting): a cheap read of the session file, a redraw on a change.
386 $.clock.every(RC_POLL_MS, async () => {
387 const bridge = await remoteControl(io, await $.session.id()).catch(() => live.bridgeSessionId)
388 if (bridge !== live.bridgeSessionId) {
389 // A new bridge, or none: whoever wrote over the old one is not known to be there.
390 await update($, remotes, all => all.filter(r => r.surface !== BRIDGE))
391 schedule()
392 }
393 })
394
395 const result = await next(e)
396 await migrateStore(prefsOf($), STORE_MOVES)
397 return result
398 })
399
400 on('command.run', { command: 'synapse' }, async ($, e) => {
401 const [verb = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
402 if (verb === 'theme') {
403 const list = THEMES.map(t => `${t.name}${t.isNerdFont ? '*' : ''} ${t.sample}`).join(' · ')
404 const names = THEMES.map(t => t.name).join(', ')
405 const at = THEMES.indexOf(live.theme)
406 if (!arg) {
407 // The engine's dialog offers the next four; "Other" takes any name. Dismissed, or no one to ask (-p): the list.
408 const offered = [1, 2, 3, 4].map(i => THEMES[(at + i) % THEMES.length]!.name)
409 const answer = await $.ui.ask(m('theme.ask', { name: live.theme.name, list: names }), { options: offered, header: 'HUD theme' }).catch(() => null)
410 if (answer === null) return { text: m('theme.list', { name: live.theme.name, list }) }
411 const asked = findTheme(answer.trim().split(/\s+/)[0]?.toLowerCase())
412 if (!asked) return { text: m('theme.unknown', { name: answer.trim(), list: names }) }
413 return setTheme($, asked, schedule)
414 }
415 // `reset`: the option's default, claude-hud's own look.
416 const picked = arg === 'next' ? THEMES[(at + 1) % THEMES.length] : arg === 'reset' ? THEMES[0] : findTheme(arg)
417 if (!picked) return { text: m('theme.unknown', { name: arg, list: names }) }
418 return setTheme($, picked, schedule)
419 }
420 if (verb === 'detail') {
421 if ((await $.ui.panes()).some(p => p.id === PANE)) {
422 await $.ui.close({ id: PANE })
423 return { text: m('pane.closed') }
424 }
425 await $.ui.open({ id: PANE, title: m('pane.title') })
426 return { text: m('pane.opened') }
427 }
428 const hidden = await setHidden($, verb, schedule)
429
430 return { text: m(hidden ? 'cmd.hidden' : 'cmd.shown') }
431 })
432
433 // A button in the prompt footer: what `/synapse` alone does. Mode labels other plugins add stay beside it.
434 if (config.hasFooterButton) {
435 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
436 const hidden = await read($, isHidden)
437 const below = await next(e)
438 const { Box, Button } = $.ui.resolve(e)
439 return (
440 <Box flexDirection="row" alignItems="center" gap={1}>
441 <Button key="hud-toggle" plain dimColor={hidden} label="HUD" onPress={() => setHidden($, '', schedule)} />
442 {below}
443 </Box>
444 )
445 })
446 }
447
448 on('session.attach', async ($, e, next) => {
449 if (e.surface !== 'terminal') {
450 await update($, remotes, all => [...all.filter(r => r.id !== e.clientId), { id: e.clientId, surface: e.surface }])
451 }
452 schedule()
453
454 return next(e)
455 })
456
457 // Remote Control's clients are seen by what they send.
458 on('prompt.submit', async ($, e, next) => {
459 if (await read($, isPicking)) await update($, isPicking, () => false)
460 if (await sawBridge($, e.origin)) schedule()
461 return next(e)
462 })
463 on('command.run', async ($, e, next) => {
464 if (await sawBridge($, e.origin)) schedule()
465 if (e.command !== 'model') return next(e)
466 // /model: the last step's model is the old one; the session's is shown until the next step.
467 const result = await next(e)
468 await update($, steps, step => ({ ...step, model: null }))
469 schedule()
470 return result
471 })
472
473 // Remote Control's prompts reach the session as deliveries before they are a prompt.
474 on('session.receive', async ($, e, next) => {
475 if (await sawBridge($, e.origin)) schedule()
476 return next(e)
477 })
478
479 // Usage pushed by the engine: the alerts at once, and the gauges redrawn.
480 on('session.measure', async ($, e, next) => {
481 await alert($, gaugesOf(e), config.contextAlerts, config.usageAlerts)
482 if (config.hasContextLine) {
483 try {
484 applySize(await $.session.messages({ as: 'api' }))
485 } catch {
486 // sem leitura agora
487 }
488 }
489 schedule()
490 return next(e)
491 })
492
493 // A compaction empties the context: the gauge drops now, not at the next tick.
494 on('session.compact', async ($, e, next) => {
495 const result = await next(e)
496 if (e.agentId === undefined) schedule()
497 return result
498 })
499
500 // spinner's pet came, went or changed: whether it fits is decided again.
501 on('state.set', { plugin: 'spinner', key: 'dock' }, async ($, e, next) => {
502 const result = await next(e)
503 schedule()
504 return result
505 })
506
507 // A click on the pet drawn here: spinner counts it as a pat.
508 on('ui.message', async ($, e, next) => {
509 if ((e.data as { pat?: unknown } | null)?.pat === true) await update($, petPats, n => n + 1)
510 return next(e)
511 })
512
513 on('session.detach', async ($, e, next) => {
514 await update($, remotes, all => all.filter(r => r.id !== e.clientId))
515 schedule()
516
517 return next(e)
518 })
519
520 on('tool.call', { tool: /^mcp__synapse-rate-limit__synapse_debug$/ }, async () => {
521 const text = JSON.stringify(
522 { lines: live.lastLines, stdin: live.lastStdin, bridgeSessionId: live.bridgeSessionId, sessionFile: live.sessionFile, error: live.lastError, refreshMs: live.refreshMs, columns: live.columns, facts: factsSummary() },
523 null,
524 2,
525 )
526
527 return { result: text, text }
528 })
529
530 on('tool.call', async ($, e, next) => {
531 schedule()
532 const started = Date.now()
533 const ran = await next(e)
534 const ms = Date.now() - started
535 const failed = 'deny' in ran ? ran.deny !== undefined : ran.isError === true
536 await update($, tools, all => {
537 const was = all[e.tool] ?? { count: 0, totalMs: 0, errors: 0 }
538 return { ...all, [e.tool]: { count: was.count + 1, totalMs: was.totalMs + ms, errors: was.errors + (failed ? 1 : 0) } }
539 })
540 schedule()
541
542 return ran
543 })
544
545 on('turn.step', async function* ($, e, next) {
546 if (!e.agentId && config.hasContextLine) {
547 try {
548 applySize(await $.session.messages({ as: 'api' }))
549 } catch {
550 // sem leitura agora
551 }
552 }
553 const started = Date.now()
554 const result = yield* next(e)
555 const elapsed = Date.now() - started
556 const usage = result.usage
557 await update($, steps, step =>
558 e.agentId
559 ? { ...step, apiDurationMs: step.apiDurationMs + elapsed }
560 : {
561 model: e.model,
562 effort: e.effort === undefined ? step.effort : String(e.effort),
563 apiDurationMs: step.apiDurationMs + elapsed,
564 currentUsage: usage
565 ? {
566 input_tokens: usage.input_tokens,
567 output_tokens: usage.output_tokens,
568 cache_creation_input_tokens: usage.cache_creation_input_tokens,
569 cache_read_input_tokens: usage.cache_read_input_tokens,
570 }
571 : step.currentUsage,
572 // The request's start is when the main thread's prompt cache was last used.
573 lastRequestAt: started,
574 },
575 )
576 schedule()
577
578 return result
579 })
580
581 // What the turn about to run starts from, for its row in `/synapse detail`.
582 on('turn.start', async ($, e, next) => {
583 const usage = await $.session.usage().catch(() => null)
584 await update($, turnStart, () => ({ usd: usage?.cost?.usd ?? null, tokens: usage?.context.tokens ?? null }))
585 return next(e)
586 })
587
588 on('turn.complete', async ($, e, next) => {
589 schedule()
590 if (e.agentId !== undefined) return next(e)
591 const start = await read($, turnStart)
592 if (start) {
593 const usage = await $.session.usage().catch(() => null)
594 const usd = usage?.cost?.usd
595 const tokens = usage?.context.tokens
596 await update($, turnStart, () => null)
597 await update($, turnLog, log => [
598 ...log.slice(-(TURN_LOG_SIZE - 1)),
599 {
600 n: (log[log.length - 1]?.n ?? 0) + 1,
601 durationMs: e.durationMs,
602 usd: usd !== undefined && start.usd !== null ? usd - start.usd : null,
603 tokens: tokens !== undefined && start.tokens !== null ? tokens - start.tokens : null,
604 },
605 ])
606 }
607 if (config.hasContextLine) {
608 try {
609 const { context } = await $.session.usage()
610 const messages = await $.session.messages({ as: 'api' })
611 let bytes = 0
612 if (Array.isArray(messages)) for (const msg of messages) bytes += JSON.stringify(msg).length
613 sizeBytes = bytes
614 sizePercent = bytesPercent(bytes)
615 const tokens = context.tokens ?? 0
616 const pct = context.percent ?? (context.window ? (tokens / context.window) * 100 : 0)
617 percentLog = [...percentLog, Math.round(Math.max(pct, sizePercent))].slice(-MAX_TURNS)
618 schedule()
619 } catch {
620 // sem leitura neste turno: mantém a última linha
621 }
622 }
623 if (e.isAborted) return next(e)
624
625 if (config.notifyMs > 0 && e.durationMs >= config.notifyMs && e.reason === 'answer') {
626 $.ui.toast(m('turn.done', { d: formatDuration(e.durationMs) }))
627 if (config.hasChime) void $.audio.play({ base64: chimeWav(), mime: 'audio/wav' }).catch(() => {})
628 }
629 const count = await update($, turns, n => n + 1)
630 // While the model keeps a task list with work left, the list already says what it is doing
631 // (Claude Code draws it, and so do todo-bar and the todos line): no fork, and an older line steps aside.
632 const todos = live.transcriptPath ? transcriptData(live.transcriptPath)?.todos ?? [] : []
633 const hasPlan = todos.some(t => t.status !== 'completed')
634 if (hasPlan && (await read($, summary)) !== null) {
635 await update($, summary, () => null)
636 schedule()
637 }
638 if (config.summaryEvery > 0 && !hasPlan && !isSummarizing && (count === 1 || count % config.summaryEvery === 0)) {
639 isSummarizing = true
640 // Not awaited: the fork reads the conversation from the prompt cache while the person reads the answer.
641 void $.model
642 .fork({ prompt: summaryPrompt() })
643 .then(async reply => {
644 const line = reply.isAnswered ? cleanSummary(reply.text) : null
645 if (line) {
646 await update($, summary, () => line)
647 schedule()
648 }
649 })
650 .catch(() => {})
651 .finally(() => {
652 isSummarizing = false
653 })
654 }
655
656 return next(e)
657 })
658
659 const trackWidth = (columns: number | undefined) => {
660 if (columns && columns !== live.columns) {
661 live.columns = columns
662 schedule()
663 }
664 }
665
666 {
667 // No desktop o HUD fica sempre acima do prompt (não há linha abaixo); nas demais superfícies vale a opção position.
668 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
669 on('prompt.edit', async ($, e, next) => {
670 const box = await next(e)
671 const isOpen = isPickerOpen(box.text, box.cursor)
672 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
673 return box
674 })
675
676 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
677 if (config.position === 'below' && e.surface !== 'desktop') return next(e)
678 // Two cells in, as the engine indents the lines under the prompt.
679 trackWidth(e.props.bodyColumns - 2)
680 const rows = await read($, lines)
681 if (e.props.hasSurvey || rows.length === 0 || (await read($, isHidden)) || (await read($, isPicking))) {
682 return next(e)
683 }
684
685 // Uma linha em branco (~24px) separa o HUD do chat.
686 return drawRows($.ui.resolve(e), rows, await next(e), 2, 1)
687 })
688 }
689 {
690 // Under the prompt, where the statusline sat: the HUD, then the engine's hint line (exceto no desktop).
691 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
692 if (config.position !== 'below' || e.surface === 'desktop') return next(e)
693 trackWidth(e.viewport?.columns)
694 const rows = await read($, lines)
695 if (rows.length === 0 || (await read($, isHidden))) {
696 return next(e)
697 }
698 const ui = $.ui.resolve(e)
699 const pet = (await read($, isPetDocked)) ? await read($, petDock) : null
700 if (!pet || !('Client' in ui)) return drawRows(ui, rows, await next(e))
701
702 const { Box } = ui
703 return (
704 <Box flexDirection="row">
705 <Box flexDirection="column" flexGrow={1} flexShrink={0}>
706 {drawRows(ui, rows, await next(e))}
707 </Box>
708 {drawPet(ui, pet, 'pet')}
709 </Box>
710 )
711 })
712 }
713
714 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
715 // Read so the pane redraws with the HUD: the transcript's agents and todos change with it.
716 await read($, lines)
717 const transcript = live.transcriptPath ? transcriptData(live.transcriptPath) : null
718 const now = await $.clock.now()
719 return drawPane($.ui.resolve(e), {
720 tools: await read($, tools),
721 turns: await read($, turnLog),
722 agents: transcript?.agents ?? [],
723 todos: transcript?.todos ?? [],
724 history: await read($, history),
725 today: localDay(now),
726 budgetUsd: config.budgetUsd,
727 now: Date.now(),
728 })
729 })
730}
731hooks/shims/globals.ts 54 lines1// The two Node globals claude-hud reaches for: `process` and `Buffer`.
2// Filled from the session at start (see register.tsx).
3export const processShim = {
4 env: {} as Record<string, string | undefined>,
5 platform: 'darwin',
6 pid: 0,
7 argv: ['node', 'claude-hud'] as string[],
8 execPath: '',
9 stdout: { columns: undefined as number | undefined, isTTY: false },
10 stderr: { columns: undefined as number | undefined, isTTY: false },
11 stdin: undefined,
12 cwdPath: '/',
13 cwd(): string {
14 return processShim.cwdPath
15 },
16}
17
18type ByteString = Uint8Array & { toString(encoding?: string): string }
19
20function withText(bytes: Uint8Array): ByteString {
21 const out = bytes as ByteString
22 out.toString = () => new TextDecoder().decode(bytes)
23 return out
24}
25
26const BufferShim = {
27 from(value: string | ArrayLike<number>): ByteString {
28 return withText(typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value))
29 },
30 alloc(size: number): ByteString {
31 return withText(new Uint8Array(size))
32 },
33 byteLength(value: string): number {
34 return new TextEncoder().encode(value).length
35 },
36 concat(list: Uint8Array[]): ByteString {
37 const out = new Uint8Array(list.reduce((n, c) => n + c.length, 0))
38 let at = 0
39 for (const c of list) {
40 out.set(c, at)
41 at += c.length
42 }
43 return withText(out)
44 },
45}
46
47const g = globalThis as Record<string, unknown>
48g.process = processShim
49g.Buffer = BufferShim
50if (typeof g.console === 'undefined') {
51 const quiet = () => {}
52 g.console = { log: quiet, error: quiet, warn: quiet, info: quiet, debug: quiet }
53}
54hooks/config.ts 73 lines1// hud's options (plugin.json `userConfig`), read once into a typed config.
2// The HUD's language and layout stay claude-hud's own config file's.
3import type { PluginOptions } from 'claude-code'
4
5import { parseThresholds } from './extras.js'
6import { LANGUAGE_OPTIONS, type LanguageOption } from './language.js'
7import { count, flag, oneOf, text } from './kit/options.js'
8import { findTheme, THEMES, type Theme } from './themes.js'
9
10export type Config = {
11 isVisible: boolean
12 /** A HUD button in the prompt footer. */
13 hasFooterButton: boolean
14 /** `auto` segue o idioma do Claude Code; `en` e `pt-BR` fixam o do HUD. */
15 language: LanguageOption
16 /** The context line: weather, size in MB, tokens, last turns' chart and growth. */
17 hasContextLine: boolean
18 position: 'above' | 'below'
19 theme: Theme
20 hasMascot: boolean
21 /** claude-hud's `--extra-cmd`. */
22 extraCmd: string
23 /** Registers `mcp__hud__hud_debug`. */
24 isDebug: boolean
25 /** A turn at least this long toasts when it ends; 0 is off. */
26 notifyMs: number
27 hasChime: boolean
28 contextAlerts: number[]
29 usageAlerts: number[]
30 hasForecast: boolean
31 budgetUsd: number
32 hasHistory: boolean
33 /** Summarize after the first turn and every this many; 0 is off. */
34 summaryEvery: number
35 /** Show the tokens left before auto-compaction from this percent of the way there; 0 is off. */
36 compactWarnPercent: number
37 /** Once the prompt cache has expired, show what the next message re-caches from this many context tokens; 0 is off. */
38 coldCacheTokens: number
39 /** Show the last turn's context growth once it reaches this many tokens; 0 off. */
40 turnGrowthTokens: number
41 gitDirtyWarn: number
42 gitAheadWarn: number
43 hasAgents: boolean
44}
45
46export function readConfig(options: PluginOptions): Config {
47 return {
48 isVisible: flag(options.visible, true),
49 hasFooterButton: flag(options.footerButton, true),
50 language: oneOf(options.language, LANGUAGE_OPTIONS, 'auto'),
51 hasContextLine: flag(options.enabled, true),
52 position: oneOf(options.position, ['above', 'below'], 'above'),
53 theme: findTheme(options.theme) ?? THEMES[0]!,
54 hasMascot: flag(options.showMascot, true),
55 extraCmd: text(options.extraCmd),
56 isDebug: flag(options.debug, false),
57 notifyMs: count(options.notifyAfterSeconds, 0, { min: 0 }) * 1000,
58 hasChime: flag(options.notifySound, true),
59 contextAlerts: parseThresholds(text(options.contextAlerts)),
60 usageAlerts: parseThresholds(text(options.usageAlerts)),
61 hasForecast: flag(options.showForecast, true),
62 budgetUsd: count(options.dailyBudgetUsd, 0, { min: 0 }),
63 hasHistory: flag(options.showHistory, false),
64 summaryEvery: count(options.summaryEveryTurns, 5, { min: 0, isInteger: true }),
65 compactWarnPercent: count(options.compactWarnPercent, 60, { min: 0 }),
66 coldCacheTokens: count(options.coldCacheTokens, 20_000, { min: 0, isInteger: true }),
67 turnGrowthTokens: count(options.turnGrowthTokens, 20_000, { min: 0, isInteger: true }),
68 gitDirtyWarn: count(options.gitDirtyWarn, 20, { min: 0 }),
69 gitAheadWarn: count(options.gitAheadWarn, 5, { min: 0 }),
70 hasAgents: flag(options.showAgents, false),
71 }
72}
73hooks/draw.tsx 135 lines1// The HUD's drawings, over the elements a render hook resolved: its rows
2// (above or below the prompt) and the `/synapse 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, marginTop = 0): RenderElement {
15 const { Box, Text, Link } = ui
16
17 return (
18 <Box flexDirection="column" marginTop={marginTop}>
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/** `/synapse detail`: each tool's calls and time, the last turns, subagents, todos, and the spend. */
69export function drawPane(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, data: PaneData): RenderElement {
70 const { Box, Text } = ui
71 const stats = Object.entries(data.tools).sort((a, b) => b[1].totalMs - a[1].totalMs)
72 const agents = data.agents.slice(-8)
73 const week = lastDays(data.history, data.today, 7)
74 const heading = (text: string) => <Text bold color="cyan">{text}</Text>
75 const todaySpent = money(data.history[data.today] ?? 0) + (data.budgetUsd > 0 ? `/${money(data.budgetUsd)}` : '')
76 const days7 = streak(data.history, data.today)
77 const spendLine = [
78 m('spend.today', { spent: todaySpent }),
79 `${m('spend.week', { spent: money(week.reduce((a, b) => a + b, 0)) })} ${sparkline(week)}`,
80 ...(days7 > 1 ? [m('streak', { n: days7 })] : []),
81 ].join(' · ')
82
83 return (
84 <Box flexDirection="column">
85 {heading(m('pane.tools'))}
86 {stats.length === 0 && <Text dimColor>{m('pane.noTools')}</Text>}
87 {stats.slice(0, 12).map(([name, s]) => (
88 <Box key={`t-${name}`} flexDirection="row" columnGap={1}>
89 <Text>{name}</Text>
90 <Text dimColor>
91 {m('pane.toolStats', { count: s.count, total: formatDuration(s.totalMs), avg: formatDuration(s.totalMs / s.count) })}
92 </Text>
93 {s.errors > 0 ? <Text color="red">{m('pane.failed', { n: s.errors })}</Text> : null}
94 </Box>
95 ))}
96 <Text> </Text>
97 {heading(m('pane.turns'))}
98 {data.turns.length === 0 && <Text dimColor>{m('pane.none')}</Text>}
99 {[...data.turns].reverse().map(t => (
100 <Text key={`turn-${t.n}`} dimColor>
101 {m('pane.turnRow', {
102 n: t.n,
103 time: formatDuration(t.durationMs),
104 cost: t.usd === null ? '—' : money(t.usd),
105 tokens: t.tokens === null ? '—' : `${t.tokens < 0 ? '−' : '+'}${formatTokens(Math.abs(t.tokens))}`,
106 })}
107 </Text>
108 ))}
109 <Text> </Text>
110 {heading(m('pane.agents'))}
111 {agents.length === 0 && <Text dimColor>{m('pane.none')}</Text>}
112 {agents.map(a => (
113 <Box key={`a-${a.id}`} flexDirection="row" columnGap={1}>
114 <Text color={a.status === 'running' ? 'yellow' : 'green'}>{a.status === 'running' ? '◐' : '✓'}</Text>
115 <Text>{a.type}</Text>
116 <Text dimColor wrap="truncate-end">
117 {a.description ?? ''} {formatDuration((a.endTime?.getTime() ?? data.now) - a.startTime.getTime())}
118 </Text>
119 </Box>
120 ))}
121 <Text> </Text>
122 {heading(m('pane.todos'))}
123 {data.todos.length === 0 && <Text dimColor>{m('pane.none')}</Text>}
124 {data.todos.map((t, i) => (
125 <Text key={`d-${i}`} dimColor={t.status === 'completed'} color={t.status === 'in_progress' ? 'yellow' : undefined}>
126 {t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'} {t.content}
127 </Text>
128 ))}
129 <Text> </Text>
130 {heading(m('pane.spend'))}
131 <Text>{spendLine}</Text>
132 </Box>
133 )
134}
135hooks/extras.ts 359 lines1// What the mod adds to claude-hud's lines: alerts, a usage forecast, today's
2// spend against a budget, the spend history, a git nag and the task summary.
3// Pure helpers; register.tsx feeds them from `$` and draws the row.
4import type { HudLine, TurnCost } from '../types'
5import { textWidth } from './hud/render/ansi.js'
6import { formatTokens } from './hud/utils/format.js'
7import { m, money } from './i18n.js'
8
9/** "80, 90" → [80, 90]: whole percents in 1-100, ascending; empty turns alerts off. */
10export function parseThresholds(spec: string): number[] {
11 const values = spec
12 .split(/[\s,]+/)
13 .map(s => Number.parseInt(s, 10))
14 .filter(n => Number.isFinite(n) && n >= 1 && n <= 100)
15 return [...new Set(values)].sort((a, b) => a - b)
16}
17
18// A threshold fires again only once the percent has dropped this far below it
19// (a /compact, a window reset), so the tick does not repeat a toast.
20const REARM_BELOW = 5
21
22/**
23 * The thresholds `percent` stands at or past, given those already fired; `alert`
24 * is the highest newly crossed one (null when none). Fired ones the percent
25 * dropped well below are re-armed.
26 */
27export function crossThresholds(
28 percent: number | null | undefined,
29 thresholds: number[],
30 fired: number[],
31): { alert: number | null; fired: number[] } {
32 if (percent === null || percent === undefined || !Number.isFinite(percent)) return { alert: null, fired }
33 const kept = fired.filter(t => percent >= t - REARM_BELOW)
34 const fresh = thresholds.filter(t => percent >= t && !kept.includes(t))
35 return { alert: fresh.length > 0 ? fresh[fresh.length - 1]! : null, fired: [...kept, ...fresh].sort((a, b) => a - b) }
36}
37
38// Below this much usage a linear projection is noise; claude-hud's pace uses the same floor.
39const MIN_USED_PERCENT = 10
40
41/**
42 * When a rate-limit window runs out at the rate used so far (ms), or null when
43 * it lasts until its reset (or the data cannot say).
44 */
45export function exhaustAt(
46 percent: number | null | undefined,
47 resetsAtSec: number | null | undefined,
48 windowMs: number,
49 now: number,
50): number | null {
51 if (percent === null || percent === undefined || !resetsAtSec || percent < MIN_USED_PERCENT) return null
52 if (percent >= 100) return now
53 const resetsAt = resetsAtSec * 1000
54 const elapsed = windowMs - (resetsAt - now)
55 if (elapsed <= 0 || resetsAt <= now) return null
56 const at = now + ((100 - percent) / percent) * elapsed
57 return at < resetsAt ? at : null
58}
59
60/** Readings of one rate-limit window: `[ms, percent]`, oldest first, for the window that resets at `resetsAt`. */
61export type Samples = { resetsAt: number; points: [number, number][] }
62
63/** How far back the recent pace looks, and the least span it needs to mean anything. */
64const PACE_LOOKBACK_MS = 60 * 60_000
65const PACE_MIN_SPAN_MS = 10 * 60_000
66
67/** `samples` with a reading at `now` added: a new window starts over, readings past the lookback leave. */
68export function addSample(samples: Samples | undefined, percent: number, resetsAtSec: number, now: number): Samples {
69 const resetsAt = resetsAtSec * 1000
70 const kept = samples && samples.resetsAt === resetsAt ? samples.points.filter(([t]) => now - t <= PACE_LOOKBACK_MS) : []
71 return { resetsAt, points: [...kept, [now, percent]] }
72}
73
74/**
75 * When a window runs out at the pace of the last hour (ms), or null when that
76 * pace lasts until the reset. With under ten minutes of readings, the pace
77 * since the window began (exhaustAt) stands in.
78 */
79export function paceAt(samples: Samples | undefined, percent: number | null | undefined, resetsAtSec: number | null | undefined, windowMs: number, now: number): number | null {
80 const first = samples?.points[0]
81 if (!samples || !first || !resetsAtSec || samples.resetsAt !== resetsAtSec * 1000 || now - first[0] < PACE_MIN_SPAN_MS) {
82 return exhaustAt(percent, resetsAtSec, windowMs, now)
83 }
84 if (percent === null || percent === undefined || percent < MIN_USED_PERCENT) return null
85 if (percent >= 100) return now
86 const rate = (percent - first[1]) / (now - first[0])
87 if (rate <= 0) return null
88 const at = now + (100 - percent) / rate
89 return at < samples.resetsAt ? at : null
90}
91
92const pad = (n: number) => String(n).padStart(2, '0')
93
94/** "14:05" in the machine's time zone. */
95export function clockTime(ms: number): string {
96 const d = new Date(ms)
97 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
98}
99
100/** "2026-10-02" in the machine's time zone. */
101export function localDay(ms: number): string {
102 const d = new Date(ms)
103 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
104}
105
106/** The day `n` days after `day` (negative: before). */
107export function addDays(day: string, n: number): string {
108 const [y, m, d] = day.split('-').map(Number)
109 return localDay(new Date(y!, m! - 1, d! + n, 12).getTime())
110}
111
112/** "45s", "2m05s", "1h03m". */
113export function formatDuration(ms: number): string {
114 const s = Math.max(0, Math.round(ms / 1000))
115 if (s < 60) return `${s}s`
116 if (s < 3600) return `${Math.floor(s / 60)}m${pad(s % 60)}s`
117 return `${Math.floor(s / 3600)}h${pad(Math.floor((s % 3600) / 60))}m`
118}
119
120const BLOCKS = '▁▂▃▄▅▆▇█'
121
122/** One block per value, scaled to the largest; a zero is the lowest block. */
123export function sparkline(values: number[]): string {
124 const max = Math.max(0, ...values)
125 return values
126 .map(v => (max <= 0 || v <= 0 ? BLOCKS[0] : BLOCKS[Math.min(7, Math.max(1, Math.round((v / max) * 7)))]))
127 .join('')
128}
129
130/** The last turn's context growth and the recent turns', once the last grew it by at least `min` tokens. */
131export function growthOf(log: readonly TurnCost[], min: number): { last: number; recent: number[] } | null {
132 const last = log[log.length - 1]?.tokens ?? null
133 if (min <= 0 || last === null || last < min) return null
134 return { last, recent: log.map(t => Math.max(0, t.tokens ?? 0)) }
135}
136
137/** Spend per day, `YYYY-MM-DD` → USD. */
138export type History = Record<string, number>
139
140/** The last `days` days' spend, oldest first, ending today. */
141export function lastDays(history: History, today: string, days: number): number[] {
142 return Array.from({ length: days }, (_, i) => history[addDays(today, i - days + 1)] ?? 0)
143}
144
145/** Consecutive days with spend, ending today (or yesterday, before today's first spend). */
146export function streak(history: History, today: string): number {
147 let day = (history[today] ?? 0) > 0 ? today : addDays(today, -1)
148 let count = 0
149 while ((history[day] ?? 0) > 0) {
150 count++
151 day = addDays(day, -1)
152 }
153 return count
154}
155
156/** Days older than `keepDays` before today leave the history. */
157export function pruneHistory(history: History, today: string, keepDays: number): History {
158 const oldest = addDays(today, -keepDays)
159 return Object.fromEntries(Object.entries(history).filter(([day]) => day > oldest))
160}
161
162/**
163 * `git status --porcelain=v2 --branch` (lines, or `-z` records) → changed paths
164 * and commits ahead of upstream.
165 */
166export function parseGitStatus(stdout: string): { dirty: number; ahead: number } {
167 const isZ = stdout.includes('\0')
168 const records = stdout.split(isZ ? '\0' : '\n')
169 let dirty = 0
170 let ahead = 0
171 for (let i = 0; i < records.length; i++) {
172 const record = records[i]!
173 if (!record) continue
174 if (record.startsWith('#')) {
175 const ab = /^# branch\.ab \+(\d+) -\d+/.exec(record)
176 if (ab) ahead = Number(ab[1])
177 } else {
178 dirty++
179 // Under -z a rename or copy is followed by its original path, a record of its own.
180 if (isZ && record.startsWith('2 ')) i++
181 }
182 }
183 return { dirty, ahead }
184}
185
186/** "Today $3.20/$10.00 ▓▓▓░░░░░", red once past the budget, yellow from 80%. */
187export function budgetSpans(todayUsd: number, budgetUsd: number, width = 8): HudLine {
188 const ratio = budgetUsd > 0 ? todayUsd / budgetUsd : 0
189 const filled = Math.min(width, Math.round(ratio * width))
190 const color = ratio >= 1 ? 'red' : ratio >= 0.8 ? 'yellow' : 'green'
191 const spans: HudLine = [{ text: `${m('spend.today', { spent: `${money(todayUsd)}/${money(budgetUsd)}` })} `, dimColor: ratio < 0.8 }]
192 if (filled > 0) spans.push({ text: '▓'.repeat(filled), color })
193 if (filled < width) spans.push({ text: '░'.repeat(width - filled), dimColor: true })
194 return spans
195}
196
197export type ExtrasInput = {
198 summary: string | null
199 /** Rate-limit windows that run out before they reset: label and when. */
200 exhaust: { label: string; at: number }[]
201 todayUsd: number | null
202 budgetUsd: number
203 /** The last 7 days' spend, oldest first, and the streak; null hides the history. */
204 week: { values: number[]; streak: number } | null
205 git: { dirty: number; ahead: number } | null
206 /** Tokens left before auto-compaction runs, once the context is far enough in; null hides it. */
207 compactLeft?: number | null
208 /** The context tokens the next message writes to the cache again, once it has expired; null hides it. */
209 coldCache?: number | null
210 /** How far the last turn grew the context, and the recent turns' growth, oldest first; null hides it. */
211 growth?: { last: number; recent: number[] } | null
212 gitDirtyWarn: number
213 gitAheadWarn: number
214 /** The row's width; parts that do not fit leave it, least important first. */
215 columns?: number
216 /** The theme's glyphs and colors for the row; claude-hud's look when absent. */
217 style?: ExtrasStyle
218 /** The theme mascot's face, first in the row. */
219 mascot?: string | null
220}
221
222export type ExtrasStyle = {
223 summary?: string
224 warning?: string
225 summaryColor?: string
226 forecastColor?: string
227 weekColor?: string
228 warningColor?: string
229 mascotColor?: string
230}
231
232const SEPARATOR = ' │ '
233
234const lineWidth = (spans: HudLine) => spans.reduce((sum, span) => sum + textWidth(span.text), 0)
235
236/** The row under claude-hud's lines; empty when there is nothing to say. */
237export function extrasLine(x: ExtrasInput): HudLine {
238 // Kept in display order; `rank` is what goes last when the row is too wide.
239 const parts: { spans: HudLine; rank: number }[] = []
240 const style = x.style ?? {}
241 if (x.mascot) parts.push({ spans: [{ text: x.mascot, color: style.mascotColor ?? style.summaryColor ?? 'cyan', bold: true }], rank: -1 })
242 if (x.summary) parts.push({ spans: [{ text: `${style.summary ?? '✎'} ${x.summary}`, color: style.summaryColor ?? 'cyan' }], rank: 3 })
243 for (const { label, at } of x.exhaust) {
244 parts.push({ spans: [{ text: m('forecast', { label, time: clockTime(at) }), color: style.forecastColor ?? 'magenta' }], rank: 2 })
245 }
246 if (typeof x.compactLeft === 'number') {
247 parts.push({ spans: [{ text: m('compact.left', { tokens: formatTokens(Math.max(0, x.compactLeft)) }), color: style.forecastColor ?? 'magenta' }], rank: 2 })
248 }
249 if (typeof x.coldCache === 'number') {
250 parts.push({ spans: [{ text: m('cache.cold', { tokens: formatTokens(x.coldCache) }), color: style.warningColor ?? 'yellow' }], rank: 2 })
251 }
252 if (x.growth) {
253 const color = style.forecastColor ?? 'magenta'
254 parts.push({
255 spans: [
256 { text: `${m('turn.growth', { tokens: formatTokens(x.growth.last) })} `, color },
257 { text: sparkline(x.growth.recent), color },
258 ],
259 rank: 1,
260 })
261 }
262 if (x.budgetUsd > 0 && x.todayUsd !== null) parts.push({ spans: budgetSpans(x.todayUsd, x.budgetUsd), rank: 1 })
263 if (x.week && x.week.values.some(v => v > 0)) {
264 parts.push({
265 spans: [
266 { text: `${m('week')} `, dimColor: true },
267 { text: sparkline(x.week.values), color: style.weekColor ?? 'blue' },
268 ...(x.week.streak > 1 ? [{ text: ` ${m('streak', { n: x.week.streak })}`, dimColor: true }] : []),
269 ],
270 rank: 0,
271 })
272 }
273 const nags: string[] = []
274 if (x.git && x.gitDirtyWarn > 0 && x.git.dirty >= x.gitDirtyWarn) nags.push(m('git.dirty', { n: x.git.dirty }))
275 if (x.git && x.gitAheadWarn > 0 && x.git.ahead >= x.gitAheadWarn) nags.push(m('git.ahead', { n: x.git.ahead }))
276 if (nags.length > 0) parts.push({ spans: [{ text: `${style.warning ?? '⚠'} ${nags.join(' · ')}`, color: style.warningColor ?? 'yellow' }], rank: 4 })
277
278 const join = (kept: typeof parts) =>
279 kept.flatMap((part, i) => (i === 0 ? part.spans : [{ text: SEPARATOR, dimColor: true }, ...part.spans]))
280 const kept = [...parts]
281 while (x.columns && kept.length > 1 && lineWidth(join(kept)) > x.columns) {
282 const lowest = Math.min(...kept.map(p => p.rank))
283 kept.splice(kept.findIndex(p => p.rank === lowest), 1)
284 }
285 return join(kept)
286}
287
288/** claude-hud's ` │ ` and ` | ` between elements, dimmed like the extras row's. */
289export function dimSeparators(row: HudLine): HudLine {
290 return row.flatMap(span => {
291 if (span.dimColor || !/ [│|] /.test(span.text)) return [span]
292 return span.text
293 .split(/( [│|] )/)
294 .flatMap((text, i) => (!text ? [] : i % 2 === 1 ? [{ text, dimColor: true }] : [{ ...span, text }]))
295 })
296}
297
298/** The extras row joins claude-hud's last row when both fit in `columns`, else goes under it. */
299export function appendExtras(rows: HudLine[], extra: HudLine, columns: number | undefined): HudLine[] {
300 if (extra.length === 0) return rows
301 const last = rows[rows.length - 1]
302 if (last && columns && lineWidth(last) + textWidth(SEPARATOR) + lineWidth(extra) <= columns) {
303 return [...rows.slice(0, -1), [...last, { text: SEPARATOR, dimColor: true }, ...extra]]
304 }
305 return [...rows, extra]
306}
307
308const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
309
310function base64(bytes: Uint8Array): string {
311 let out = ''
312 for (let i = 0; i < bytes.length; i += 3) {
313 const [a, b = 0, c = 0] = [bytes[i]!, bytes[i + 1], bytes[i + 2]]
314 const n = (a << 16) | (b << 8) | c
315 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
316 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
317 out += i + 2 < bytes.length ? B64[n & 63]! : '='
318 }
319 return out
320}
321
322let chime: string | null = null
323
324/** A short two-note chime, as a base64 16-bit mono WAV: the turn-done sound. */
325export function chimeWav(): string {
326 if (chime) return chime
327 const rate = 22_050
328 const notes = [
329 { hz: 880, ms: 110 },
330 { hz: 1320, ms: 170 },
331 ]
332 const samples = notes.flatMap(({ hz, ms }) => {
333 const count = Math.round((rate * ms) / 1000)
334 return Array.from({ length: count }, (_, i) => {
335 const fade = Math.min(1, i / 200, (count - i) / 600)
336 return Math.round(Math.sin((2 * Math.PI * hz * i) / rate) * fade * 0.35 * 32767)
337 })
338 })
339 const data = samples.length * 2
340 const buf = new DataView(new ArrayBuffer(44 + data))
341 const text = (at: number, s: string) => [...s].forEach((ch, i) => buf.setUint8(at + i, ch.charCodeAt(0)))
342 text(0, 'RIFF')
343 buf.setUint32(4, 36 + data, true)
344 text(8, 'WAVE')
345 text(12, 'fmt ')
346 buf.setUint32(16, 16, true)
347 buf.setUint16(20, 1, true)
348 buf.setUint16(22, 1, true)
349 buf.setUint32(24, rate, true)
350 buf.setUint32(28, rate * 2, true)
351 buf.setUint16(32, 2, true)
352 buf.setUint16(34, 16, true)
353 text(36, 'data')
354 buf.setUint32(40, data, true)
355 samples.forEach((s, i) => buf.setInt16(44 + i * 2, s, true))
356 chime = base64(new Uint8Array(buf.buffer))
357 return chime
358}
359hooks/hud/config.ts 546 lines1import * as fs from '../shims/fs.js';
2import * as path from '../shims/path.js';
3import * as os from '../shims/os.js';
4import { expandHomeDirPrefix, getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
5import { createDebug } from './debug.js';
6import type { Language } from './i18n/types.js';
7import { MAX_TERMINAL_WIDTH } from './utils/terminal.js';
8import { sanitizeDisplayText } from './utils/sanitize.js';
9
10const debug = createDebug('config');
11const MAX_CONFIG_FILE_BYTES = 64 * 1024;
12const MAX_CONFIG_NESTING_DEPTH = 8;
13const UNSAFE_CONFIG_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
14
15const LANGUAGES = ['en', 'zh', 'zh-Hans', 'zh-Hant', 'zh-TW', 'ja', 'ko', 'es', 'fr', 'de', 'pt', 'pt-BR', 'ru'] as const satisfies readonly Language[];
16const LINE_LAYOUTS = ['compact', 'expanded'] as const;
17const PATH_LEVELS = [1, 2, 3, 'full'] as const;
18const CONTEXT_VALUE_MODES = ['percent', 'tokens', 'remaining', 'both'] as const;
19const USAGE_VALUE_MODES = ['percent', 'remaining'] as const;
20const GIT_BRANCH_OVERFLOW_MODES = ['truncate', 'wrap'] as const;
21// full: display name as-is; compact: drop the context-window suffix; short: also drop "Claude ".
22const MODEL_FORMATS = ['full', 'compact', 'short'] as const;
23const MODEL_SOURCES = ['auto', 'stdin', 'transcript'] as const;
24const EFFORT_FORMATS = ['full', 'symbol', 'text'] as const;
25const TIME_FORMATS = ['relative', 'absolute', 'both', 'elapsed', 'elapsedAndAbsolute'] as const;
26const HOUR_CYCLES = ['auto', 'h11', 'h12', 'h23', 'h24'] as const;
27const CUSTOM_LINE_POSITIONS = ['first', 'last'] as const;
28const ADDED_DIRS_LAYOUTS = ['inline', 'line'] as const;
29const COLOR_NAMES = ['dim', 'red', 'green', 'yellow', 'magenta', 'cyan', 'brightBlue', 'brightMagenta'] as const;
30
31const ELEMENTS = [
32 'project',
33 'addedDirs',
34 'context',
35 'usage',
36 'promptCache',
37 'cacheHitRate',
38 'memory',
39 'environment',
40 'tools',
41 'skills',
42 'mcp',
43 'agents',
44 'todos',
45 'sessionTime',
46] as const;
47
48// Orderable segments of the first line, shared by the expanded and compact layouts.
49const FIRST_LINE_SEGMENTS = [
50 'model',
51 'project',
52 'advisor',
53 'sessionName',
54 'version',
55 'extra',
56 'duration',
57 'cost',
58 'speed',
59 'auth',
60] as const;
61
62export type LineLayoutType = typeof LINE_LAYOUTS[number];
63export type PathLevels = typeof PATH_LEVELS[number];
64export type ContextValueMode = typeof CONTEXT_VALUE_MODES[number];
65export type UsageValueMode = typeof USAGE_VALUE_MODES[number];
66export type GitBranchOverflowMode = typeof GIT_BRANCH_OVERFLOW_MODES[number];
67export type ModelFormatMode = typeof MODEL_FORMATS[number];
68export type EffortFormatMode = typeof EFFORT_FORMATS[number];
69export type TimeFormatMode = typeof TIME_FORMATS[number];
70export type HourCycleMode = typeof HOUR_CYCLES[number];
71export type CustomLinePosition = typeof CUSTOM_LINE_POSITIONS[number];
72export type AddedDirsLayout = typeof ADDED_DIRS_LAYOUTS[number];
73export type HudColorName = typeof COLOR_NAMES[number];
74export type HudElement = typeof ELEMENTS[number];
75export type FirstLineSegment = typeof FIRST_LINE_SEGMENTS[number];
76
77/** A named preset, a 256-color index (0-255), or a #rrggbb hex string. */
78export type HudColorValue = HudColorName | number | string;
79
80export interface HudColorOverrides {
81 context: HudColorValue;
82 usage: HudColorValue;
83 warning: HudColorValue;
84 usageWarning: HudColorValue;
85 critical: HudColorValue;
86 model: HudColorValue;
87 project: HudColorValue;
88 git: HudColorValue;
89 gitBranch: HudColorValue;
90 label: HudColorValue;
91 custom: HudColorValue;
92 barFilled: string;
93 barEmpty: string;
94}
95
96export const DEFAULT_ELEMENT_ORDER: HudElement[] = [...ELEMENTS];
97export const DEFAULT_MERGE_GROUPS: HudElement[][] = [['context', 'usage']];
98// Empty keeps each renderer's native order until the user moves a segment.
99export const DEFAULT_PROJECT_LINE_ORDER: FirstLineSegment[] = [];
100
101export interface HudConfig {
102 language: Language;
103 lineLayout: LineLayoutType;
104 showSeparators: boolean;
105 pathLevels: PathLevels;
106 maxWidth: number | null;
107 forceMaxWidth: boolean;
108 elementOrder: HudElement[];
109 projectLineOrder: FirstLineSegment[];
110 gitStatus: {
111 enabled: boolean;
112 showDirty: boolean;
113 showAheadBehind: boolean;
114 showFileStats: boolean;
115 showWorktree: boolean;
116 branchOverflow: GitBranchOverflowMode;
117 pushWarningThreshold: number;
118 pushCriticalThreshold: number;
119 };
120 jjStatus: {
121 enabled: boolean;
122 showDirty: boolean;
123 showConflicts: boolean;
124 };
125 display: {
126 showModel: boolean;
127 showProject: boolean;
128 showAddedDirs: boolean;
129 addedDirsLayout: AddedDirsLayout;
130 showContextBar: boolean;
131 contextValue: ContextValueMode;
132 showConfigCounts: boolean;
133 showCost: boolean;
134 // Also show cost for routed providers (Bedrock/Vertex), which showCost hides.
135 showRoutedCost: boolean;
136 showDailyCost: boolean;
137 showWeeklyCost: boolean;
138 showDuration: boolean;
139 showSpeed: boolean;
140 showTokenBreakdown: boolean;
141 showUsage: boolean;
142 usageValue: UsageValueMode;
143 usageBarEnabled: boolean;
144 showResetLabel: boolean;
145 usageCompact: boolean;
146 showModelScopedUsage: boolean;
147 usagePace: boolean;
148 showTools: boolean;
149 showSkills: boolean;
150 showMcp: boolean;
151 toolNameMaxLength: number;
152 toolsMaxVisible: number;
153 skillsMaxVisible: number;
154 showAgents: boolean;
155 showTodos: boolean;
156 showSessionName: boolean;
157 showAuth: boolean;
158 showAuthUser: boolean;
159 // Max characters of the account name (0 = full).
160 authUserLength: number;
161 showClaudeCodeVersion: boolean;
162 showEffortLevel: boolean;
163 effortFormat: EffortFormatMode;
164 showMemoryUsage: boolean;
165 showPromptCache: boolean;
166 showCacheHitRate: boolean;
167 showSessionTokens: boolean;
168 showOutputStyle: boolean;
169 showSessionStartDate: boolean;
170 showLastResponseAt: boolean;
171 showCompactions: boolean;
172 mergeGroups: HudElement[][];
173 // Elements pushed to the right edge of a merged line, when it fits and the width is known.
174 rightAlign: HudElement[];
175 contextWarningThreshold: number;
176 contextCriticalThreshold: number;
177 usageThreshold: number;
178 sevenDayThreshold: number;
179 environmentThreshold: number;
180 externalUsagePath: string;
181 externalUsageWritePath: string;
182 externalUsageFreshnessMs: number;
183 modelFormat: ModelFormatMode;
184 modelOverride: string;
185 // auto: transcript model for non-Claude (proxied) models; stdin/transcript: always that source.
186 modelSource: typeof MODEL_SOURCES[number];
187 showProvider: boolean;
188 providerName: string;
189 customLine: string;
190 customLinePosition: CustomLinePosition;
191 timeFormat: TimeFormatMode;
192 hourCycle: HourCycleMode;
193 showClockSeconds: boolean;
194 showAdvisor: boolean;
195 advisorOverride: string;
196 autoCompactWindow: number | null;
197 };
198 colors: HudColorOverrides;
199}
200
201export const DEFAULT_CONFIG: HudConfig = {
202 language: 'en',
203 lineLayout: 'expanded',
204 showSeparators: false,
205 pathLevels: 1,
206 maxWidth: null,
207 forceMaxWidth: false,
208 elementOrder: [...DEFAULT_ELEMENT_ORDER],
209 projectLineOrder: [...DEFAULT_PROJECT_LINE_ORDER],
210 gitStatus: {
211 enabled: true,
212 showDirty: true,
213 showAheadBehind: false,
214 showFileStats: false,
215 showWorktree: false,
216 branchOverflow: 'truncate',
217 pushWarningThreshold: 0,
218 pushCriticalThreshold: 0,
219 },
220 jjStatus: {
221 enabled: false,
222 showDirty: true,
223 showConflicts: true,
224 },
225 display: {
226 showModel: true,
227 showProject: true,
228 showAddedDirs: true,
229 addedDirsLayout: 'inline',
230 showContextBar: true,
231 contextValue: 'percent',
232 showConfigCounts: false,
233 showCost: false,
234 showRoutedCost: false,
235 showDailyCost: false,
236 showWeeklyCost: false,
237 showDuration: false,
238 showSpeed: false,
239 showTokenBreakdown: true,
240 showUsage: true,
241 usageValue: 'percent',
242 usageBarEnabled: true,
243 showResetLabel: true,
244 usageCompact: false,
245 showModelScopedUsage: true,
246 usagePace: false,
247 showTools: false,
248 showSkills: false,
249 showMcp: false,
250 toolNameMaxLength: 0,
251 toolsMaxVisible: 4,
252 skillsMaxVisible: 4,
253 showAgents: false,
254 showTodos: false,
255 showSessionName: false,
256 showAuth: false,
257 showAuthUser: false,
258 authUserLength: 8,
259 showClaudeCodeVersion: false,
260 showEffortLevel: false,
261 effortFormat: 'full',
262 showMemoryUsage: false,
263 showPromptCache: false,
264 showCacheHitRate: false,
265 showSessionTokens: false,
266 showOutputStyle: false,
267 showSessionStartDate: false,
268 showLastResponseAt: false,
269 showCompactions: false,
270 mergeGroups: DEFAULT_MERGE_GROUPS.map(group => [...group]),
271 rightAlign: [],
272 contextWarningThreshold: 70,
273 contextCriticalThreshold: 85,
274 usageThreshold: 0,
275 sevenDayThreshold: 80,
276 environmentThreshold: 0,
277 externalUsagePath: '',
278 externalUsageWritePath: '',
279 externalUsageFreshnessMs: 300000,
280 modelFormat: 'full',
281 modelOverride: '',
282 modelSource: 'stdin',
283 showProvider: false,
284 providerName: '',
285 customLine: '',
286 customLinePosition: 'last',
287 timeFormat: 'relative',
288 hourCycle: 'auto',
289 showClockSeconds: false,
290 showAdvisor: false,
291 advisorOverride: '',
292 autoCompactWindow: null,
293 },
294 colors: {
295 context: 'green',
296 usage: 'brightBlue',
297 warning: 'yellow',
298 usageWarning: 'brightMagenta',
299 critical: 'red',
300 model: 'cyan',
301 project: 'yellow',
302 git: 'magenta',
303 gitBranch: 'cyan',
304 label: 'dim',
305 custom: 208,
306 barFilled: '█',
307 barEmpty: '░',
308 },
309};
310
311export function getConfigPath(): string {
312 return path.join(getHudPluginDir(os.homedir()), 'config.json');
313}
314
315// Lives outside plugins/, which users often symlink across several CLAUDE_CONFIG_DIRs,
316// so it stays per-directory and can override the shared config.
317export function getConfigOverridePath(): string {
318 return path.join(getClaudeConfigDir(os.homedir()), 'claude-hud.json');
319}
320
321// A rule maps a raw user value to a valid one, or to the fallback (the default).
322type Rule = (value: unknown, fallback: any) => unknown;
323
324const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
325
326const oneOf = (allowed: readonly unknown[]): Rule => (value, fallback) => (
327 allowed.includes(value) ? value : fallback
328);
329const clamp = (min: number, max: number): Rule => (value, fallback) => (
330 isNumber(value) ? Math.max(min, Math.min(max, value)) : fallback
331);
332const floorAtLeastZero: Rule = (value, fallback) => (isNumber(value) ? Math.max(0, Math.floor(value)) : fallback);
333const count: Rule = (value, fallback) => (Number.isInteger(value) && (value as number) >= 0 ? value : fallback);
334const text = (maxLength: number): Rule => (value, fallback) => (
335 typeof value === 'string' ? sanitizeDisplayText(value).slice(0, maxLength) : fallback
336);
337
338// Keeps known names once each, in order. An empty result falls back only when `nonEmpty`.
339const names = (known: readonly unknown[], nonEmpty: boolean): Rule => (value, fallback) => {
340 if (!Array.isArray(value)) return fallback;
341 const kept = [...new Set(value.filter(item => known.includes(item)))];
342 return kept.length > 0 || !nonEmpty ? kept : fallback;
343};
344
345// Groups need two or more known elements, and an element joins at most one group.
346const mergeGroups: Rule = (value, fallback) => {
347 if (!Array.isArray(value)) return fallback;
348 if (value.length === 0) return [];
349 const used = new Set<unknown>();
350 const groups: unknown[][] = [];
351 for (const group of value) {
352 if (!Array.isArray(group)) continue;
353 const members = [...new Set(group.filter(item => (ELEMENTS as readonly unknown[]).includes(item) && !used.has(item)))];
354 if (members.length < 2) continue;
355 members.forEach(member => used.add(member));
356 groups.push(members);
357 }
358 return groups.length > 0 ? groups : fallback;
359};
360
361const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
362const color: Rule = (value, fallback) => (
363 COLOR_NAMES.includes(value as HudColorName)
364 || (Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 255)
365 || (typeof value === 'string' && HEX_COLOR.test(value))
366 ? value
367 : fallback
368);
369
370// Exactly one visible grapheme: no controls, format, variation, separator, or unassigned code points.
371const INVISIBLE_CODEPOINT = /[\p{Cc}\p{Cf}\p{Variation_Selector}\p{Zl}\p{Zp}\p{Cn}]/u;
372const barChar: Rule = (value, fallback) => {
373 if (typeof value !== 'string' || value.length === 0) return fallback;
374 const graphemes = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)];
375 return graphemes.length === 1 && !INVISIBLE_CODEPOINT.test(value) ? value : fallback;
376};
377
378// Expands a leading ~ and ${VAR}; unset variables are left as written.
379const usagePath: Rule = (value) => (
380 typeof value === 'string'
381 ? expandHomeDirPrefix(value.trim(), os.homedir())
382 .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => process.env[name] ?? match)
383 : ''
384);
385
386// Booleans need no rule: a default of type boolean accepts only booleans.
387const RULES: Record<string, Rule> = {
388 'language': oneOf(LANGUAGES),
389 'lineLayout': oneOf(LINE_LAYOUTS),
390 'pathLevels': oneOf(PATH_LEVELS),
391 'maxWidth': (value) => (isNumber(value) && value > 0 ? Math.min(Math.floor(value), MAX_TERMINAL_WIDTH) : null),
392 'elementOrder': names(ELEMENTS, true),
393 'projectLineOrder': names(FIRST_LINE_SEGMENTS, false),
394 'gitStatus.branchOverflow': oneOf(GIT_BRANCH_OVERFLOW_MODES),
395 'gitStatus.pushWarningThreshold': floorAtLeastZero,
396 'gitStatus.pushCriticalThreshold': floorAtLeastZero,
397 'display.addedDirsLayout': oneOf(ADDED_DIRS_LAYOUTS),
398 'display.contextValue': oneOf(CONTEXT_VALUE_MODES),
399 'display.usageValue': oneOf(USAGE_VALUE_MODES),
400 'display.toolNameMaxLength': count,
401 'display.toolsMaxVisible': count,
402 'display.skillsMaxVisible': count,
403 'display.authUserLength': count,
404 'display.effortFormat': oneOf(EFFORT_FORMATS),
405 'display.mergeGroups': mergeGroups,
406 'display.rightAlign': names(ELEMENTS, false),
407 'display.contextWarningThreshold': clamp(0, 100),
408 'display.contextCriticalThreshold': clamp(0, 100),
409 'display.usageThreshold': clamp(0, 100),
410 'display.sevenDayThreshold': clamp(0, 100),
411 'display.environmentThreshold': clamp(0, 100),
412 'display.externalUsagePath': usagePath,
413 'display.externalUsageWritePath': usagePath,
414 'display.externalUsageFreshnessMs': floorAtLeastZero,
415 'display.modelFormat': oneOf(MODEL_FORMATS),
416 'display.modelOverride': text(80),
417 'display.modelSource': oneOf(MODEL_SOURCES),
418 'display.providerName': text(40),
419 'display.customLine': text(80),
420 'display.customLinePosition': oneOf(CUSTOM_LINE_POSITIONS),
421 'display.timeFormat': oneOf(TIME_FORMATS),
422 'display.hourCycle': oneOf(HOUR_CYCLES),
423 'display.advisorOverride': text(80),
424 'display.autoCompactWindow': (value) => (Number.isInteger(value) && (value as number) > 0 ? value : null),
425 'colors.barFilled': barChar,
426 'colors.barEmpty': barChar,
427 'colors.*': color,
428};
429
430function isPlainObject(value: unknown): value is Record<string, unknown> {
431 return typeof value === 'object' && value !== null && !Array.isArray(value);
432}
433
434// Walks the defaults, so unknown user keys are dropped and every key is validated.
435function normalize(defaults: Record<string, unknown>, input: unknown, prefix = ''): Record<string, unknown> {
436 const source = isPlainObject(input) ? input : {};
437 const result: Record<string, unknown> = {};
438 for (const [key, fallback] of Object.entries(defaults)) {
439 const keyPath = prefix + key;
440 const rule = RULES[keyPath] ?? RULES[`${prefix}*`];
441 const value = source[key];
442 if (rule) {
443 result[key] = rule(value, fallback);
444 } else if (isPlainObject(fallback)) {
445 result[key] = normalize(fallback, value, `${keyPath}.`);
446 } else {
447 result[key] = typeof fallback === 'boolean' && typeof value === 'boolean' ? value : fallback;
448 }
449 }
450 return result;
451}
452
453// v0.0.x wrote `layout: "default" | "separators"`; some third-party tools write an object.
454function migrateLegacyLayout(config: Record<string, unknown>): Record<string, unknown> {
455 if (!('layout' in config) || 'lineLayout' in config) return config;
456 const { layout, ...rest } = config;
457 if (typeof layout === 'string') {
458 return { ...rest, lineLayout: 'compact', showSeparators: layout === 'separators' };
459 }
460 if (isPlainObject(layout)) {
461 const { lineLayout, showSeparators, pathLevels } = layout;
462 return {
463 ...rest,
464 ...(typeof lineLayout === 'string' && { lineLayout }),
465 ...(typeof showSeparators === 'boolean' && { showSeparators }),
466 ...((typeof pathLevels === 'number' || pathLevels === 'full') && { pathLevels }),
467 };
468 }
469 return rest;
470}
471
472export function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
473 const migrated = migrateLegacyLayout(userConfig as Record<string, unknown>);
474 const defaults = structuredClone(DEFAULT_CONFIG) as unknown as Record<string, unknown>;
475 return normalize(defaults, migrated) as unknown as HudConfig;
476}
477
478function hasSafeConfigShape(value: unknown, depth = 0): boolean {
479 if (depth > MAX_CONFIG_NESTING_DEPTH) return false;
480 if (Array.isArray(value)) return value.every(item => hasSafeConfigShape(item, depth + 1));
481 if (!isPlainObject(value)) return true;
482 return Object.entries(value).every(([key, child]) => (
483 !UNSAFE_CONFIG_KEYS.has(key) && hasSafeConfigShape(child, depth + 1)
484 ));
485}
486
487// Sections merge key by key; arrays and scalars replace the base value.
488function mergeOverrides(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
489 const result = Object.assign(Object.create(null), base) as Record<string, unknown>;
490 for (const [key, value] of Object.entries(override)) {
491 const current = result[key];
492 result[key] = isPlainObject(current) && isPlainObject(value) ? mergeOverrides(current, value) : value;
493 }
494 return result;
495}
496
497// Checks and reads through one descriptor so a swapped or growing file can't slip past either guard.
498function readConfigFile(configPath: string): Record<string, unknown> | null {
499 try {
500 // O_NOFOLLOW rejects symlinks on POSIX; it is undefined on Windows.
501 const fd = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
502 try {
503 if (!fs.fstatSync(fd).isFile()) {
504 debug('Ignoring %s: not a regular file', configPath);
505 return null;
506 }
507 // hud mod: one bounded whole read; O_NOFOLLOW becomes an lstat check.
508 if (fs.lstatSync(configPath).isSymbolicLink()) {
509 debug('Ignoring %s: not a regular file', configPath);
510 return null;
511 }
512 const content = fs.readFileSync(fd, 'utf-8');
513 if (new TextEncoder().encode(content).length > MAX_CONFIG_FILE_BYTES) {
514 debug('Ignoring %s: larger than %d bytes', configPath, MAX_CONFIG_FILE_BYTES);
515 return null;
516 }
517 const parsed: unknown = JSON.parse(content);
518 if (!isPlainObject(parsed) || !hasSafeConfigShape(parsed)) {
519 debug('Ignoring %s: not a bounded JSON object without unsafe keys', configPath);
520 return null;
521 }
522 return parsed;
523 } finally {
524 fs.closeSync(fd);
525 }
526 } catch (err) {
527 if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
528 debug('Ignoring %s:', configPath, err instanceof Error ? err.message : err);
529 }
530 return null;
531 }
532}
533
534// The mod's own options laid over the loaded config (the mod, not upstream).
535let configPatch: (config: HudConfig) => HudConfig = (config) => config;
536
537export function setConfigPatch(patch: (config: HudConfig) => HudConfig): void {
538 configPatch = patch;
539}
540
541export async function loadConfig(): Promise<HudConfig> {
542 const base = readConfigFile(getConfigPath()) ?? {};
543 const override = readConfigFile(getConfigOverridePath());
544 return configPatch(mergeConfig((override ? mergeOverrides(base, override) : base) as Partial<HudConfig>));
545}
546hooks/hud/i18n/index.ts 77 lines1import type { Language, MessageKey, Messages } from "./types.js";
2import { en } from "./en.js";
3import { zhHans } from "./zh-Hans.js";
4import { zhHant } from "./zh-Hant.js";
5import { ja } from "./ja.js";
6import { ko } from "./ko.js";
7import { es } from "./es.js";
8import { fr } from "./fr.js";
9import { de } from "./de.js";
10import { ptBR } from "./pt-BR.js";
11import { ru } from "./ru.js";
12
13export type { Language, MessageKey, Messages };
14
15export type CanonicalLanguage = "en" | "zh-Hans" | "zh-Hant" | "ja" | "ko" | "es" | "fr" | "de" | "pt-BR" | "ru";
16
17const locales: Record<CanonicalLanguage, Messages> = {
18 en,
19 "zh-Hans": zhHans,
20 "zh-Hant": zhHant,
21 ja,
22 ko,
23 es,
24 fr,
25 de,
26 "pt-BR": ptBR,
27 ru,
28};
29
30// Resolve short language tags to canonical BCP 47 forms.
31// Based on CLDR likely subtags: zh → zh-Hans-CN
32// https://www.unicode.org/cldr/charts/latest/supplemental/likely_subtags.html
33const CANONICAL: Record<Language, CanonicalLanguage> = {
34 "en": "en",
35 "zh": "zh-Hans",
36 "zh-Hans": "zh-Hans",
37 "zh-Hant": "zh-Hant",
38 "zh-TW": "zh-Hant",
39 "ja": "ja",
40 "ko": "ko",
41 "es": "es",
42 "fr": "fr",
43 "de": "de",
44 "pt": "pt-BR",
45 "pt-BR": "pt-BR",
46 "ru": "ru",
47};
48
49let currentLanguage: Language = "en";
50
51export function setLanguage(lang: Language): void {
52 currentLanguage = lang;
53}
54
55// https://www.rfc-editor.org/info/bcp47
56export function getCanonicalLanguage(): CanonicalLanguage {
57 return CANONICAL[currentLanguage] ?? "en";
58}
59
60// https://www.unicode.org/reports/tr11/
61export function isCjkLanguage(): boolean {
62 const canon = getCanonicalLanguage();
63 return canon === "zh-Hans" || canon === "zh-Hant" || canon === "ja" || canon === "ko";
64}
65
66export function t(key: MessageKey): string {
67 const canon = getCanonicalLanguage();
68 return locales[canon]?.[key] ?? locales.en[key] ?? key;
69}
70
71// Minimal named-placeholder interpolation. Layout that varies by language
72// (spacing, affix position) lives in each locale's pattern string rather than in
73// render code. Unknown placeholders render as empty string (kept lenient).
74export function interpolate(pattern: string, params: Record<string, string | number>): string {
75 return pattern.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ""));
76}
77hooks/hud/transcript.ts 386 lines1import * as fs from '../shims/fs.js';
2import * as readline from '../shims/readline.js';
3import { createDebug } from './debug.js';
4import type { AgentEntry, SessionTokenUsage, TodoItem, ToolEntry, TranscriptData } from './types.js';
5import { sanitizeDisplayText } from './utils/sanitize.js';
6import { sanitizeTranscriptModel } from './model-source.js';
7
8const debug = createDebug('transcript');
9
10const TOOLS_KEPT = 20;
11const AGENTS_KEPT = 10;
12const NAME_MAX_LEN = 64;
13const ADVISOR_MODEL_MAX_LEN = 64;
14const MESSAGE_ID_MAX_LEN = 128;
15const MESSAGE_IDS_MAX = 4096;
16const MCP_ERRORS_MAX = 64;
17const MCP_TOOL = /^mcp__(.+?)__(.+)$/;
18// Claude Code's /effort output; anchored so prose quoting it can't flip ultracode.
19const EFFORT_COMMAND = /^<local-command-stdout>Set effort level to (\w+)/;
20
21interface Usage {
22 input_tokens?: unknown;
23 output_tokens?: unknown;
24 cache_creation_input_tokens?: unknown;
25 cache_read_input_tokens?: unknown;
26}
27
28interface Block {
29 type?: string;
30 id?: string;
31 name?: string;
32 input?: Record<string, unknown>;
33 tool_use_id?: string;
34 is_error?: boolean;
35}
36
37interface Entry {
38 type?: string;
39 subtype?: string;
40 operation?: string;
41 content?: unknown;
42 timestamp?: string;
43 isSidechain?: boolean;
44 advisorModel?: unknown;
45 message?: { id?: unknown; model?: unknown; content?: Block[] | string; usage?: Usage };
46 toolUseResult?: { resolvedModel?: unknown; isAsync?: unknown; status?: unknown };
47 compactMetadata?: { postTokens?: unknown };
48 attachment?: { type?: string };
49}
50
51const emptyTranscript = (): TranscriptData => ({ tools: [], skills: [], mcpServers: [], mcpErrors: [], agents: [], todos: [] });
52
53const ZERO_USAGE: SessionTokenUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
54const USAGE_FIELDS = Object.keys(ZERO_USAGE) as (keyof SessionTokenUsage)[];
55
56const count = (value: unknown): number =>
57 typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
58
59function addUsage(total: SessionTokenUsage, usage: SessionTokenUsage): void {
60 for (const field of USAGE_FIELDS) total[field] += usage[field];
61}
62
63function name(value: unknown): string | undefined {
64 if (typeof value !== 'string') return undefined;
65 const text = sanitizeDisplayText(value).trim();
66 if (!text) return undefined;
67 return text.length <= NAME_MAX_LEN ? text : `${text.slice(0, NAME_MAX_LEN - 1)}…`;
68}
69
70const mcpServer = (toolName: string): string | undefined => name(MCP_TOOL.exec(toolName)?.[1]);
71
72// Tool inputs are written by the model, so their text is untrusted terminal input.
73const text = (value: unknown): string | undefined =>
74 typeof value === 'string' ? sanitizeDisplayText(value) || undefined : undefined;
75
76function toolTarget(toolName: string, input: Record<string, unknown> | undefined): string | undefined {
77 if (!input) return undefined;
78 switch (toolName) {
79 case 'Read':
80 case 'Write':
81 case 'Edit':
82 return text(input.file_path ?? input.path);
83 case 'Glob':
84 case 'Grep':
85 return text(input.pattern);
86 case 'Skill':
87 return name(input.skill);
88 case 'Bash': {
89 const command = text(input.command)?.replace(/\s+/g, ' ').trim();
90 if (!command) return undefined;
91 return command.length > 30 ? `${command.slice(0, 30).trimEnd()}...` : command;
92 }
93 }
94 return undefined;
95}
96
97function taskStatus(status: unknown): TodoItem['status'] | null {
98 switch (status) {
99 case 'pending':
100 case 'not_started':
101 return 'pending';
102 case 'in_progress':
103 case 'running':
104 return 'in_progress';
105 case 'completed':
106 case 'complete':
107 case 'done':
108 return 'completed';
109 default:
110 return null;
111 }
112}
113
114function toTodo(value: unknown): TodoItem[] {
115 const todo = value as { content?: unknown; status?: unknown } | null;
116 const content = text(todo?.content);
117 const status = taskStatus(todo?.status);
118 return content && status ? [{ content, status }] : [];
119}
120
121export class Parser {
122 private tools = new Map<string, ToolEntry>();
123 private agents = new Map<string, AgentEntry>();
124 private skills = new Set<string>();
125 private mcpServers = new Set<string>();
126 private mcpErrors = new Set<string>();
127 private todos: TodoItem[] = [];
128 private taskIndex = new Map<string, number>();
129 private agentCompletions = new Map<string, Date>();
130 // Claude Code logs one API response several times, sometimes non-adjacently, so usage
131 // is the per-field max per message id. Ids evicted to bound memory settle into `settled`.
132 private usageById = new Map<string, SessionTokenUsage>();
133 private settled: SessionTokenUsage = { ...ZERO_USAGE };
134 private lastIdlessUsage: string | undefined;
135 private data: TranscriptData = { ...emptyTranscript(), compactionCount: 0 };
136
137 line(raw: string): void {
138 let entry: Entry | null = null;
139 try {
140 entry = raw.trim() ? JSON.parse(raw) : null;
141 } catch {
142 // Malformed lines are skipped.
143 }
144 if (!entry || typeof entry !== 'object') {
145 this.lastIdlessUsage = undefined;
146 return;
147 }
148
149 const time = entry.timestamp ? new Date(entry.timestamp) : null;
150 const at = time && !Number.isNaN(time.getTime()) ? time : null;
151 if (at && !this.data.sessionStart) this.data.sessionStart = at;
152
153 if (entry.type === 'assistant') {
154 this.assistant(entry, at);
155 } else {
156 this.lastIdlessUsage = undefined;
157 }
158 if (entry.type === 'user' && typeof entry.message?.content === 'string') {
159 const effort = EFFORT_COMMAND.exec(entry.message.content);
160 if (effort) this.data.ultracodeActive = effort[1].toLowerCase() === 'ultracode';
161 }
162 if (entry.type === 'attachment') {
163 if (entry.attachment?.type === 'ultra_effort_enter') this.data.ultracodeActive = true;
164 if (entry.attachment?.type === 'ultra_effort_exit') this.data.ultracodeActive = false;
165 }
166 if (entry.type === 'system' && entry.subtype === 'compact_boundary' && at) {
167 this.data.compactionCount = (this.data.compactionCount ?? 0) + 1;
168 const post = entry.compactMetadata?.postTokens;
169 this.data.contextTokens = typeof post === 'number' && Number.isFinite(post) && post >= 0 ? Math.trunc(post) : undefined;
170 }
171 // A background agent's tool_result lands at launch; its completion is this enqueue.
172 if (entry.type === 'queue-operation' && entry.operation === 'enqueue' && typeof entry.content === 'string' && at) {
173 const toolUseId = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(entry.content)?.[1];
174 if (toolUseId && /<task-id>[^<]+<\/task-id>/.test(entry.content)) this.agentCompletions.set(toolUseId, at);
175 }
176
177 if (Array.isArray(entry.message?.content)) {
178 for (const block of entry.message.content) {
179 if (block?.type === 'tool_use' && block.id && block.name) this.toolUse(block, at ?? new Date());
180 if (block?.type === 'tool_result' && block.tool_use_id) this.toolResult(block, entry, at ?? new Date());
181 }
182 }
183 }
184
185 private assistant(entry: Entry, at: Date | null): void {
186 if (at) this.data.lastAssistantResponseAt = at;
187 if (typeof entry.advisorModel === 'string' && entry.advisorModel) {
188 this.data.advisorModel = entry.advisorModel.slice(0, ADVISOR_MODEL_MAX_LEN);
189 }
190 const model = sanitizeTranscriptModel(entry.message?.model);
191 // Claude Code writes '<synthetic>' on assistant records it generates locally.
192 if (model && model !== '<synthetic>') this.data.lastAssistantModel = model;
193
194 const raw = entry.message?.usage;
195 if (!raw) {
196 this.lastIdlessUsage = undefined;
197 return;
198 }
199 const usage: SessionTokenUsage = {
200 inputTokens: count(raw.input_tokens),
201 outputTokens: count(raw.output_tokens),
202 cacheCreationTokens: count(raw.cache_creation_input_tokens),
203 cacheReadTokens: count(raw.cache_read_input_tokens),
204 };
205 if (entry.isSidechain !== true) {
206 this.data.contextTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens;
207 }
208
209 const id = entry.message?.id;
210 if (typeof id === 'string' && id && id.length <= MESSAGE_ID_MAX_LEN) {
211 this.lastIdlessUsage = undefined;
212 const previous = this.usageById.get(id);
213 this.usageById.set(id, previous ? maxUsage(previous, usage) : usage);
214 if (this.usageById.size > MESSAGE_IDS_MAX) {
215 const [oldestId, oldest] = this.usageById.entries().next().value as [string, SessionTokenUsage];
216 this.usageById.delete(oldestId);
217 addUsage(this.settled, oldest);
218 }
219 return;
220 }
221 // Without an id, only an identical record right after the previous one is a duplicate.
222 const fingerprint = JSON.stringify(usage);
223 if (fingerprint !== this.lastIdlessUsage) addUsage(this.settled, usage);
224 this.lastIdlessUsage = fingerprint;
225 }
226
227 private toolUse(block: Block, at: Date): void {
228 const toolName = block.name as string;
229 const input = block.input;
230 const skill = toolName === 'Skill' ? name(input?.skill) : undefined;
231 if (skill) this.skills.add(skill);
232 const server = mcpServer(toolName);
233 if (server) this.mcpServers.add(server);
234
235 if (toolName === 'Task' || toolName === 'Agent') {
236 this.agents.set(block.id as string, {
237 id: block.id as string,
238 type: (input?.subagent_type as string) ?? 'agent',
239 model: sanitizeTranscriptModel(input?.model),
240 description: (input?.description as string) ?? undefined,
241 status: 'running',
242 startTime: at,
243 background: input?.run_in_background === true,
244 });
245 } else if (toolName === 'TodoWrite') {
246 if (Array.isArray(input?.todos)) this.replaceTodos(input.todos.flatMap(toTodo));
247 } else if (toolName === 'TaskCreate') {
248 const content = text(input?.subject) ?? text(input?.description);
249 this.todos.push({ content: content ?? 'Untitled task', status: taskStatus(input?.status) ?? 'pending' });
250 const taskId = typeof input?.taskId === 'string' || typeof input?.taskId === 'number' ? String(input.taskId) : block.id;
251 if (taskId) this.taskIndex.set(taskId, this.todos.length - 1);
252 } else if (toolName === 'TaskUpdate') {
253 const todo = this.todos[this.findTask(input?.taskId) ?? -1];
254 if (!todo) return;
255 const status = taskStatus(input?.status);
256 if (status) todo.status = status;
257 const content = text(input?.subject) ?? text(input?.description);
258 if (content) todo.content = content;
259 } else {
260 this.tools.set(block.id as string, {
261 id: block.id as string,
262 name: toolName,
263 target: toolTarget(toolName, input),
264 status: 'running',
265 startTime: at,
266 });
267 }
268 }
269
270 private toolResult(block: Block, entry: Entry, at: Date): void {
271 const id = block.tool_use_id as string;
272 const tool = this.tools.get(id);
273 if (tool) {
274 tool.status = block.is_error ? 'error' : 'completed';
275 tool.endTime = at;
276 const server = mcpServer(tool.name);
277 if (server && block.is_error) {
278 this.mcpErrors.add(server);
279 if (this.mcpErrors.size > MCP_ERRORS_MAX) this.mcpErrors.delete(this.mcpErrors.values().next().value as string);
280 } else if (server) {
281 this.mcpErrors.delete(server);
282 }
283 }
284
285 const agent = this.agents.get(id);
286 if (agent) {
287 // resolvedModel is what the subagent actually ran on, so it beats the caller's alias.
288 agent.model = sanitizeTranscriptModel(entry.toolUseResult?.resolvedModel) ?? agent.model;
289 if (entry.toolUseResult?.isAsync === true || entry.toolUseResult?.status === 'async_launched') {
290 agent.background = true;
291 }
292 if (!agent.background) agent.endTime = at;
293 }
294 }
295
296 // TodoWrite replaces the list; TaskCreate ids follow their todo by content, in order,
297 // so duplicate-content todos each keep their own id.
298 private replaceTodos(next: TodoItem[]): void {
299 const idsByContent = new Map<string, string[]>();
300 for (const [taskId, index] of [...this.taskIndex].sort((a, b) => a[1] - b[1])) {
301 const content = this.todos[index]?.content;
302 if (content === undefined) continue;
303 idsByContent.set(content, [...(idsByContent.get(content) ?? []), taskId]);
304 }
305 this.todos = [...next];
306 this.taskIndex.clear();
307 this.todos.forEach((todo, index) => {
308 const taskId = idsByContent.get(todo.content)?.shift();
309 if (taskId) this.taskIndex.set(taskId, index);
310 });
311 }
312
313 // TaskUpdate names a task by the id TaskCreate returned, or by its 1-based position.
314 private findTask(taskId: unknown): number | null {
315 if (typeof taskId !== 'string' && typeof taskId !== 'number') return null;
316 const key = String(taskId);
317 const mapped = this.taskIndex.get(key);
318 if (mapped !== undefined) return mapped;
319 const position = /^\d+$/.test(key) ? Number(key) - 1 : -1;
320 return position >= 0 && position < this.todos.length ? position : null;
321 }
322
323 finish(): TranscriptData {
324 for (const [id, endTime] of this.agentCompletions) {
325 const agent = this.agents.get(id);
326 if (agent?.background) agent.endTime = endTime;
327 }
328 for (const agent of this.agents.values()) {
329 if (agent.endTime) agent.status = 'completed';
330 }
331 const sessionTokens = { ...this.settled };
332 for (const usage of this.usageById.values()) addUsage(sessionTokens, usage);
333 return {
334 ...this.data,
335 tools: [...this.tools.values()].slice(-TOOLS_KEPT),
336 agents: [...this.agents.values()].slice(-AGENTS_KEPT),
337 skills: [...this.skills],
338 mcpServers: [...this.mcpServers],
339 mcpErrors: [...this.mcpErrors],
340 todos: this.todos,
341 sessionTokens,
342 };
343 }
344}
345
346function maxUsage(a: SessionTokenUsage, b: SessionTokenUsage): SessionTokenUsage {
347 return {
348 inputTokens: Math.max(a.inputTokens, b.inputTokens),
349 outputTokens: Math.max(a.outputTokens, b.outputTokens),
350 cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens),
351 cacheReadTokens: Math.max(a.cacheReadTokens, b.cacheReadTokens),
352 };
353}
354
355// hud mod: the mod parses the transcript incrementally (only appended lines,
356// one Parser kept per transcript) and answers here; the stream read below
357// stays as upstream wrote it for when no provider is set.
358let transcriptProvider: ((transcriptPath: string) => Promise<TranscriptData | null>) | null = null;
359
360export function setTranscriptProvider(provider: (transcriptPath: string) => Promise<TranscriptData | null>): void {
361 transcriptProvider = provider;
362}
363
364export async function parseTranscript(transcriptPath: string): Promise<TranscriptData> {
365 if (transcriptProvider) {
366 const provided = await transcriptProvider(transcriptPath);
367 if (provided) return provided;
368 }
369 try {
370 if (!transcriptPath || !fs.statSync(transcriptPath).isFile()) return emptyTranscript();
371 } catch {
372 return emptyTranscript();
373 }
374 const parser = new Parser();
375 try {
376 const input = fs.createReadStream(transcriptPath);
377 for await (const raw of readline.createInterface({ input, crlfDelay: Infinity })) {
378 parser.line(raw);
379 }
380 } catch (err) {
381 // A read cut short still renders what was parsed.
382 debug('Transcript read failed:', err instanceof Error ? err.message : err);
383 }
384 return parser.finish();
385}
386hooks/hud/types.ts 203 lines1import type { HudConfig } from './config.js';
2import type { GitRepoIdentity, GitStatus } from './git.js';
3import type { AuthInfo } from './auth.js';
4import type { CostTotals } from './daily-cost.js';
5
6// The statusline payload Claude Code writes to stdin (code.claude.com/docs/en/statusline).
7export interface StdinData {
8 session_id?: string;
9 session_name?: string;
10 version?: string;
11 transcript_path?: string;
12 cwd?: string;
13 workspace?: {
14 current_dir?: string;
15 project_dir?: string;
16 added_dirs?: string[];
17 git_worktree?: string;
18 repo?: GitRepoIdentity;
19 } | null;
20 model?: {
21 id?: string;
22 display_name?: string;
23 };
24 output_style?: { name?: string };
25 context_window?: {
26 context_window_size?: number;
27 total_input_tokens?: number | null;
28 total_output_tokens?: number | null;
29 current_usage?: {
30 input_tokens?: number;
31 output_tokens?: number;
32 cache_creation_input_tokens?: number;
33 cache_read_input_tokens?: number;
34 } | null;
35 used_percentage?: number | null;
36 remaining_percentage?: number | null;
37 };
38 cost?: {
39 total_cost_usd?: number | null;
40 total_duration_ms?: number | null;
41 total_api_duration_ms?: number | null;
42 total_lines_added?: number | null;
43 total_lines_removed?: number | null;
44 } | null;
45 rate_limits?: {
46 five_hour?: RateLimitWindow | null;
47 seven_day?: RateLimitWindow | null;
48 spend_limit?: RateLimitWindow | null;
49 } | null;
50 // hud mod: the model-scoped weekly windows (e.g. Fable), as the external snapshot's model_scoped.
51 model_scoped?: ExternalUsageSnapshot['model_scoped'];
52 prompt_cache?: {
53 warm?: boolean;
54 caching_observed?: boolean;
55 ttl?: string;
56 expires_at?: number | null;
57 hit_ratio?: number | null;
58 } | null;
59 effort?: { level?: string } | null;
60 worktree?: { name?: string; path?: string; branch?: string } | null;
61}
62
63interface RateLimitWindow {
64 used_percentage?: number | null;
65 resets_at?: number | null;
66}
67
68export interface ToolEntry {
69 id: string;
70 name: string;
71 target?: string;
72 status: 'running' | 'completed' | 'error';
73 startTime: Date;
74 endTime?: Date;
75}
76
77export interface AgentEntry {
78 id: string;
79 type: string;
80 model?: string;
81 description?: string;
82 status: 'running' | 'completed';
83 startTime: Date;
84 endTime?: Date;
85 background?: boolean;
86}
87
88export interface TodoItem {
89 content: string;
90 status: 'pending' | 'in_progress' | 'completed';
91}
92
93export interface UsageData {
94 fiveHour: number | null; // 0-100 percentage, null if unavailable
95 sevenDay: number | null; // 0-100 percentage, null if unavailable
96 fiveHourResetAt: Date | null;
97 sevenDayResetAt: Date | null;
98 balanceLabel?: string | null; // optional raw balance text (e.g. "¥6.35")
99 // Model-scoped weekly windows (e.g. Fable), from the external usage snapshot.
100 scopedWindows?: ScopedUsageWindow[];
101}
102
103/** One model-scoped weekly quota window (e.g. label "Fable", used percent 0-100). */
104export interface ScopedUsageWindow {
105 label: string;
106 percent: number | null;
107 resetAt: Date | null;
108}
109
110export interface ExternalUsageSnapshot {
111 five_hour?: {
112 used_percentage?: number | null;
113 resets_at?: string | number | null;
114 } | null;
115 seven_day?: {
116 used_percentage?: number | null;
117 resets_at?: string | number | null;
118 } | null;
119 updated_at?: string | number | null;
120 balance_label?: string | null;
121 // Model-scoped weekly windows (e.g. Fable), in the shape of Claude Code's /usage data.
122 model_scoped?: Array<{
123 display_name?: string | null;
124 utilization?: number | null;
125 resets_at?: string | null;
126 }> | null;
127}
128
129export interface MemoryInfo {
130 totalBytes: number;
131 usedBytes: number;
132 freeBytes: number;
133 usedPercent: number;
134}
135
136/** Check if usage limit is reached (either window at 100%) */
137export function isLimitReached(data: UsageData): boolean {
138 return data.fiveHour === 100 || data.sevenDay === 100;
139}
140
141export interface SessionTokenUsage {
142 inputTokens: number;
143 outputTokens: number;
144 cacheCreationTokens: number;
145 cacheReadTokens: number;
146}
147
148export interface TranscriptData {
149 tools: ToolEntry[];
150 skills: string[];
151 mcpServers: string[];
152 /**
153 * MCP servers whose latest observed tool result is an error, derived
154 * from `mcp__<server>__<tool>` results carrying is_error. Distinct from
155 * mcpServers, which is a plain activity list.
156 */
157 mcpErrors: string[];
158 agents: AgentEntry[];
159 todos: TodoItem[];
160 sessionStart?: Date;
161 // Last assistant response of any kind, subagents included. Drives the
162 // last-response element.
163 lastAssistantResponseAt?: Date;
164 sessionTokens?: SessionTokenUsage;
165 // Number of compact_boundary entries (manual /compact or auto compaction)
166 // with a valid timestamp seen in the transcript.
167 compactionCount?: number;
168 // Tokens in the main conversation's context as of its last request, or the
169 // post-compaction size after a compact boundary.
170 contextTokens?: number;
171 // Advisor model ID for the current session, captured from the top-level
172 // `advisorModel` field that Claude Code stamps onto every assistant record
173 // after `/advisor` is set (e.g. "claude-opus-4-7"). undefined when /advisor
174 // is off or no assistant turn has happened yet.
175 advisorModel?: string;
176 // Current ultracode effort state from the most recent transcript signal
177 // (`ultra_effort_enter`/`ultra_effort_exit` attachment or `/effort` output).
178 // undefined when ultracode was never entered this session.
179 ultracodeActive?: boolean;
180 // Model ID from the most recent assistant message's `message.model` field.
181 // This reflects what the API actually served — may differ from stdin.model
182 // when a proxy (e.g. cc-switch) routes to a different model. Transcript
183 // parsing sanitizes terminal controls and caps the retained value at 80 chars.
184 lastAssistantModel?: string;
185}
186
187export interface RenderContext {
188 stdin: StdinData;
189 transcript: TranscriptData;
190 claudeMdCount: number;
191 rulesCount: number;
192 mcpCount: number;
193 hooksCount: number;
194 costTotals: CostTotals | null;
195 outputSpeed: number | null;
196 gitStatus: GitStatus | null;
197 usageData: UsageData | null;
198 memoryUsage: MemoryInfo | null;
199 config: HudConfig;
200 extraLabel: string | null;
201 authInfo?: AuthInfo | null;
202}
203hooks/hud/usage-pace.ts 98 lines1import type { HudConfig } from './config.js';
2import type { ScopedUsageWindow, UsageData } from './types.js';
3
4export type UsagePace = 'normal' | 'warning' | 'critical';
5
6export const FIVE_HOUR_WINDOW_MS = 5 * 60 * 60 * 1000;
7export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
8
9// Below this much usage a linear projection is noise (3% five minutes in
10// "projects" 180%), so pace stays normal.
11const MIN_USED_PERCENT = 10;
12// Amber once the current rate would end the window at 90% of the limit or more.
13const WARNING_PROJECTED_PERCENT = 90;
14// Red once the current rate would exhaust the limit before the window resets.
15const CRITICAL_PROJECTED_PERCENT = 100;
16
17/**
18 * Grades how fast a rate-limit window is being consumed by projecting the
19 * used percentage linearly to the window's reset. Returns null when there is
20 * no usable rate: missing data, a reset already past, or a reset at least a
21 * full window away (no time elapsed).
22 */
23export function getUsagePace(
24 percent: number | null,
25 resetAt: Date | null,
26 windowMs: number,
27 now: number = Date.now(),
28): UsagePace | null {
29 if (percent === null || !resetAt) {
30 return null;
31 }
32
33 const remainingMs = resetAt.getTime() - now;
34 if (!Number.isFinite(remainingMs) || remainingMs <= 0 || remainingMs >= windowMs) {
35 return null;
36 }
37
38 if (percent < MIN_USED_PERCENT) {
39 return 'normal';
40 }
41
42 const elapsedFraction = (windowMs - remainingMs) / windowMs;
43 const projected = percent / elapsedFraction;
44 if (projected > CRITICAL_PROJECTED_PERCENT) {
45 return 'critical';
46 }
47 if (projected >= WARNING_PROJECTED_PERCENT) {
48 return 'warning';
49 }
50 return 'normal';
51}
52
53/** True when pace should draw attention (amber or red). */
54export function isPaceAlert(pace: UsagePace | null): boolean {
55 return pace === 'warning' || pace === 'critical';
56}
57
58/** Pace of every usage window, plus the display rules pace imposes. */
59export interface UsagePaces {
60 fiveHour: UsagePace | null;
61 sevenDay: UsagePace | null;
62 /** Parallel to the scoped windows passed in. */
63 scoped: Array<UsagePace | null>;
64 /** Some window is at amber/red pace: show usage despite `usageThreshold`. */
65 alert: boolean;
66 /** Show the weekly window: at/above `sevenDayThreshold`, or at amber/red pace. */
67 showSevenDay: boolean;
68}
69
70/**
71 * Grades every window's pace (all null unless `display.usagePace` is on) and
72 * resolves the visibility rules both usage renderers share.
73 */
74export function resolveUsagePaces(
75 usage: UsageData,
76 scopedWindows: ScopedUsageWindow[],
77 display: Partial<HudConfig['display']> | undefined,
78 now: number = Date.now(),
79): UsagePaces {
80 const enabled = display?.usagePace === true;
81 const paceOf = (percent: number | null, resetAt: Date | null, windowMs: number): UsagePace | null =>
82 enabled ? getUsagePace(percent, resetAt, windowMs, now) : null;
83
84 const fiveHour = paceOf(usage.fiveHour, usage.fiveHourResetAt, FIVE_HOUR_WINDOW_MS);
85 const sevenDay = paceOf(usage.sevenDay, usage.sevenDayResetAt, SEVEN_DAY_WINDOW_MS);
86 const scoped = scopedWindows.map((w) => paceOf(w.percent, w.resetAt, SEVEN_DAY_WINDOW_MS));
87 const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
88
89 return {
90 fiveHour,
91 sevenDay,
92 scoped,
93 alert: [fiveHour, sevenDay, ...scoped].some(isPaceAlert),
94 showSevenDay: usage.sevenDay !== null
95 && (usage.sevenDay >= sevenDayThreshold || isPaceAlert(sevenDay)),
96 };
97}
98hooks/i18n.ts 595 lines1// The mod's own strings (what it adds to claude-hud's lines), in the language
2// claude-hud is set to (`language` in its config), so one setting covers the HUD.
3import { type CanonicalLanguage, getCanonicalLanguage, interpolate } from './hud/i18n/index.js'
4
5/** A string, or its plural forms by `Intl.PluralRules` category (`other` required). */
6type Text = string | { one?: string; few?: string; many?: string; other: string }
7
8type Key =
9 | 'rc.label'
10 | 'rc.attached'
11 | 'rc.connected'
12 | 'surface.mobile'
13 | 'surface.desktop'
14 | 'forecast'
15 | 'limit.fiveHour'
16 | 'limit.sevenDay'
17 | 'limit.scoped'
18 | 'week'
19 | 'streak'
20 | 'git.dirty'
21 | 'git.ahead'
22 | 'compact.left'
23 | 'cache.cold'
24 | 'turn.growth'
25 | 'alert.context'
26 | 'alert.fiveHour'
27 | 'alert.sevenDay'
28 | 'alert.scoped'
29 | 'turn.done'
30 | 'cmd.description'
31 | 'theme.set'
32 | 'theme.list'
33 | 'theme.unknown'
34 | 'theme.ask'
35 | 'cmd.hidden'
36 | 'cmd.shown'
37 | 'pane.title'
38 | 'pane.opened'
39 | 'pane.closed'
40 | 'pane.tools'
41 | 'pane.noTools'
42 | 'pane.toolStats'
43 | 'pane.failed'
44 | 'pane.agents'
45 | 'pane.none'
46 | 'pane.todos'
47 | 'pane.spend'
48 | 'pane.turns'
49 | 'pane.turnRow'
50 | 'spend.today'
51 | 'spend.week'
52 | 'summary.language'
53
54// French (and German, Spanish, Russian) set a percent sign off with a space; French
55// also a colon. A no-break space keeps the sign on its number.
56const NB = ' '
57
58export const MESSAGES: Record<CanonicalLanguage, Record<Key, Text>> = {
59 en: {
60 'rc.label': '⇄ Remote Control',
61 'rc.attached': 'connected: {who}',
62 'rc.connected': 'connected',
63 'surface.mobile': 'phone',
64 'surface.desktop': 'web/desktop',
65 forecast: '{label} runs out ≈{time} at this pace',
66 'limit.fiveHour': '5-hour limit',
67 'limit.sevenDay': '7-day limit',
68 'limit.scoped': '{name} weekly limit',
69 week: '7d',
70 streak: '{n}-day streak',
71 'git.dirty': { one: '{n} uncommitted change', other: '{n} uncommitted changes' },
72 'git.ahead': { one: '{n} unpushed commit', other: '{n} unpushed commits' },
73 'compact.left': '{tokens} to auto-compact',
74 'cache.cold': 'cache cold: next message re-caches {tokens}',
75 'turn.growth': 'last turn +{tokens}',
76 'alert.context': 'Context is {p}% full — consider /compact',
77 'alert.fiveHour': '5-hour limit at {p}%',
78 'alert.sevenDay': '7-day limit at {p}%',
79 'alert.scoped': '{name} weekly limit at {p}%',
80 'turn.done': '✓ Done in {d}',
81 'cmd.description': 'Show or hide the claude-hud band; detail opens the details pane; theme switches its look',
82 'theme.set': 'HUD theme: {name}',
83 'theme.list': 'HUD themes (now {name}): {list}. /synapse 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 (/synapse 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}。/synapse 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 详情面板已打开(/synapse 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}。/synapse 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 詳細資訊面板已開啟(/synapse 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}。/synapse 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 詳細パネルを開きました(/synapse 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}. /synapse 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 상세 패널을 열었습니다(/synapse 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}. /synapse 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 (/synapse 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}. /synapse 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 (/synapse 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}. /synapse 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 (/synapse 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}. /synapse 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 (/synapse 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}. /synapse 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 открыта (/synapse detail закрывает её)',
514 'pane.closed': 'Панель подробностей HUD закрыта',
515 'pane.tools': 'Время по инструментам',
516 'pane.noTools': 'В этой сессии ещё не было вызовов инструментов',
517 'pane.toolStats': '×{count} · всего {total} · в среднем {avg}',
518 'pane.failed': 'ошибок: {n}',
519 'pane.agents': 'Субагенты',
520 'pane.none': 'Нет',
521 'pane.todos': 'Задачи',
522 'pane.spend': 'Расходы',
523 'pane.turns': 'Последние ходы',
524 'pane.turnRow': '#{n} · {time} · {cost} · контекст {tokens}',
525 'spend.today': 'Сегодня: {spent}',
526 'spend.week': '7 дн.: {spent}',
527 'summary.language': 'Russian',
528 },
529}
530
531// The BCP 47 tag `Intl` formats and pluralizes with, per language.
532const INTL_TAG: Record<CanonicalLanguage, string> = {
533 en: 'en-US',
534 'zh-Hans': 'zh-CN',
535 'zh-Hant': 'zh-TW',
536 ja: 'ja-JP',
537 ko: 'ko-KR',
538 es: 'es',
539 fr: 'fr-FR',
540 de: 'de-DE',
541 'pt-BR': 'pt-BR',
542 ru: 'ru-RU',
543}
544
545/** The language the HUD draws in: claude-hud's, as its last config load set it. */
546export function language(): CanonicalLanguage {
547 return getCanonicalLanguage()
548}
549
550/** Whether the language sets text in CJK characters (two columns each). */
551export function isCjk(lang: CanonicalLanguage = language()): boolean {
552 return lang === 'zh-Hans' || lang === 'zh-Hant' || lang === 'ja' || lang === 'ko'
553}
554
555function pluralCategory(lang: CanonicalLanguage, n: number): string {
556 try {
557 return new Intl.PluralRules(INTL_TAG[lang]).select(n)
558 } catch {
559 return n === 1 ? 'one' : 'other'
560 }
561}
562
563/** The message `key`, its `{placeholders}` filled; `params.n` picks a plural form. */
564export function m(key: Key, params: Record<string, string | number> = {}, lang: CanonicalLanguage = language()): string {
565 const text = MESSAGES[lang][key] ?? MESSAGES.en[key]
566 const pattern =
567 typeof text === 'string'
568 ? text
569 : ((text as Record<string, string | undefined>)[typeof params.n === 'number' ? pluralCategory(lang, params.n) : 'other'] ??
570 text.other)
571 return interpolate(pattern, params)
572}
573
574/** "$3.20", "3,20 $", "US$ 3,20": a USD amount as the language writes it. */
575export function money(usd: number, lang: CanonicalLanguage = language()): string {
576 try {
577 return new Intl.NumberFormat(INTL_TAG[lang], {
578 style: 'currency',
579 currency: 'USD',
580 currencyDisplay: 'narrowSymbol',
581 }).format(usd)
582 } catch {
583 return `$${usd.toFixed(2)}`
584 }
585}
586
587/** The one-line task summary's prompt: the language by name and a length to keep to. */
588export function summaryPrompt(lang: CanonicalLanguage = language()): string {
589 const budget = isCjk(lang) ? 'at most 25 characters' : 'at most 8 words'
590 return (
591 `In ${m('summary.language', {}, lang)}, state in one line (${budget}) the task this session is working on right now. ` +
592 'Output only that line: no quotes, no prefix, no closing punctuation.'
593 )
594}
595hooks/language.ts 27 lines1// O idioma do HUD: a opção `language` do mod (`auto`, `en`, `pt-BR`) e, em `auto`,
2// o idioma do próprio Claude Code (`language` no settings.json), com o `language`
3// do claude-hud como último recurso.
4import type { Language } from './hud/config.js'
5
6export const LANGUAGE_OPTIONS = ['auto', 'en', 'pt-BR'] as const
7export type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]
8
9/** O idioma do HUD que um `language` livre do Claude Code ("Portugues", "pt-BR", "English") nomeia; null se não for um dos suportados. */
10export function fromClaudeCode(value: unknown): 'en' | 'pt-BR' | null {
11 if (typeof value !== 'string') return null
12 const v = value
13 .trim()
14 .toLowerCase()
15 .normalize('NFD')
16 .replace(/[̀-ͯ]/g, '')
17 if (/^pt\b|portugu|brasil|brazil/.test(v)) return 'pt-BR'
18 if (/^en\b|english|ingles/.test(v)) return 'en'
19 return null
20}
21
22/** A opção do mod; em `auto`, o idioma do Claude Code; senão o que o claude-hud já tinha. */
23export function resolveLanguage(option: LanguageOption, claudeCode: unknown, fallback: Language): Language {
24 if (option !== 'auto') return option
25 return fromClaudeCode(claudeCode) ?? fallback
26}
27