SLOPSHOPPER

synapse-rate-limit

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

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

synapse-rate-limit: a Claude Code HUD with weather and context size in MB

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.

synapse-rate-limit, neon theme

Do not use it together with the original hud plugin: both bars would show at once. Disable hud.

The synapse line (what this plugin adds)

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)
PartMeaning
☂ RainWeather (the "forecast"): ☀ Clear below 25%, ☁ Cloudy from 25%, ☂ Rain from 50%, ↯ Storm from 75% and ! Compact soon from 90%
12 MBSize 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 / 1mTokens used / context window
▁▂▃…Chart of the last 12 turns
  • The weather uses the larger of the token percentage and the MB percentage, so it can change because of attachments even with few tokens.
  • The context percentage is not repeated here, as the HUD's context line already shows it. Neither is the last turn's growth, which the HUD's extras row shows (turnGrowthTokens).
  • The size in MB is recomputed at the end of each turn.
  • With the HUD above the prompt, a blank line separates the bar from the chat.
  • The enabled option turns only this line on and off. It follows the HUD's language (see Language): Portuguese for pt-BR and English otherwise.

What comes from the HUD

  • Everything claude-hud shows: model and effort, project and git branch with its changes, context and usage gauges, the running tools, subagents and todos.
  • Warnings before you hit a wall: toasts at the context and quota levels you pick, when a limit runs out at the current pace, per-model weekly limits such as Fable's, the tokens left before auto-compaction, what the next message re-caches once the prompt cache has expired, a turn that grew the context a lot (by how much, beside the recent turns), and too many uncommitted changes or unpushed commits.
  • Spend: today's spend against a daily budget, and the last 7 days as a sparkline.
  • A one-line task summary, and /synapse detail for per-tool times, the last turns' cost and context growth, subagents and todos.
  • Twelve themes: neon, rainbow, emoji, anime themes with a kaomoji mascot (sakura, kawaii, mecha, shonen), Tokyo Night, Matrix, Nerd Font and powerline.
  • Turn-done toast (with an optional chime on macOS) for long turns, and the Remote Control state with the clients attached.

Install

/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.

Commands

CommandEffect
/synapseShows or hides the bar
/synapse on / /synapse offShows / hides it explicitly
/synapse detailOpens 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 themeAsks which theme
/synapse theme <name> / next / resetSwitches 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.

Configuration

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):

OptionDefaultDescription
languageautoThe HUD's language: auto (Claude Code's), en or pt-BR (see Language)
enabledtrueThe synapse line (weather, MB, tokens, chart)
visibletrueShow the HUD. /synapse, /synapse on, /synapse off and the footer button change the option, which is kept across sessions
footerButtontrueThe HUD button in the prompt footer
positionaboveabove (a band above the prompt) or below (beside the hint line, where the statusline sat); the desktop app always draws above
themeclassicTheme (see below)
showMascottrueThe anime themes' mascot
extraCmdemptyclaude-hud's --extra-cmd: a shell command whose output becomes a label (needs CLAUDE_HUD_ALLOW_EXTRA_CMD=1)
debugfalseRegisters the mcp__synapse-rate-limit__synapse_debug tool
notifyAfterSeconds0Toast when a turn runs at least this long; 0 turns it off
notifySoundtrueA chime with the turn-done toast (macOS)
contextAlertsemptyContext percentages that raise a toast, e.g. 80,90
usageAlertsemptyPercentages of the 5-hour, 7-day or model-scoped weekly limits that raise a toast
showForecasttrueForecast of when a usage limit runs out
dailyBudgetUsd0Daily budget in USD; 0 turns it off
showHistoryfalseThe last 7 days' spend as a sparkline, with the streak of days in use
summaryEveryTurns5Summarize the task every N turns; 0 turns it off
compactWarnPercent60Show the tokens left before auto-compaction from this percent; 0 turns it off
coldCacheTokens20000Expired-cache warning from this context size; 0 turns it off
turnGrowthTokens20000Show the context growth when a turn grows it by at least this much; 0 turns it off
gitDirtyWarn20Warn on this many changed, uncommitted paths; 0 turns it off
gitAheadWarn5Warn on this many unpushed commits; 0 turns it off
showAgentsfalseSubagent lines (Claude Code already lists running subagents, with their time and tokens; the detail pane still lists them)

Language

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:

ValueEffect
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
enEnglish
pt-BRBrazilian Portuguese
  • settings.json is read again on each refresh of the bar, so changing Claude Code's language changes the HUD without a restart.
  • As the last resort under 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.
  • The options' names and descriptions (in /config) and the /synapse command's description stay fixed: a plugin's manifest is not translated.

Themes

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.

ThemeLook
classicclaude-hud as it ships
neoncyberpunk: neon truecolor, ⬢ ◆ ◈ ⚡, ▰▱ bars, ❯ separators
rainbowa hue per element, filled bar cells and the model name along a rainbow gradient
emoji🤖 📂 🌿 🧠 ⚡ 📅 ⏳ ✅
sakurapastel pink, 🌸 🎀 🍡 💗, ✿ bars, a kaomoji mascot (◕‿◕)♡
kawaiipastel, 「Opus」, ●○ bars, a cat mascot ฅ^•ω•^ฅ
mechapurple, green and orange, UNIT·Opus◤, SYNC / PWR gauges, a robot mascot [•_•]
shonenred-orange-gold, 🔥 ⭐ 🍥 💥, gradient bars, a mascot (ง •̀_•́)ง
tokyo-nightthe Tokyo Night palette, quiet glyphs
matrixgreen on black, ▮▯ bars, ┊ separators
nerdNerd Font symbols (needs a Nerd Font)
powerlineNerd 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.

  • Palette: the theme's colors go over claude-hud's colors; a color set in claude-hud's own config (off its default) stays.
  • Mascot (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.
  • Width: glyphs are drawn by claude-hud, so its wrapping measures them; separators are no wider than │ ; powerline adds 2 cells to a row. Emoji are default-presentation ones only (no U+FE0F).
  • Known limit: claude-hud keeps a [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 | .

Derived rather than reported

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.
  • Before the session's first model request, 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.

Added by the HUD over claude-hud

  • Remote Control: │ ⇄ 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.
  • An extras row: appended to claude-hud's last line when both fit the width, else a line of its own under it; parts that do not fit leave it, a theme's mascot first, then the 7-day sparkline, and the ⚠ 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.
  • Usage forecast (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.
  • Tokens left before auto-compaction (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.
  • Expired prompt cache (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).
  • Context growth (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.
  • Today's spend across sessions against dailyBudgetUsd (0 off), from claude-hud's daily-cost ledger; yellow from 80%, red past it.
  • The last 7 days' spend as a sparkline and the streak of days in use (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.
  • Alerts (off by default): a toast when context use reaches each of 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).
  • Turn done: a turn of the main thread that ran notifyAfterSeconds or longer (default 0, off; e.g. 60) ends with a toast and, with notifySound, a short chime (macOS).
  • Subagent lines: off by default (showAgents).
  • Prompt redraws: right after a compaction, and after /model (showing the new model before its first step).
  • Display tweaks over claude-hud: the │ 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 ⏱️.

Not carried over

  • OSC 8 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.

Differences from the original hud

  • The /synapse command instead of /hud; the debug tool is synapse_debug.
  • State and config keys live under the synapse-rate-limit name, so they do not clash with hud.
  • The extra synapse line (weather, MB, tokens and chart) and the enabled option.
  • The language option (auto, en, pt-BR), which follows Claude Code's language.
  • A blank line between the chat and the bar, when it sits above the prompt.
  • Caches stay in plugins/claude-hud-mod, the same directory as the original hud, so the two share the daily-cost ledger.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir /path/to/synapse-rate-limit

Layout

  • 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.

Updating from upstream

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 ..

License

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).

Source 88 files
hooks/register.tsx 731 lines
1// claude-hud as a mod. claude-hud's own source (hooks/hud, MIT, see
2// LICENSE.claude-hud) renders the lines; this module wires the engine's
3// events to it and draws its output above or below the prompt. The work sits
4// beside it: stdin.ts builds the statusline's stdin, render.ts runs a pass,
5// remote.ts follows Remote Control, draw.tsx draws. Whatever calls `$` stays
6// in this file (the engine follows `$` only within the hooks module's own
7// file); the other modules get closures over it (`Io`, `SessionApi`).
8import './shims/globals.js'
9
10import { atom, 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}
731
hooks/shims/globals.ts 54 lines
1// The two Node globals claude-hud reaches for: `process` and `Buffer`.
2// Filled from the session at start (see register.tsx).
3export const processShim = {
4  env: {} as Record<string, string | undefined>,
5  platform: 'darwin',
6  pid: 0,
7  argv: ['node', 'claude-hud'] as string[],
8  execPath: '',
9  stdout: { columns: undefined as number | undefined, isTTY: false },
10  stderr: { columns: undefined as number | undefined, isTTY: false },
11  stdin: undefined,
12  cwdPath: '/',
13  cwd(): string {
14    return processShim.cwdPath
15  },
16}
17
18type ByteString = Uint8Array & { toString(encoding?: string): string }
19
20function withText(bytes: Uint8Array): ByteString {
21  const out = bytes as ByteString
22  out.toString = () => new TextDecoder().decode(bytes)
23  return out
24}
25
26const BufferShim = {
27  from(value: string | ArrayLike<number>): ByteString {
28    return withText(typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value))
29  },
30  alloc(size: number): ByteString {
31    return withText(new Uint8Array(size))
32  },
33  byteLength(value: string): number {
34    return new TextEncoder().encode(value).length
35  },
36  concat(list: Uint8Array[]): ByteString {
37    const out = new Uint8Array(list.reduce((n, c) => n + c.length, 0))
38    let at = 0
39    for (const c of list) {
40      out.set(c, at)
41      at += c.length
42    }
43    return withText(out)
44  },
45}
46
47const g = globalThis as Record<string, unknown>
48g.process = processShim
49g.Buffer = BufferShim
50if (typeof g.console === 'undefined') {
51  const quiet = () => {}
52  g.console = { log: quiet, error: quiet, warn: quiet, info: quiet, debug: quiet }
53}
54
hooks/config.ts 73 lines
1// hud's options (plugin.json `userConfig`), read once into a typed config.
2// The HUD's language and layout stay claude-hud's own config file's.
3import type { PluginOptions } from 'claude-code'
4
5import { parseThresholds } from './extras.js'
6import { 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}
73
hooks/draw.tsx 135 lines
1// The HUD's drawings, over the elements a render hook resolved: its rows
2// (above or below the prompt) and the `/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}
135
hooks/extras.ts 359 lines
1// What the mod adds to claude-hud's lines: alerts, a usage forecast, today's
2// spend against a budget, the spend history, a git nag and the task summary.
3// Pure helpers; register.tsx feeds them from `$` and draws the row.
4import type { HudLine, TurnCost } from '../types'
5import { textWidth } from './hud/render/ansi.js'
6import { formatTokens } from './hud/utils/format.js'
7import { m, money } from './i18n.js'
8
9/** "80, 90" → [80, 90]: whole percents in 1-100, ascending; empty turns alerts off. */
10export function parseThresholds(spec: string): number[] {
11  const values = spec
12    .split(/[\s,]+/)
13    .map(s => Number.parseInt(s, 10))
14    .filter(n => Number.isFinite(n) && n >= 1 && n <= 100)
15  return [...new Set(values)].sort((a, b) => a - b)
16}
17
18// A threshold fires again only once the percent has dropped this far below it
19// (a /compact, a window reset), so the tick does not repeat a toast.
20const REARM_BELOW = 5
21
22/**
23 * The thresholds `percent` stands at or past, given those already fired; `alert`
24 * is the highest newly crossed one (null when none). Fired ones the percent
25 * dropped well below are re-armed.
26 */
27export function crossThresholds(
28  percent: number | null | undefined,
29  thresholds: number[],
30  fired: number[],
31): { alert: number | null; fired: number[] } {
32  if (percent === null || percent === undefined || !Number.isFinite(percent)) return { alert: null, fired }
33  const kept = fired.filter(t => percent >= t - REARM_BELOW)
34  const fresh = thresholds.filter(t => percent >= t && !kept.includes(t))
35  return { alert: fresh.length > 0 ? fresh[fresh.length - 1]! : null, fired: [...kept, ...fresh].sort((a, b) => a - b) }
36}
37
38// Below this much usage a linear projection is noise; claude-hud's pace uses the same floor.
39const MIN_USED_PERCENT = 10
40
41/**
42 * When a rate-limit window runs out at the rate used so far (ms), or null when
43 * it lasts until its reset (or the data cannot say).
44 */
45export function exhaustAt(
46  percent: number | null | undefined,
47  resetsAtSec: number | null | undefined,
48  windowMs: number,
49  now: number,
50): number | null {
51  if (percent === null || percent === undefined || !resetsAtSec || percent < MIN_USED_PERCENT) return null
52  if (percent >= 100) return now
53  const resetsAt = resetsAtSec * 1000
54  const elapsed = windowMs - (resetsAt - now)
55  if (elapsed <= 0 || resetsAt <= now) return null
56  const at = now + ((100 - percent) / percent) * elapsed
57  return at < resetsAt ? at : null
58}
59
60/** Readings of one rate-limit window: `[ms, percent]`, oldest first, for the window that resets at `resetsAt`. */
61export type Samples = { resetsAt: number; points: [number, number][] }
62
63/** How far back the recent pace looks, and the least span it needs to mean anything. */
64const PACE_LOOKBACK_MS = 60 * 60_000
65const PACE_MIN_SPAN_MS = 10 * 60_000
66
67/** `samples` with a reading at `now` added: a new window starts over, readings past the lookback leave. */
68export function addSample(samples: Samples | undefined, percent: number, resetsAtSec: number, now: number): Samples {
69  const resetsAt = resetsAtSec * 1000
70  const kept = samples && samples.resetsAt === resetsAt ? samples.points.filter(([t]) => now - t <= PACE_LOOKBACK_MS) : []
71  return { resetsAt, points: [...kept, [now, percent]] }
72}
73
74/**
75 * When a window runs out at the pace of the last hour (ms), or null when that
76 * pace lasts until the reset. With under ten minutes of readings, the pace
77 * since the window began (exhaustAt) stands in.
78 */
79export function paceAt(samples: Samples | undefined, percent: number | null | undefined, resetsAtSec: number | null | undefined, windowMs: number, now: number): number | null {
80  const first = samples?.points[0]
81  if (!samples || !first || !resetsAtSec || samples.resetsAt !== resetsAtSec * 1000 || now - first[0] < PACE_MIN_SPAN_MS) {
82    return exhaustAt(percent, resetsAtSec, windowMs, now)
83  }
84  if (percent === null || percent === undefined || percent < MIN_USED_PERCENT) return null
85  if (percent >= 100) return now
86  const rate = (percent - first[1]) / (now - first[0])
87  if (rate <= 0) return null
88  const at = now + (100 - percent) / rate
89  return at < samples.resetsAt ? at : null
90}
91
92const pad = (n: number) => String(n).padStart(2, '0')
93
94/** "14:05" in the machine's time zone. */
95export function clockTime(ms: number): string {
96  const d = new Date(ms)
97  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
98}
99
100/** "2026-10-02" in the machine's time zone. */
101export function localDay(ms: number): string {
102  const d = new Date(ms)
103  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
104}
105
106/** The day `n` days after `day` (negative: before). */
107export function addDays(day: string, n: number): string {
108  const [y, m, d] = day.split('-').map(Number)
109  return localDay(new Date(y!, m! - 1, d! + n, 12).getTime())
110}
111
112/** "45s", "2m05s", "1h03m". */
113export function formatDuration(ms: number): string {
114  const s = Math.max(0, Math.round(ms / 1000))
115  if (s < 60) return `${s}s`
116  if (s < 3600) return `${Math.floor(s / 60)}m${pad(s % 60)}s`
117  return `${Math.floor(s / 3600)}h${pad(Math.floor((s % 3600) / 60))}m`
118}
119
120const BLOCKS = '▁▂▃▄▅▆▇█'
121
122/** One block per value, scaled to the largest; a zero is the lowest block. */
123export function sparkline(values: number[]): string {
124  const max = Math.max(0, ...values)
125  return values
126    .map(v => (max <= 0 || v <= 0 ? BLOCKS[0] : BLOCKS[Math.min(7, Math.max(1, Math.round((v / max) * 7)))]))
127    .join('')
128}
129
130/** The last turn's context growth and the recent turns', once the last grew it by at least `min` tokens. */
131export function growthOf(log: readonly TurnCost[], min: number): { last: number; recent: number[] } | null {
132  const last = log[log.length - 1]?.tokens ?? null
133  if (min <= 0 || last === null || last < min) return null
134  return { last, recent: log.map(t => Math.max(0, t.tokens ?? 0)) }
135}
136
137/** Spend per day, `YYYY-MM-DD` → USD. */
138export type History = Record<string, number>
139
140/** The last `days` days' spend, oldest first, ending today. */
141export function lastDays(history: History, today: string, days: number): number[] {
142  return Array.from({ length: days }, (_, i) => history[addDays(today, i - days + 1)] ?? 0)
143}
144
145/** Consecutive days with spend, ending today (or yesterday, before today's first spend). */
146export function streak(history: History, today: string): number {
147  let day = (history[today] ?? 0) > 0 ? today : addDays(today, -1)
148  let count = 0
149  while ((history[day] ?? 0) > 0) {
150    count++
151    day = addDays(day, -1)
152  }
153  return count
154}
155
156/** Days older than `keepDays` before today leave the history. */
157export function pruneHistory(history: History, today: string, keepDays: number): History {
158  const oldest = addDays(today, -keepDays)
159  return Object.fromEntries(Object.entries(history).filter(([day]) => day > oldest))
160}
161
162/**
163 * `git status --porcelain=v2 --branch` (lines, or `-z` records) → changed paths
164 * and commits ahead of upstream.
165 */
166export function parseGitStatus(stdout: string): { dirty: number; ahead: number } {
167  const isZ = stdout.includes('\0')
168  const records = stdout.split(isZ ? '\0' : '\n')
169  let dirty = 0
170  let ahead = 0
171  for (let i = 0; i < records.length; i++) {
172    const record = records[i]!
173    if (!record) continue
174    if (record.startsWith('#')) {
175      const ab = /^# branch\.ab \+(\d+) -\d+/.exec(record)
176      if (ab) ahead = Number(ab[1])
177    } else {
178      dirty++
179      // Under -z a rename or copy is followed by its original path, a record of its own.
180      if (isZ && record.startsWith('2 ')) i++
181    }
182  }
183  return { dirty, ahead }
184}
185
186/** "Today $3.20/$10.00 ▓▓▓░░░░░", red once past the budget, yellow from 80%. */
187export function budgetSpans(todayUsd: number, budgetUsd: number, width = 8): HudLine {
188  const ratio = budgetUsd > 0 ? todayUsd / budgetUsd : 0
189  const filled = Math.min(width, Math.round(ratio * width))
190  const color = ratio >= 1 ? 'red' : ratio >= 0.8 ? 'yellow' : 'green'
191  const spans: HudLine = [{ text: `${m('spend.today', { spent: `${money(todayUsd)}/${money(budgetUsd)}` })} `, dimColor: ratio < 0.8 }]
192  if (filled > 0) spans.push({ text: '▓'.repeat(filled), color })
193  if (filled < width) spans.push({ text: '░'.repeat(width - filled), dimColor: true })
194  return spans
195}
196
197export type ExtrasInput = {
198  summary: string | null
199  /** Rate-limit windows that run out before they reset: label and when. */
200  exhaust: { label: string; at: number }[]
201  todayUsd: number | null
202  budgetUsd: number
203  /** The last 7 days' spend, oldest first, and the streak; null hides the history. */
204  week: { values: number[]; streak: number } | null
205  git: { dirty: number; ahead: number } | null
206  /** Tokens left before auto-compaction runs, once the context is far enough in; null hides it. */
207  compactLeft?: number | null
208  /** The context tokens the next message writes to the cache again, once it has expired; null hides it. */
209  coldCache?: number | null
210  /** How far the last turn grew the context, and the recent turns' growth, oldest first; null hides it. */
211  growth?: { last: number; recent: number[] } | null
212  gitDirtyWarn: number
213  gitAheadWarn: number
214  /** The row's width; parts that do not fit leave it, least important first. */
215  columns?: number
216  /** The theme's glyphs and colors for the row; claude-hud's look when absent. */
217  style?: ExtrasStyle
218  /** The theme mascot's face, first in the row. */
219  mascot?: string | null
220}
221
222export type ExtrasStyle = {
223  summary?: string
224  warning?: string
225  summaryColor?: string
226  forecastColor?: string
227  weekColor?: string
228  warningColor?: string
229  mascotColor?: string
230}
231
232const SEPARATOR = ' │ '
233
234const lineWidth = (spans: HudLine) => spans.reduce((sum, span) => sum + textWidth(span.text), 0)
235
236/** The row under claude-hud's lines; empty when there is nothing to say. */
237export function extrasLine(x: ExtrasInput): HudLine {
238  // Kept in display order; `rank` is what goes last when the row is too wide.
239  const parts: { spans: HudLine; rank: number }[] = []
240  const style = x.style ?? {}
241  if (x.mascot) parts.push({ spans: [{ text: x.mascot, color: style.mascotColor ?? style.summaryColor ?? 'cyan', bold: true }], rank: -1 })
242  if (x.summary) parts.push({ spans: [{ text: `${style.summary ?? '✎'} ${x.summary}`, color: style.summaryColor ?? 'cyan' }], rank: 3 })
243  for (const { label, at } of x.exhaust) {
244    parts.push({ spans: [{ text: m('forecast', { label, time: clockTime(at) }), color: style.forecastColor ?? 'magenta' }], rank: 2 })
245  }
246  if (typeof x.compactLeft === 'number') {
247    parts.push({ spans: [{ text: m('compact.left', { tokens: formatTokens(Math.max(0, x.compactLeft)) }), color: style.forecastColor ?? 'magenta' }], rank: 2 })
248  }
249  if (typeof x.coldCache === 'number') {
250    parts.push({ spans: [{ text: m('cache.cold', { tokens: formatTokens(x.coldCache) }), color: style.warningColor ?? 'yellow' }], rank: 2 })
251  }
252  if (x.growth) {
253    const color = style.forecastColor ?? 'magenta'
254    parts.push({
255      spans: [
256        { text: `${m('turn.growth', { tokens: formatTokens(x.growth.last) })} `, color },
257        { text: sparkline(x.growth.recent), color },
258      ],
259      rank: 1,
260    })
261  }
262  if (x.budgetUsd > 0 && x.todayUsd !== null) parts.push({ spans: budgetSpans(x.todayUsd, x.budgetUsd), rank: 1 })
263  if (x.week && x.week.values.some(v => v > 0)) {
264    parts.push({
265      spans: [
266        { text: `${m('week')} `, dimColor: true },
267        { text: sparkline(x.week.values), color: style.weekColor ?? 'blue' },
268        ...(x.week.streak > 1 ? [{ text: ` ${m('streak', { n: x.week.streak })}`, dimColor: true }] : []),
269      ],
270      rank: 0,
271    })
272  }
273  const nags: string[] = []
274  if (x.git && x.gitDirtyWarn > 0 && x.git.dirty >= x.gitDirtyWarn) nags.push(m('git.dirty', { n: x.git.dirty }))
275  if (x.git && x.gitAheadWarn > 0 && x.git.ahead >= x.gitAheadWarn) nags.push(m('git.ahead', { n: x.git.ahead }))
276  if (nags.length > 0) parts.push({ spans: [{ text: `${style.warning ?? '⚠'} ${nags.join(' · ')}`, color: style.warningColor ?? 'yellow' }], rank: 4 })
277
278  const join = (kept: typeof parts) =>
279    kept.flatMap((part, i) => (i === 0 ? part.spans : [{ text: SEPARATOR, dimColor: true }, ...part.spans]))
280  const kept = [...parts]
281  while (x.columns && kept.length > 1 && lineWidth(join(kept)) > x.columns) {
282    const lowest = Math.min(...kept.map(p => p.rank))
283    kept.splice(kept.findIndex(p => p.rank === lowest), 1)
284  }
285  return join(kept)
286}
287
288/** claude-hud's ` │ ` and ` | ` between elements, dimmed like the extras row's. */
289export function dimSeparators(row: HudLine): HudLine {
290  return row.flatMap(span => {
291    if (span.dimColor || !/ [│|] /.test(span.text)) return [span]
292    return span.text
293      .split(/( [│|] )/)
294      .flatMap((text, i) => (!text ? [] : i % 2 === 1 ? [{ text, dimColor: true }] : [{ ...span, text }]))
295  })
296}
297
298/** The extras row joins claude-hud's last row when both fit in `columns`, else goes under it. */
299export function appendExtras(rows: HudLine[], extra: HudLine, columns: number | undefined): HudLine[] {
300  if (extra.length === 0) return rows
301  const last = rows[rows.length - 1]
302  if (last && columns && lineWidth(last) + textWidth(SEPARATOR) + lineWidth(extra) <= columns) {
303    return [...rows.slice(0, -1), [...last, { text: SEPARATOR, dimColor: true }, ...extra]]
304  }
305  return [...rows, extra]
306}
307
308const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
309
310function base64(bytes: Uint8Array): string {
311  let out = ''
312  for (let i = 0; i < bytes.length; i += 3) {
313    const [a, b = 0, c = 0] = [bytes[i]!, bytes[i + 1], bytes[i + 2]]
314    const n = (a << 16) | (b << 8) | c
315    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
316    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
317    out += i + 2 < bytes.length ? B64[n & 63]! : '='
318  }
319  return out
320}
321
322let chime: string | null = null
323
324/** A short two-note chime, as a base64 16-bit mono WAV: the turn-done sound. */
325export function chimeWav(): string {
326  if (chime) return chime
327  const rate = 22_050
328  const notes = [
329    { hz: 880, ms: 110 },
330    { hz: 1320, ms: 170 },
331  ]
332  const samples = notes.flatMap(({ hz, ms }) => {
333    const count = Math.round((rate * ms) / 1000)
334    return Array.from({ length: count }, (_, i) => {
335      const fade = Math.min(1, i / 200, (count - i) / 600)
336      return Math.round(Math.sin((2 * Math.PI * hz * i) / rate) * fade * 0.35 * 32767)
337    })
338  })
339  const data = samples.length * 2
340  const buf = new DataView(new ArrayBuffer(44 + data))
341  const text = (at: number, s: string) => [...s].forEach((ch, i) => buf.setUint8(at + i, ch.charCodeAt(0)))
342  text(0, 'RIFF')
343  buf.setUint32(4, 36 + data, true)
344  text(8, 'WAVE')
345  text(12, 'fmt ')
346  buf.setUint32(16, 16, true)
347  buf.setUint16(20, 1, true)
348  buf.setUint16(22, 1, true)
349  buf.setUint32(24, rate, true)
350  buf.setUint32(28, rate * 2, true)
351  buf.setUint16(32, 2, true)
352  buf.setUint16(34, 16, true)
353  text(36, 'data')
354  buf.setUint32(40, data, true)
355  samples.forEach((s, i) => buf.setInt16(44 + i * 2, s, true))
356  chime = base64(new Uint8Array(buf.buffer))
357  return chime
358}
359
hooks/hud/config.ts 546 lines
1import * as fs from '../shims/fs.js';
2import * as path from '../shims/path.js';
3import * as os from '../shims/os.js';
4import { expandHomeDirPrefix, getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
5import { createDebug } from './debug.js';
6import type { Language } from './i18n/types.js';
7import { MAX_TERMINAL_WIDTH } from './utils/terminal.js';
8import { sanitizeDisplayText } from './utils/sanitize.js';
9
10const debug = createDebug('config');
11const MAX_CONFIG_FILE_BYTES = 64 * 1024;
12const MAX_CONFIG_NESTING_DEPTH = 8;
13const UNSAFE_CONFIG_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
14
15const LANGUAGES = ['en', 'zh', 'zh-Hans', 'zh-Hant', 'zh-TW', 'ja', 'ko', 'es', 'fr', 'de', 'pt', 'pt-BR', 'ru'] as const satisfies readonly Language[];
16const LINE_LAYOUTS = ['compact', 'expanded'] as const;
17const PATH_LEVELS = [1, 2, 3, 'full'] as const;
18const CONTEXT_VALUE_MODES = ['percent', 'tokens', 'remaining', 'both'] as const;
19const USAGE_VALUE_MODES = ['percent', 'remaining'] as const;
20const GIT_BRANCH_OVERFLOW_MODES = ['truncate', 'wrap'] as const;
21// full: display name as-is; compact: drop the context-window suffix; short: also drop "Claude ".
22const MODEL_FORMATS = ['full', 'compact', 'short'] as const;
23const MODEL_SOURCES = ['auto', 'stdin', 'transcript'] as const;
24const EFFORT_FORMATS = ['full', 'symbol', 'text'] as const;
25const TIME_FORMATS = ['relative', 'absolute', 'both', 'elapsed', 'elapsedAndAbsolute'] as const;
26const HOUR_CYCLES = ['auto', 'h11', 'h12', 'h23', 'h24'] as const;
27const CUSTOM_LINE_POSITIONS = ['first', 'last'] as const;
28const ADDED_DIRS_LAYOUTS = ['inline', 'line'] as const;
29const COLOR_NAMES = ['dim', 'red', 'green', 'yellow', 'magenta', 'cyan', 'brightBlue', 'brightMagenta'] as const;
30
31const ELEMENTS = [
32  'project',
33  'addedDirs',
34  'context',
35  'usage',
36  'promptCache',
37  'cacheHitRate',
38  'memory',
39  'environment',
40  'tools',
41  'skills',
42  'mcp',
43  'agents',
44  'todos',
45  'sessionTime',
46] as const;
47
48// Orderable segments of the first line, shared by the expanded and compact layouts.
49const FIRST_LINE_SEGMENTS = [
50  'model',
51  'project',
52  'advisor',
53  'sessionName',
54  'version',
55  'extra',
56  'duration',
57  'cost',
58  'speed',
59  'auth',
60] as const;
61
62export type LineLayoutType = typeof LINE_LAYOUTS[number];
63export type PathLevels = typeof PATH_LEVELS[number];
64export type ContextValueMode = typeof CONTEXT_VALUE_MODES[number];
65export type UsageValueMode = typeof USAGE_VALUE_MODES[number];
66export type GitBranchOverflowMode = typeof GIT_BRANCH_OVERFLOW_MODES[number];
67export type ModelFormatMode = typeof MODEL_FORMATS[number];
68export type EffortFormatMode = typeof EFFORT_FORMATS[number];
69export type TimeFormatMode = typeof TIME_FORMATS[number];
70export type HourCycleMode = typeof HOUR_CYCLES[number];
71export type CustomLinePosition = typeof CUSTOM_LINE_POSITIONS[number];
72export type AddedDirsLayout = typeof ADDED_DIRS_LAYOUTS[number];
73export type HudColorName = typeof COLOR_NAMES[number];
74export type HudElement = typeof ELEMENTS[number];
75export type FirstLineSegment = typeof FIRST_LINE_SEGMENTS[number];
76
77/** A named preset, a 256-color index (0-255), or a #rrggbb hex string. */
78export type HudColorValue = HudColorName | number | string;
79
80export interface HudColorOverrides {
81  context: HudColorValue;
82  usage: HudColorValue;
83  warning: HudColorValue;
84  usageWarning: HudColorValue;
85  critical: HudColorValue;
86  model: HudColorValue;
87  project: HudColorValue;
88  git: HudColorValue;
89  gitBranch: HudColorValue;
90  label: HudColorValue;
91  custom: HudColorValue;
92  barFilled: string;
93  barEmpty: string;
94}
95
96export const DEFAULT_ELEMENT_ORDER: HudElement[] = [...ELEMENTS];
97export const DEFAULT_MERGE_GROUPS: HudElement[][] = [['context', 'usage']];
98// Empty keeps each renderer's native order until the user moves a segment.
99export const DEFAULT_PROJECT_LINE_ORDER: FirstLineSegment[] = [];
100
101export interface HudConfig {
102  language: Language;
103  lineLayout: LineLayoutType;
104  showSeparators: boolean;
105  pathLevels: PathLevels;
106  maxWidth: number | null;
107  forceMaxWidth: boolean;
108  elementOrder: HudElement[];
109  projectLineOrder: FirstLineSegment[];
110  gitStatus: {
111    enabled: boolean;
112    showDirty: boolean;
113    showAheadBehind: boolean;
114    showFileStats: boolean;
115    showWorktree: boolean;
116    branchOverflow: GitBranchOverflowMode;
117    pushWarningThreshold: number;
118    pushCriticalThreshold: number;
119  };
120  jjStatus: {
121    enabled: boolean;
122    showDirty: boolean;
123    showConflicts: boolean;
124  };
125  display: {
126    showModel: boolean;
127    showProject: boolean;
128    showAddedDirs: boolean;
129    addedDirsLayout: AddedDirsLayout;
130    showContextBar: boolean;
131    contextValue: ContextValueMode;
132    showConfigCounts: boolean;
133    showCost: boolean;
134    // Also show cost for routed providers (Bedrock/Vertex), which showCost hides.
135    showRoutedCost: boolean;
136    showDailyCost: boolean;
137    showWeeklyCost: boolean;
138    showDuration: boolean;
139    showSpeed: boolean;
140    showTokenBreakdown: boolean;
141    showUsage: boolean;
142    usageValue: UsageValueMode;
143    usageBarEnabled: boolean;
144    showResetLabel: boolean;
145    usageCompact: boolean;
146    showModelScopedUsage: boolean;
147    usagePace: boolean;
148    showTools: boolean;
149    showSkills: boolean;
150    showMcp: boolean;
151    toolNameMaxLength: number;
152    toolsMaxVisible: number;
153    skillsMaxVisible: number;
154    showAgents: boolean;
155    showTodos: boolean;
156    showSessionName: boolean;
157    showAuth: boolean;
158    showAuthUser: boolean;
159    // Max characters of the account name (0 = full).
160    authUserLength: number;
161    showClaudeCodeVersion: boolean;
162    showEffortLevel: boolean;
163    effortFormat: EffortFormatMode;
164    showMemoryUsage: boolean;
165    showPromptCache: boolean;
166    showCacheHitRate: boolean;
167    showSessionTokens: boolean;
168    showOutputStyle: boolean;
169    showSessionStartDate: boolean;
170    showLastResponseAt: boolean;
171    showCompactions: boolean;
172    mergeGroups: HudElement[][];
173    // Elements pushed to the right edge of a merged line, when it fits and the width is known.
174    rightAlign: HudElement[];
175    contextWarningThreshold: number;
176    contextCriticalThreshold: number;
177    usageThreshold: number;
178    sevenDayThreshold: number;
179    environmentThreshold: number;
180    externalUsagePath: string;
181    externalUsageWritePath: string;
182    externalUsageFreshnessMs: number;
183    modelFormat: ModelFormatMode;
184    modelOverride: string;
185    // auto: transcript model for non-Claude (proxied) models; stdin/transcript: always that source.
186    modelSource: typeof MODEL_SOURCES[number];
187    showProvider: boolean;
188    providerName: string;
189    customLine: string;
190    customLinePosition: CustomLinePosition;
191    timeFormat: TimeFormatMode;
192    hourCycle: HourCycleMode;
193    showClockSeconds: boolean;
194    showAdvisor: boolean;
195    advisorOverride: string;
196    autoCompactWindow: number | null;
197  };
198  colors: HudColorOverrides;
199}
200
201export const DEFAULT_CONFIG: HudConfig = {
202  language: 'en',
203  lineLayout: 'expanded',
204  showSeparators: false,
205  pathLevels: 1,
206  maxWidth: null,
207  forceMaxWidth: false,
208  elementOrder: [...DEFAULT_ELEMENT_ORDER],
209  projectLineOrder: [...DEFAULT_PROJECT_LINE_ORDER],
210  gitStatus: {
211    enabled: true,
212    showDirty: true,
213    showAheadBehind: false,
214    showFileStats: false,
215    showWorktree: false,
216    branchOverflow: 'truncate',
217    pushWarningThreshold: 0,
218    pushCriticalThreshold: 0,
219  },
220  jjStatus: {
221    enabled: false,
222    showDirty: true,
223    showConflicts: true,
224  },
225  display: {
226    showModel: true,
227    showProject: true,
228    showAddedDirs: true,
229    addedDirsLayout: 'inline',
230    showContextBar: true,
231    contextValue: 'percent',
232    showConfigCounts: false,
233    showCost: false,
234    showRoutedCost: false,
235    showDailyCost: false,
236    showWeeklyCost: false,
237    showDuration: false,
238    showSpeed: false,
239    showTokenBreakdown: true,
240    showUsage: true,
241    usageValue: 'percent',
242    usageBarEnabled: true,
243    showResetLabel: true,
244    usageCompact: false,
245    showModelScopedUsage: true,
246    usagePace: false,
247    showTools: false,
248    showSkills: false,
249    showMcp: false,
250    toolNameMaxLength: 0,
251    toolsMaxVisible: 4,
252    skillsMaxVisible: 4,
253    showAgents: false,
254    showTodos: false,
255    showSessionName: false,
256    showAuth: false,
257    showAuthUser: false,
258    authUserLength: 8,
259    showClaudeCodeVersion: false,
260    showEffortLevel: false,
261    effortFormat: 'full',
262    showMemoryUsage: false,
263    showPromptCache: false,
264    showCacheHitRate: false,
265    showSessionTokens: false,
266    showOutputStyle: false,
267    showSessionStartDate: false,
268    showLastResponseAt: false,
269    showCompactions: false,
270    mergeGroups: DEFAULT_MERGE_GROUPS.map(group => [...group]),
271    rightAlign: [],
272    contextWarningThreshold: 70,
273    contextCriticalThreshold: 85,
274    usageThreshold: 0,
275    sevenDayThreshold: 80,
276    environmentThreshold: 0,
277    externalUsagePath: '',
278    externalUsageWritePath: '',
279    externalUsageFreshnessMs: 300000,
280    modelFormat: 'full',
281    modelOverride: '',
282    modelSource: 'stdin',
283    showProvider: false,
284    providerName: '',
285    customLine: '',
286    customLinePosition: 'last',
287    timeFormat: 'relative',
288    hourCycle: 'auto',
289    showClockSeconds: false,
290    showAdvisor: false,
291    advisorOverride: '',
292    autoCompactWindow: null,
293  },
294  colors: {
295    context: 'green',
296    usage: 'brightBlue',
297    warning: 'yellow',
298    usageWarning: 'brightMagenta',
299    critical: 'red',
300    model: 'cyan',
301    project: 'yellow',
302    git: 'magenta',
303    gitBranch: 'cyan',
304    label: 'dim',
305    custom: 208,
306    barFilled: '█',
307    barEmpty: '░',
308  },
309};
310
311export function getConfigPath(): string {
312  return path.join(getHudPluginDir(os.homedir()), 'config.json');
313}
314
315// Lives outside plugins/, which users often symlink across several CLAUDE_CONFIG_DIRs,
316// so it stays per-directory and can override the shared config.
317export function getConfigOverridePath(): string {
318  return path.join(getClaudeConfigDir(os.homedir()), 'claude-hud.json');
319}
320
321// A rule maps a raw user value to a valid one, or to the fallback (the default).
322type Rule = (value: unknown, fallback: any) => unknown;
323
324const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
325
326const oneOf = (allowed: readonly unknown[]): Rule => (value, fallback) => (
327  allowed.includes(value) ? value : fallback
328);
329const clamp = (min: number, max: number): Rule => (value, fallback) => (
330  isNumber(value) ? Math.max(min, Math.min(max, value)) : fallback
331);
332const floorAtLeastZero: Rule = (value, fallback) => (isNumber(value) ? Math.max(0, Math.floor(value)) : fallback);
333const count: Rule = (value, fallback) => (Number.isInteger(value) && (value as number) >= 0 ? value : fallback);
334const text = (maxLength: number): Rule => (value, fallback) => (
335  typeof value === 'string' ? sanitizeDisplayText(value).slice(0, maxLength) : fallback
336);
337
338// Keeps known names once each, in order. An empty result falls back only when `nonEmpty`.
339const names = (known: readonly unknown[], nonEmpty: boolean): Rule => (value, fallback) => {
340  if (!Array.isArray(value)) return fallback;
341  const kept = [...new Set(value.filter(item => known.includes(item)))];
342  return kept.length > 0 || !nonEmpty ? kept : fallback;
343};
344
345// Groups need two or more known elements, and an element joins at most one group.
346const mergeGroups: Rule = (value, fallback) => {
347  if (!Array.isArray(value)) return fallback;
348  if (value.length === 0) return [];
349  const used = new Set<unknown>();
350  const groups: unknown[][] = [];
351  for (const group of value) {
352    if (!Array.isArray(group)) continue;
353    const members = [...new Set(group.filter(item => (ELEMENTS as readonly unknown[]).includes(item) && !used.has(item)))];
354    if (members.length < 2) continue;
355    members.forEach(member => used.add(member));
356    groups.push(members);
357  }
358  return groups.length > 0 ? groups : fallback;
359};
360
361const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
362const color: Rule = (value, fallback) => (
363  COLOR_NAMES.includes(value as HudColorName)
364  || (Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 255)
365  || (typeof value === 'string' && HEX_COLOR.test(value))
366    ? value
367    : fallback
368);
369
370// Exactly one visible grapheme: no controls, format, variation, separator, or unassigned code points.
371const INVISIBLE_CODEPOINT = /[\p{Cc}\p{Cf}\p{Variation_Selector}\p{Zl}\p{Zp}\p{Cn}]/u;
372const barChar: Rule = (value, fallback) => {
373  if (typeof value !== 'string' || value.length === 0) return fallback;
374  const graphemes = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)];
375  return graphemes.length === 1 && !INVISIBLE_CODEPOINT.test(value) ? value : fallback;
376};
377
378// Expands a leading ~ and ${VAR}; unset variables are left as written.
379const usagePath: Rule = (value) => (
380  typeof value === 'string'
381    ? expandHomeDirPrefix(value.trim(), os.homedir())
382      .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => process.env[name] ?? match)
383    : ''
384);
385
386// Booleans need no rule: a default of type boolean accepts only booleans.
387const RULES: Record<string, Rule> = {
388  'language': oneOf(LANGUAGES),
389  'lineLayout': oneOf(LINE_LAYOUTS),
390  'pathLevels': oneOf(PATH_LEVELS),
391  'maxWidth': (value) => (isNumber(value) && value > 0 ? Math.min(Math.floor(value), MAX_TERMINAL_WIDTH) : null),
392  'elementOrder': names(ELEMENTS, true),
393  'projectLineOrder': names(FIRST_LINE_SEGMENTS, false),
394  'gitStatus.branchOverflow': oneOf(GIT_BRANCH_OVERFLOW_MODES),
395  'gitStatus.pushWarningThreshold': floorAtLeastZero,
396  'gitStatus.pushCriticalThreshold': floorAtLeastZero,
397  'display.addedDirsLayout': oneOf(ADDED_DIRS_LAYOUTS),
398  'display.contextValue': oneOf(CONTEXT_VALUE_MODES),
399  'display.usageValue': oneOf(USAGE_VALUE_MODES),
400  'display.toolNameMaxLength': count,
401  'display.toolsMaxVisible': count,
402  'display.skillsMaxVisible': count,
403  'display.authUserLength': count,
404  'display.effortFormat': oneOf(EFFORT_FORMATS),
405  'display.mergeGroups': mergeGroups,
406  'display.rightAlign': names(ELEMENTS, false),
407  'display.contextWarningThreshold': clamp(0, 100),
408  'display.contextCriticalThreshold': clamp(0, 100),
409  'display.usageThreshold': clamp(0, 100),
410  'display.sevenDayThreshold': clamp(0, 100),
411  'display.environmentThreshold': clamp(0, 100),
412  'display.externalUsagePath': usagePath,
413  'display.externalUsageWritePath': usagePath,
414  'display.externalUsageFreshnessMs': floorAtLeastZero,
415  'display.modelFormat': oneOf(MODEL_FORMATS),
416  'display.modelOverride': text(80),
417  'display.modelSource': oneOf(MODEL_SOURCES),
418  'display.providerName': text(40),
419  'display.customLine': text(80),
420  'display.customLinePosition': oneOf(CUSTOM_LINE_POSITIONS),
421  'display.timeFormat': oneOf(TIME_FORMATS),
422  'display.hourCycle': oneOf(HOUR_CYCLES),
423  'display.advisorOverride': text(80),
424  'display.autoCompactWindow': (value) => (Number.isInteger(value) && (value as number) > 0 ? value : null),
425  'colors.barFilled': barChar,
426  'colors.barEmpty': barChar,
427  'colors.*': color,
428};
429
430function isPlainObject(value: unknown): value is Record<string, unknown> {
431  return typeof value === 'object' && value !== null && !Array.isArray(value);
432}
433
434// Walks the defaults, so unknown user keys are dropped and every key is validated.
435function normalize(defaults: Record<string, unknown>, input: unknown, prefix = ''): Record<string, unknown> {
436  const source = isPlainObject(input) ? input : {};
437  const result: Record<string, unknown> = {};
438  for (const [key, fallback] of Object.entries(defaults)) {
439    const keyPath = prefix + key;
440    const rule = RULES[keyPath] ?? RULES[`${prefix}*`];
441    const value = source[key];
442    if (rule) {
443      result[key] = rule(value, fallback);
444    } else if (isPlainObject(fallback)) {
445      result[key] = normalize(fallback, value, `${keyPath}.`);
446    } else {
447      result[key] = typeof fallback === 'boolean' && typeof value === 'boolean' ? value : fallback;
448    }
449  }
450  return result;
451}
452
453// v0.0.x wrote `layout: "default" | "separators"`; some third-party tools write an object.
454function migrateLegacyLayout(config: Record<string, unknown>): Record<string, unknown> {
455  if (!('layout' in config) || 'lineLayout' in config) return config;
456  const { layout, ...rest } = config;
457  if (typeof layout === 'string') {
458    return { ...rest, lineLayout: 'compact', showSeparators: layout === 'separators' };
459  }
460  if (isPlainObject(layout)) {
461    const { lineLayout, showSeparators, pathLevels } = layout;
462    return {
463      ...rest,
464      ...(typeof lineLayout === 'string' && { lineLayout }),
465      ...(typeof showSeparators === 'boolean' && { showSeparators }),
466      ...((typeof pathLevels === 'number' || pathLevels === 'full') && { pathLevels }),
467    };
468  }
469  return rest;
470}
471
472export function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
473  const migrated = migrateLegacyLayout(userConfig as Record<string, unknown>);
474  const defaults = structuredClone(DEFAULT_CONFIG) as unknown as Record<string, unknown>;
475  return normalize(defaults, migrated) as unknown as HudConfig;
476}
477
478function hasSafeConfigShape(value: unknown, depth = 0): boolean {
479  if (depth > MAX_CONFIG_NESTING_DEPTH) return false;
480  if (Array.isArray(value)) return value.every(item => hasSafeConfigShape(item, depth + 1));
481  if (!isPlainObject(value)) return true;
482  return Object.entries(value).every(([key, child]) => (
483    !UNSAFE_CONFIG_KEYS.has(key) && hasSafeConfigShape(child, depth + 1)
484  ));
485}
486
487// Sections merge key by key; arrays and scalars replace the base value.
488function mergeOverrides(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
489  const result = Object.assign(Object.create(null), base) as Record<string, unknown>;
490  for (const [key, value] of Object.entries(override)) {
491    const current = result[key];
492    result[key] = isPlainObject(current) && isPlainObject(value) ? mergeOverrides(current, value) : value;
493  }
494  return result;
495}
496
497// Checks and reads through one descriptor so a swapped or growing file can't slip past either guard.
498function readConfigFile(configPath: string): Record<string, unknown> | null {
499  try {
500    // O_NOFOLLOW rejects symlinks on POSIX; it is undefined on Windows.
501    const fd = fs.openSync(configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
502    try {
503      if (!fs.fstatSync(fd).isFile()) {
504        debug('Ignoring %s: not a regular file', configPath);
505        return null;
506      }
507      // hud mod: one bounded whole read; O_NOFOLLOW becomes an lstat check.
508      if (fs.lstatSync(configPath).isSymbolicLink()) {
509        debug('Ignoring %s: not a regular file', configPath);
510        return null;
511      }
512      const content = fs.readFileSync(fd, 'utf-8');
513      if (new TextEncoder().encode(content).length > MAX_CONFIG_FILE_BYTES) {
514        debug('Ignoring %s: larger than %d bytes', configPath, MAX_CONFIG_FILE_BYTES);
515        return null;
516      }
517      const parsed: unknown = JSON.parse(content);
518      if (!isPlainObject(parsed) || !hasSafeConfigShape(parsed)) {
519        debug('Ignoring %s: not a bounded JSON object without unsafe keys', configPath);
520        return null;
521      }
522      return parsed;
523    } finally {
524      fs.closeSync(fd);
525    }
526  } catch (err) {
527    if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
528      debug('Ignoring %s:', configPath, err instanceof Error ? err.message : err);
529    }
530    return null;
531  }
532}
533
534// The mod's own options laid over the loaded config (the mod, not upstream).
535let configPatch: (config: HudConfig) => HudConfig = (config) => config;
536
537export function setConfigPatch(patch: (config: HudConfig) => HudConfig): void {
538  configPatch = patch;
539}
540
541export async function loadConfig(): Promise<HudConfig> {
542  const base = readConfigFile(getConfigPath()) ?? {};
543  const override = readConfigFile(getConfigOverridePath());
544  return configPatch(mergeConfig((override ? mergeOverrides(base, override) : base) as Partial<HudConfig>));
545}
546
hooks/hud/i18n/index.ts 77 lines
1import type { Language, MessageKey, Messages } from "./types.js";
2import { en } from "./en.js";
3import { zhHans } from "./zh-Hans.js";
4import { zhHant } from "./zh-Hant.js";
5import { ja } from "./ja.js";
6import { ko } from "./ko.js";
7import { es } from "./es.js";
8import { fr } from "./fr.js";
9import { de } from "./de.js";
10import { ptBR } from "./pt-BR.js";
11import { ru } from "./ru.js";
12
13export type { Language, MessageKey, Messages };
14
15export type CanonicalLanguage = "en" | "zh-Hans" | "zh-Hant" | "ja" | "ko" | "es" | "fr" | "de" | "pt-BR" | "ru";
16
17const locales: Record<CanonicalLanguage, Messages> = {
18  en,
19  "zh-Hans": zhHans,
20  "zh-Hant": zhHant,
21  ja,
22  ko,
23  es,
24  fr,
25  de,
26  "pt-BR": ptBR,
27  ru,
28};
29
30// Resolve short language tags to canonical BCP 47 forms.
31// Based on CLDR likely subtags: zh → zh-Hans-CN
32// https://www.unicode.org/cldr/charts/latest/supplemental/likely_subtags.html
33const CANONICAL: Record<Language, CanonicalLanguage> = {
34  "en": "en",
35  "zh": "zh-Hans",
36  "zh-Hans": "zh-Hans",
37  "zh-Hant": "zh-Hant",
38  "zh-TW": "zh-Hant",
39  "ja": "ja",
40  "ko": "ko",
41  "es": "es",
42  "fr": "fr",
43  "de": "de",
44  "pt": "pt-BR",
45  "pt-BR": "pt-BR",
46  "ru": "ru",
47};
48
49let currentLanguage: Language = "en";
50
51export function setLanguage(lang: Language): void {
52  currentLanguage = lang;
53}
54
55// https://www.rfc-editor.org/info/bcp47
56export function getCanonicalLanguage(): CanonicalLanguage {
57  return CANONICAL[currentLanguage] ?? "en";
58}
59
60// https://www.unicode.org/reports/tr11/
61export function isCjkLanguage(): boolean {
62  const canon = getCanonicalLanguage();
63  return canon === "zh-Hans" || canon === "zh-Hant" || canon === "ja" || canon === "ko";
64}
65
66export function t(key: MessageKey): string {
67  const canon = getCanonicalLanguage();
68  return locales[canon]?.[key] ?? locales.en[key] ?? key;
69}
70
71// Minimal named-placeholder interpolation. Layout that varies by language
72// (spacing, affix position) lives in each locale's pattern string rather than in
73// render code. Unknown placeholders render as empty string (kept lenient).
74export function interpolate(pattern: string, params: Record<string, string | number>): string {
75  return pattern.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ""));
76}
77
hooks/hud/transcript.ts 386 lines
1import * as fs from '../shims/fs.js';
2import * as readline from '../shims/readline.js';
3import { createDebug } from './debug.js';
4import type { AgentEntry, SessionTokenUsage, TodoItem, ToolEntry, TranscriptData } from './types.js';
5import { sanitizeDisplayText } from './utils/sanitize.js';
6import { sanitizeTranscriptModel } from './model-source.js';
7
8const debug = createDebug('transcript');
9
10const TOOLS_KEPT = 20;
11const AGENTS_KEPT = 10;
12const NAME_MAX_LEN = 64;
13const ADVISOR_MODEL_MAX_LEN = 64;
14const MESSAGE_ID_MAX_LEN = 128;
15const MESSAGE_IDS_MAX = 4096;
16const MCP_ERRORS_MAX = 64;
17const MCP_TOOL = /^mcp__(.+?)__(.+)$/;
18// Claude Code's /effort output; anchored so prose quoting it can't flip ultracode.
19const EFFORT_COMMAND = /^<local-command-stdout>Set effort level to (\w+)/;
20
21interface Usage {
22  input_tokens?: unknown;
23  output_tokens?: unknown;
24  cache_creation_input_tokens?: unknown;
25  cache_read_input_tokens?: unknown;
26}
27
28interface Block {
29  type?: string;
30  id?: string;
31  name?: string;
32  input?: Record<string, unknown>;
33  tool_use_id?: string;
34  is_error?: boolean;
35}
36
37interface Entry {
38  type?: string;
39  subtype?: string;
40  operation?: string;
41  content?: unknown;
42  timestamp?: string;
43  isSidechain?: boolean;
44  advisorModel?: unknown;
45  message?: { id?: unknown; model?: unknown; content?: Block[] | string; usage?: Usage };
46  toolUseResult?: { resolvedModel?: unknown; isAsync?: unknown; status?: unknown };
47  compactMetadata?: { postTokens?: unknown };
48  attachment?: { type?: string };
49}
50
51const emptyTranscript = (): TranscriptData => ({ tools: [], skills: [], mcpServers: [], mcpErrors: [], agents: [], todos: [] });
52
53const ZERO_USAGE: SessionTokenUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
54const USAGE_FIELDS = Object.keys(ZERO_USAGE) as (keyof SessionTokenUsage)[];
55
56const count = (value: unknown): number =>
57  typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
58
59function addUsage(total: SessionTokenUsage, usage: SessionTokenUsage): void {
60  for (const field of USAGE_FIELDS) total[field] += usage[field];
61}
62
63function name(value: unknown): string | undefined {
64  if (typeof value !== 'string') return undefined;
65  const text = sanitizeDisplayText(value).trim();
66  if (!text) return undefined;
67  return text.length <= NAME_MAX_LEN ? text : `${text.slice(0, NAME_MAX_LEN - 1)}…`;
68}
69
70const mcpServer = (toolName: string): string | undefined => name(MCP_TOOL.exec(toolName)?.[1]);
71
72// Tool inputs are written by the model, so their text is untrusted terminal input.
73const text = (value: unknown): string | undefined =>
74  typeof value === 'string' ? sanitizeDisplayText(value) || undefined : undefined;
75
76function toolTarget(toolName: string, input: Record<string, unknown> | undefined): string | undefined {
77  if (!input) return undefined;
78  switch (toolName) {
79    case 'Read':
80    case 'Write':
81    case 'Edit':
82      return text(input.file_path ?? input.path);
83    case 'Glob':
84    case 'Grep':
85      return text(input.pattern);
86    case 'Skill':
87      return name(input.skill);
88    case 'Bash': {
89      const command = text(input.command)?.replace(/\s+/g, ' ').trim();
90      if (!command) return undefined;
91      return command.length > 30 ? `${command.slice(0, 30).trimEnd()}...` : command;
92    }
93  }
94  return undefined;
95}
96
97function taskStatus(status: unknown): TodoItem['status'] | null {
98  switch (status) {
99    case 'pending':
100    case 'not_started':
101      return 'pending';
102    case 'in_progress':
103    case 'running':
104      return 'in_progress';
105    case 'completed':
106    case 'complete':
107    case 'done':
108      return 'completed';
109    default:
110      return null;
111  }
112}
113
114function toTodo(value: unknown): TodoItem[] {
115  const todo = value as { content?: unknown; status?: unknown } | null;
116  const content = text(todo?.content);
117  const status = taskStatus(todo?.status);
118  return content && status ? [{ content, status }] : [];
119}
120
121export class Parser {
122  private tools = new Map<string, ToolEntry>();
123  private agents = new Map<string, AgentEntry>();
124  private skills = new Set<string>();
125  private mcpServers = new Set<string>();
126  private mcpErrors = new Set<string>();
127  private todos: TodoItem[] = [];
128  private taskIndex = new Map<string, number>();
129  private agentCompletions = new Map<string, Date>();
130  // Claude Code logs one API response several times, sometimes non-adjacently, so usage
131  // is the per-field max per message id. Ids evicted to bound memory settle into `settled`.
132  private usageById = new Map<string, SessionTokenUsage>();
133  private settled: SessionTokenUsage = { ...ZERO_USAGE };
134  private lastIdlessUsage: string | undefined;
135  private data: TranscriptData = { ...emptyTranscript(), compactionCount: 0 };
136
137  line(raw: string): void {
138    let entry: Entry | null = null;
139    try {
140      entry = raw.trim() ? JSON.parse(raw) : null;
141    } catch {
142      // Malformed lines are skipped.
143    }
144    if (!entry || typeof entry !== 'object') {
145      this.lastIdlessUsage = undefined;
146      return;
147    }
148
149    const time = entry.timestamp ? new Date(entry.timestamp) : null;
150    const at = time && !Number.isNaN(time.getTime()) ? time : null;
151    if (at && !this.data.sessionStart) this.data.sessionStart = at;
152
153    if (entry.type === 'assistant') {
154      this.assistant(entry, at);
155    } else {
156      this.lastIdlessUsage = undefined;
157    }
158    if (entry.type === 'user' && typeof entry.message?.content === 'string') {
159      const effort = EFFORT_COMMAND.exec(entry.message.content);
160      if (effort) this.data.ultracodeActive = effort[1].toLowerCase() === 'ultracode';
161    }
162    if (entry.type === 'attachment') {
163      if (entry.attachment?.type === 'ultra_effort_enter') this.data.ultracodeActive = true;
164      if (entry.attachment?.type === 'ultra_effort_exit') this.data.ultracodeActive = false;
165    }
166    if (entry.type === 'system' && entry.subtype === 'compact_boundary' && at) {
167      this.data.compactionCount = (this.data.compactionCount ?? 0) + 1;
168      const post = entry.compactMetadata?.postTokens;
169      this.data.contextTokens = typeof post === 'number' && Number.isFinite(post) && post >= 0 ? Math.trunc(post) : undefined;
170    }
171    // A background agent's tool_result lands at launch; its completion is this enqueue.
172    if (entry.type === 'queue-operation' && entry.operation === 'enqueue' && typeof entry.content === 'string' && at) {
173      const toolUseId = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(entry.content)?.[1];
174      if (toolUseId && /<task-id>[^<]+<\/task-id>/.test(entry.content)) this.agentCompletions.set(toolUseId, at);
175    }
176
177    if (Array.isArray(entry.message?.content)) {
178      for (const block of entry.message.content) {
179        if (block?.type === 'tool_use' && block.id && block.name) this.toolUse(block, at ?? new Date());
180        if (block?.type === 'tool_result' && block.tool_use_id) this.toolResult(block, entry, at ?? new Date());
181      }
182    }
183  }
184
185  private assistant(entry: Entry, at: Date | null): void {
186    if (at) this.data.lastAssistantResponseAt = at;
187    if (typeof entry.advisorModel === 'string' && entry.advisorModel) {
188      this.data.advisorModel = entry.advisorModel.slice(0, ADVISOR_MODEL_MAX_LEN);
189    }
190    const model = sanitizeTranscriptModel(entry.message?.model);
191    // Claude Code writes '<synthetic>' on assistant records it generates locally.
192    if (model && model !== '<synthetic>') this.data.lastAssistantModel = model;
193
194    const raw = entry.message?.usage;
195    if (!raw) {
196      this.lastIdlessUsage = undefined;
197      return;
198    }
199    const usage: SessionTokenUsage = {
200      inputTokens: count(raw.input_tokens),
201      outputTokens: count(raw.output_tokens),
202      cacheCreationTokens: count(raw.cache_creation_input_tokens),
203      cacheReadTokens: count(raw.cache_read_input_tokens),
204    };
205    if (entry.isSidechain !== true) {
206      this.data.contextTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens;
207    }
208
209    const id = entry.message?.id;
210    if (typeof id === 'string' && id && id.length <= MESSAGE_ID_MAX_LEN) {
211      this.lastIdlessUsage = undefined;
212      const previous = this.usageById.get(id);
213      this.usageById.set(id, previous ? maxUsage(previous, usage) : usage);
214      if (this.usageById.size > MESSAGE_IDS_MAX) {
215        const [oldestId, oldest] = this.usageById.entries().next().value as [string, SessionTokenUsage];
216        this.usageById.delete(oldestId);
217        addUsage(this.settled, oldest);
218      }
219      return;
220    }
221    // Without an id, only an identical record right after the previous one is a duplicate.
222    const fingerprint = JSON.stringify(usage);
223    if (fingerprint !== this.lastIdlessUsage) addUsage(this.settled, usage);
224    this.lastIdlessUsage = fingerprint;
225  }
226
227  private toolUse(block: Block, at: Date): void {
228    const toolName = block.name as string;
229    const input = block.input;
230    const skill = toolName === 'Skill' ? name(input?.skill) : undefined;
231    if (skill) this.skills.add(skill);
232    const server = mcpServer(toolName);
233    if (server) this.mcpServers.add(server);
234
235    if (toolName === 'Task' || toolName === 'Agent') {
236      this.agents.set(block.id as string, {
237        id: block.id as string,
238        type: (input?.subagent_type as string) ?? 'agent',
239        model: sanitizeTranscriptModel(input?.model),
240        description: (input?.description as string) ?? undefined,
241        status: 'running',
242        startTime: at,
243        background: input?.run_in_background === true,
244      });
245    } else if (toolName === 'TodoWrite') {
246      if (Array.isArray(input?.todos)) this.replaceTodos(input.todos.flatMap(toTodo));
247    } else if (toolName === 'TaskCreate') {
248      const content = text(input?.subject) ?? text(input?.description);
249      this.todos.push({ content: content ?? 'Untitled task', status: taskStatus(input?.status) ?? 'pending' });
250      const taskId = typeof input?.taskId === 'string' || typeof input?.taskId === 'number' ? String(input.taskId) : block.id;
251      if (taskId) this.taskIndex.set(taskId, this.todos.length - 1);
252    } else if (toolName === 'TaskUpdate') {
253      const todo = this.todos[this.findTask(input?.taskId) ?? -1];
254      if (!todo) return;
255      const status = taskStatus(input?.status);
256      if (status) todo.status = status;
257      const content = text(input?.subject) ?? text(input?.description);
258      if (content) todo.content = content;
259    } else {
260      this.tools.set(block.id as string, {
261        id: block.id as string,
262        name: toolName,
263        target: toolTarget(toolName, input),
264        status: 'running',
265        startTime: at,
266      });
267    }
268  }
269
270  private toolResult(block: Block, entry: Entry, at: Date): void {
271    const id = block.tool_use_id as string;
272    const tool = this.tools.get(id);
273    if (tool) {
274      tool.status = block.is_error ? 'error' : 'completed';
275      tool.endTime = at;
276      const server = mcpServer(tool.name);
277      if (server && block.is_error) {
278        this.mcpErrors.add(server);
279        if (this.mcpErrors.size > MCP_ERRORS_MAX) this.mcpErrors.delete(this.mcpErrors.values().next().value as string);
280      } else if (server) {
281        this.mcpErrors.delete(server);
282      }
283    }
284
285    const agent = this.agents.get(id);
286    if (agent) {
287      // resolvedModel is what the subagent actually ran on, so it beats the caller's alias.
288      agent.model = sanitizeTranscriptModel(entry.toolUseResult?.resolvedModel) ?? agent.model;
289      if (entry.toolUseResult?.isAsync === true || entry.toolUseResult?.status === 'async_launched') {
290        agent.background = true;
291      }
292      if (!agent.background) agent.endTime = at;
293    }
294  }
295
296  // TodoWrite replaces the list; TaskCreate ids follow their todo by content, in order,
297  // so duplicate-content todos each keep their own id.
298  private replaceTodos(next: TodoItem[]): void {
299    const idsByContent = new Map<string, string[]>();
300    for (const [taskId, index] of [...this.taskIndex].sort((a, b) => a[1] - b[1])) {
301      const content = this.todos[index]?.content;
302      if (content === undefined) continue;
303      idsByContent.set(content, [...(idsByContent.get(content) ?? []), taskId]);
304    }
305    this.todos = [...next];
306    this.taskIndex.clear();
307    this.todos.forEach((todo, index) => {
308      const taskId = idsByContent.get(todo.content)?.shift();
309      if (taskId) this.taskIndex.set(taskId, index);
310    });
311  }
312
313  // TaskUpdate names a task by the id TaskCreate returned, or by its 1-based position.
314  private findTask(taskId: unknown): number | null {
315    if (typeof taskId !== 'string' && typeof taskId !== 'number') return null;
316    const key = String(taskId);
317    const mapped = this.taskIndex.get(key);
318    if (mapped !== undefined) return mapped;
319    const position = /^\d+$/.test(key) ? Number(key) - 1 : -1;
320    return position >= 0 && position < this.todos.length ? position : null;
321  }
322
323  finish(): TranscriptData {
324    for (const [id, endTime] of this.agentCompletions) {
325      const agent = this.agents.get(id);
326      if (agent?.background) agent.endTime = endTime;
327    }
328    for (const agent of this.agents.values()) {
329      if (agent.endTime) agent.status = 'completed';
330    }
331    const sessionTokens = { ...this.settled };
332    for (const usage of this.usageById.values()) addUsage(sessionTokens, usage);
333    return {
334      ...this.data,
335      tools: [...this.tools.values()].slice(-TOOLS_KEPT),
336      agents: [...this.agents.values()].slice(-AGENTS_KEPT),
337      skills: [...this.skills],
338      mcpServers: [...this.mcpServers],
339      mcpErrors: [...this.mcpErrors],
340      todos: this.todos,
341      sessionTokens,
342    };
343  }
344}
345
346function maxUsage(a: SessionTokenUsage, b: SessionTokenUsage): SessionTokenUsage {
347  return {
348    inputTokens: Math.max(a.inputTokens, b.inputTokens),
349    outputTokens: Math.max(a.outputTokens, b.outputTokens),
350    cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens),
351    cacheReadTokens: Math.max(a.cacheReadTokens, b.cacheReadTokens),
352  };
353}
354
355// hud mod: the mod parses the transcript incrementally (only appended lines,
356// one Parser kept per transcript) and answers here; the stream read below
357// stays as upstream wrote it for when no provider is set.
358let transcriptProvider: ((transcriptPath: string) => Promise<TranscriptData | null>) | null = null;
359
360export function setTranscriptProvider(provider: (transcriptPath: string) => Promise<TranscriptData | null>): void {
361  transcriptProvider = provider;
362}
363
364export async function parseTranscript(transcriptPath: string): Promise<TranscriptData> {
365  if (transcriptProvider) {
366    const provided = await transcriptProvider(transcriptPath);
367    if (provided) return provided;
368  }
369  try {
370    if (!transcriptPath || !fs.statSync(transcriptPath).isFile()) return emptyTranscript();
371  } catch {
372    return emptyTranscript();
373  }
374  const parser = new Parser();
375  try {
376    const input = fs.createReadStream(transcriptPath);
377    for await (const raw of readline.createInterface({ input, crlfDelay: Infinity })) {
378      parser.line(raw);
379    }
380  } catch (err) {
381    // A read cut short still renders what was parsed.
382    debug('Transcript read failed:', err instanceof Error ? err.message : err);
383  }
384  return parser.finish();
385}
386
hooks/hud/types.ts 203 lines
1import type { HudConfig } from './config.js';
2import type { GitRepoIdentity, GitStatus } from './git.js';
3import type { AuthInfo } from './auth.js';
4import type { CostTotals } from './daily-cost.js';
5
6// The statusline payload Claude Code writes to stdin (code.claude.com/docs/en/statusline).
7export interface StdinData {
8  session_id?: string;
9  session_name?: string;
10  version?: string;
11  transcript_path?: string;
12  cwd?: string;
13  workspace?: {
14    current_dir?: string;
15    project_dir?: string;
16    added_dirs?: string[];
17    git_worktree?: string;
18    repo?: GitRepoIdentity;
19  } | null;
20  model?: {
21    id?: string;
22    display_name?: string;
23  };
24  output_style?: { name?: string };
25  context_window?: {
26    context_window_size?: number;
27    total_input_tokens?: number | null;
28    total_output_tokens?: number | null;
29    current_usage?: {
30      input_tokens?: number;
31      output_tokens?: number;
32      cache_creation_input_tokens?: number;
33      cache_read_input_tokens?: number;
34    } | null;
35    used_percentage?: number | null;
36    remaining_percentage?: number | null;
37  };
38  cost?: {
39    total_cost_usd?: number | null;
40    total_duration_ms?: number | null;
41    total_api_duration_ms?: number | null;
42    total_lines_added?: number | null;
43    total_lines_removed?: number | null;
44  } | null;
45  rate_limits?: {
46    five_hour?: RateLimitWindow | null;
47    seven_day?: RateLimitWindow | null;
48    spend_limit?: RateLimitWindow | null;
49  } | null;
50  // hud mod: the model-scoped weekly windows (e.g. Fable), as the external snapshot's model_scoped.
51  model_scoped?: ExternalUsageSnapshot['model_scoped'];
52  prompt_cache?: {
53    warm?: boolean;
54    caching_observed?: boolean;
55    ttl?: string;
56    expires_at?: number | null;
57    hit_ratio?: number | null;
58  } | null;
59  effort?: { level?: string } | null;
60  worktree?: { name?: string; path?: string; branch?: string } | null;
61}
62
63interface RateLimitWindow {
64  used_percentage?: number | null;
65  resets_at?: number | null;
66}
67
68export interface ToolEntry {
69  id: string;
70  name: string;
71  target?: string;
72  status: 'running' | 'completed' | 'error';
73  startTime: Date;
74  endTime?: Date;
75}
76
77export interface AgentEntry {
78  id: string;
79  type: string;
80  model?: string;
81  description?: string;
82  status: 'running' | 'completed';
83  startTime: Date;
84  endTime?: Date;
85  background?: boolean;
86}
87
88export interface TodoItem {
89  content: string;
90  status: 'pending' | 'in_progress' | 'completed';
91}
92
93export interface UsageData {
94  fiveHour: number | null;  // 0-100 percentage, null if unavailable
95  sevenDay: number | null;  // 0-100 percentage, null if unavailable
96  fiveHourResetAt: Date | null;
97  sevenDayResetAt: Date | null;
98  balanceLabel?: string | null;  // optional raw balance text (e.g. "¥6.35")
99  // Model-scoped weekly windows (e.g. Fable), from the external usage snapshot.
100  scopedWindows?: ScopedUsageWindow[];
101}
102
103/** One model-scoped weekly quota window (e.g. label "Fable", used percent 0-100). */
104export interface ScopedUsageWindow {
105  label: string;
106  percent: number | null;
107  resetAt: Date | null;
108}
109
110export interface ExternalUsageSnapshot {
111  five_hour?: {
112    used_percentage?: number | null;
113    resets_at?: string | number | null;
114  } | null;
115  seven_day?: {
116    used_percentage?: number | null;
117    resets_at?: string | number | null;
118  } | null;
119  updated_at?: string | number | null;
120  balance_label?: string | null;
121  // Model-scoped weekly windows (e.g. Fable), in the shape of Claude Code's /usage data.
122  model_scoped?: Array<{
123    display_name?: string | null;
124    utilization?: number | null;
125    resets_at?: string | null;
126  }> | null;
127}
128
129export interface MemoryInfo {
130  totalBytes: number;
131  usedBytes: number;
132  freeBytes: number;
133  usedPercent: number;
134}
135
136/** Check if usage limit is reached (either window at 100%) */
137export function isLimitReached(data: UsageData): boolean {
138  return data.fiveHour === 100 || data.sevenDay === 100;
139}
140
141export interface SessionTokenUsage {
142  inputTokens: number;
143  outputTokens: number;
144  cacheCreationTokens: number;
145  cacheReadTokens: number;
146}
147
148export interface TranscriptData {
149  tools: ToolEntry[];
150  skills: string[];
151  mcpServers: string[];
152  /**
153   * MCP servers whose latest observed tool result is an error, derived
154   * from `mcp__<server>__<tool>` results carrying is_error. Distinct from
155   * mcpServers, which is a plain activity list.
156   */
157  mcpErrors: string[];
158  agents: AgentEntry[];
159  todos: TodoItem[];
160  sessionStart?: Date;
161  // Last assistant response of any kind, subagents included. Drives the
162  // last-response element.
163  lastAssistantResponseAt?: Date;
164  sessionTokens?: SessionTokenUsage;
165  // Number of compact_boundary entries (manual /compact or auto compaction)
166  // with a valid timestamp seen in the transcript.
167  compactionCount?: number;
168  // Tokens in the main conversation's context as of its last request, or the
169  // post-compaction size after a compact boundary.
170  contextTokens?: number;
171  // Advisor model ID for the current session, captured from the top-level
172  // `advisorModel` field that Claude Code stamps onto every assistant record
173  // after `/advisor` is set (e.g. "claude-opus-4-7"). undefined when /advisor
174  // is off or no assistant turn has happened yet.
175  advisorModel?: string;
176  // Current ultracode effort state from the most recent transcript signal
177  // (`ultra_effort_enter`/`ultra_effort_exit` attachment or `/effort` output).
178  // undefined when ultracode was never entered this session.
179  ultracodeActive?: boolean;
180  // Model ID from the most recent assistant message's `message.model` field.
181  // This reflects what the API actually served — may differ from stdin.model
182  // when a proxy (e.g. cc-switch) routes to a different model. Transcript
183  // parsing sanitizes terminal controls and caps the retained value at 80 chars.
184  lastAssistantModel?: string;
185}
186
187export interface RenderContext {
188  stdin: StdinData;
189  transcript: TranscriptData;
190  claudeMdCount: number;
191  rulesCount: number;
192  mcpCount: number;
193  hooksCount: number;
194  costTotals: CostTotals | null;
195  outputSpeed: number | null;
196  gitStatus: GitStatus | null;
197  usageData: UsageData | null;
198  memoryUsage: MemoryInfo | null;
199  config: HudConfig;
200  extraLabel: string | null;
201  authInfo?: AuthInfo | null;
202}
203
hooks/hud/usage-pace.ts 98 lines
1import type { HudConfig } from './config.js';
2import type { ScopedUsageWindow, UsageData } from './types.js';
3
4export type UsagePace = 'normal' | 'warning' | 'critical';
5
6export const FIVE_HOUR_WINDOW_MS = 5 * 60 * 60 * 1000;
7export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
8
9// Below this much usage a linear projection is noise (3% five minutes in
10// "projects" 180%), so pace stays normal.
11const MIN_USED_PERCENT = 10;
12// Amber once the current rate would end the window at 90% of the limit or more.
13const WARNING_PROJECTED_PERCENT = 90;
14// Red once the current rate would exhaust the limit before the window resets.
15const CRITICAL_PROJECTED_PERCENT = 100;
16
17/**
18 * Grades how fast a rate-limit window is being consumed by projecting the
19 * used percentage linearly to the window's reset. Returns null when there is
20 * no usable rate: missing data, a reset already past, or a reset at least a
21 * full window away (no time elapsed).
22 */
23export function getUsagePace(
24  percent: number | null,
25  resetAt: Date | null,
26  windowMs: number,
27  now: number = Date.now(),
28): UsagePace | null {
29  if (percent === null || !resetAt) {
30    return null;
31  }
32
33  const remainingMs = resetAt.getTime() - now;
34  if (!Number.isFinite(remainingMs) || remainingMs <= 0 || remainingMs >= windowMs) {
35    return null;
36  }
37
38  if (percent < MIN_USED_PERCENT) {
39    return 'normal';
40  }
41
42  const elapsedFraction = (windowMs - remainingMs) / windowMs;
43  const projected = percent / elapsedFraction;
44  if (projected > CRITICAL_PROJECTED_PERCENT) {
45    return 'critical';
46  }
47  if (projected >= WARNING_PROJECTED_PERCENT) {
48    return 'warning';
49  }
50  return 'normal';
51}
52
53/** True when pace should draw attention (amber or red). */
54export function isPaceAlert(pace: UsagePace | null): boolean {
55  return pace === 'warning' || pace === 'critical';
56}
57
58/** Pace of every usage window, plus the display rules pace imposes. */
59export interface UsagePaces {
60  fiveHour: UsagePace | null;
61  sevenDay: UsagePace | null;
62  /** Parallel to the scoped windows passed in. */
63  scoped: Array<UsagePace | null>;
64  /** Some window is at amber/red pace: show usage despite `usageThreshold`. */
65  alert: boolean;
66  /** Show the weekly window: at/above `sevenDayThreshold`, or at amber/red pace. */
67  showSevenDay: boolean;
68}
69
70/**
71 * Grades every window's pace (all null unless `display.usagePace` is on) and
72 * resolves the visibility rules both usage renderers share.
73 */
74export function resolveUsagePaces(
75  usage: UsageData,
76  scopedWindows: ScopedUsageWindow[],
77  display: Partial<HudConfig['display']> | undefined,
78  now: number = Date.now(),
79): UsagePaces {
80  const enabled = display?.usagePace === true;
81  const paceOf = (percent: number | null, resetAt: Date | null, windowMs: number): UsagePace | null =>
82    enabled ? getUsagePace(percent, resetAt, windowMs, now) : null;
83
84  const fiveHour = paceOf(usage.fiveHour, usage.fiveHourResetAt, FIVE_HOUR_WINDOW_MS);
85  const sevenDay = paceOf(usage.sevenDay, usage.sevenDayResetAt, SEVEN_DAY_WINDOW_MS);
86  const scoped = scopedWindows.map((w) => paceOf(w.percent, w.resetAt, SEVEN_DAY_WINDOW_MS));
87  const sevenDayThreshold = display?.sevenDayThreshold ?? 80;
88
89  return {
90    fiveHour,
91    sevenDay,
92    scoped,
93    alert: [fiveHour, sevenDay, ...scoped].some(isPaceAlert),
94    showSevenDay: usage.sevenDay !== null
95      && (usage.sevenDay >= sevenDayThreshold || isPaceAlert(sevenDay)),
96  };
97}
98
hooks/i18n.ts 595 lines
1// The mod's own strings (what it adds to claude-hud's lines), in the language
2// claude-hud is set to (`language` in its config), so one setting covers the HUD.
3import { type CanonicalLanguage, getCanonicalLanguage, interpolate } from './hud/i18n/index.js'
4
5/** A string, or its plural forms by `Intl.PluralRules` category (`other` required). */
6type Text = string | { one?: string; few?: string; many?: string; other: string }
7
8type Key =
9  | 'rc.label'
10  | 'rc.attached'
11  | 'rc.connected'
12  | 'surface.mobile'
13  | 'surface.desktop'
14  | 'forecast'
15  | 'limit.fiveHour'
16  | 'limit.sevenDay'
17  | 'limit.scoped'
18  | 'week'
19  | 'streak'
20  | 'git.dirty'
21  | 'git.ahead'
22  | 'compact.left'
23  | 'cache.cold'
24  | 'turn.growth'
25  | 'alert.context'
26  | 'alert.fiveHour'
27  | 'alert.sevenDay'
28  | 'alert.scoped'
29  | 'turn.done'
30  | 'cmd.description'
31  | 'theme.set'
32  | 'theme.list'
33  | 'theme.unknown'
34  | 'theme.ask'
35  | 'cmd.hidden'
36  | 'cmd.shown'
37  | 'pane.title'
38  | 'pane.opened'
39  | 'pane.closed'
40  | 'pane.tools'
41  | 'pane.noTools'
42  | 'pane.toolStats'
43  | 'pane.failed'
44  | 'pane.agents'
45  | 'pane.none'
46  | 'pane.todos'
47  | 'pane.spend'
48  | 'pane.turns'
49  | 'pane.turnRow'
50  | 'spend.today'
51  | 'spend.week'
52  | 'summary.language'
53
54// French (and German, Spanish, Russian) set a percent sign off with a space; French
55// also a colon. A no-break space keeps the sign on its number.
56const NB = ' '
57
58export const MESSAGES: Record<CanonicalLanguage, Record<Key, Text>> = {
59  en: {
60    'rc.label': '⇄ Remote Control',
61    'rc.attached': 'connected: {who}',
62    'rc.connected': 'connected',
63    'surface.mobile': 'phone',
64    'surface.desktop': 'web/desktop',
65    forecast: '{label} runs out ≈{time} at this pace',
66    'limit.fiveHour': '5-hour limit',
67    'limit.sevenDay': '7-day limit',
68    'limit.scoped': '{name} weekly limit',
69    week: '7d',
70    streak: '{n}-day streak',
71    'git.dirty': { one: '{n} uncommitted change', other: '{n} uncommitted changes' },
72    'git.ahead': { one: '{n} unpushed commit', other: '{n} unpushed commits' },
73    'compact.left': '{tokens} to auto-compact',
74    'cache.cold': 'cache cold: next message re-caches {tokens}',
75    'turn.growth': 'last turn +{tokens}',
76    'alert.context': 'Context is {p}% full — consider /compact',
77    'alert.fiveHour': '5-hour limit at {p}%',
78    'alert.sevenDay': '7-day limit at {p}%',
79    'alert.scoped': '{name} weekly limit at {p}%',
80    'turn.done': '✓ Done in {d}',
81    'cmd.description': 'Show or hide the claude-hud band; detail opens the details pane; theme switches its look',
82    'theme.set': 'HUD theme: {name}',
83    'theme.list': 'HUD themes (now {name}): {list}. /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}
595
hooks/language.ts 27 lines
1// 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