SLOPSHOPPER

hud

Heads-up display for Claude Code and Copilot CLI: token-weather band with PR, git and limit forecasts, turn footer, tool rows, spinner, /weather, /subagents…

panebandspinnerrowsguardcommand
v0.11.0MITupdated 2026-10-24giocaizzi/harness/plugins/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
│ ┃ Token weather ✕ › fix the failing auth test and add an audit log call │ ┃ context %, one bar per turn │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ █ ⟨Claude Code's own drawing⟩ +0 -0 │ ┃ █ ⏺ Bash(bun test) │ ┃ turn 1  49%  93% ⎿ 3 pass, 1 fail │ ┃  5h 31% │ ┃ ▂ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ last turn: 2.1k in · 91.0k read · 4.3k │ ┃ written · 1.5k out ✻ Worked… · ↑97.4k ↓1.5k ↺93% │ ┃ ━━━━━━━━━━━━━━━━━━━━ 4:59 │ ┃ [ Close ] › /weather │ ⎿ hud: Token weather pane opened. │ │ ⟨Claude Code's own drawing⟩ [ #undefined] [ ~2 +14 -3 ?1] [ 93%] [ 4:59] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ #undefined] [ ~2 +14 -3 ?1] [ 93%] [ 4:59]
Pane · Token weather
context %, one bar per turn █ █ turn 1  49%  93%  5h 31% ▂ last turn: 2.1k in · 91.0k read · 4.3k written · 1.5k out━━━━━━━━━━━━━━━━━━━━ 4:59 [ Close ]
Pane · Subagents
No subagents yet. [ Close ]
Pane · Context
No context figures yet. [ Close ]
Turn line
✻ Worked… · ↑97.4k ↓1.5k ↺93%
Tool row
⟨Claude Code's own drawing⟩ +0 -0
README

hud plugin

Heads-up display for Claude Code (a mod), a status line for Claude Code and Copilot CLI, a footer and reply rewrites for pi (hooks/pi.mjs, loaded through hooks/pi-entry.mjs), and running subagents in opencode's TUI (hooks/opencode-tui.mjs). No skills or agents. Architecture and the install/verify runbook: docs/hud.md.

Parts

PartWhereHarnessWhat it shows
token-weather bandmod AbovePromptClaude[ cache %] [ TTL] [sparkline]: last turn's cache hit rate, prompt-cache countdown, context over the last 8 turns. Indented one column like the status line.
PR and CI stripmod AbovePromptClaude[ #56 ✓ 3/3] the branch's PR or MR on GitHub, GitLab or Azure DevOps (click to open) and its passed checks (an hourglass while some still run; merged or closed once it is), [↑2 ↓1] unpushed work, [~3 +120 -30 ?2] uncommitted changes (files, lines added and removed, untracked).
/weather panemod PaneClaudeContext chart, per-turn table, last turn's tokens, 5h and 7d history, TTL gauge.
Spinnermod SpinnerClaude Running, Searching, Editing, … while a tool runs.
Limit forecastmod AbovePromptClaude[ 5h full ~16:40] when a window fills before it resets at the pace of the last turns.
Subagentsmod AbovePrompt, /subagents paneClaude[ 2] running subagents; the pane lists each with type, task, model, time, tokens and how it ended.
Turn footermod TurnDurationClaudeBaked for 3s · ↑10.0k ↓1.2k ↺90% $0.04 (↑ in, ↓ out, ↺ cache hit).
Tool rowsmod ToolUseClaudeAn icon per tool coloured by outcome, +12 -4 on edits, the call's time.
/ctx panemod PaneClaudeThe context breakdown as a coloured grid with one row per category.
Reply rewritesmod AssistantMessage; pi message_endClaude, piEach a setting, with its glyph and colour (no colour on pi): #123 / !45 / owner/repo#12 / AB#77 as links with their open, merged, closed or draft state; absolute paths as <repo> › path and file:line opening the editor (editor: vscode, cursor, zed, idea, sublime, file, custom); commit SHAs as links; long URLs as owner/repo#12; ISO times as 13 Oct 20:56 (5h ago); ✓ ✗ ⊘ statuses with counts; tokens and keys masked to their prefix. Settings and defaults: docs/hud.md.
/refresh-refsmod command.run; pi commandClaude, piForgets the looked-up states, files and commits so replies ask again (states also refresh by themselves after 5 minutes).
pi footerpi extension (hooks/pi.mjs)piContext weather, model, PR with checks, unpushed and uncommitted work, after each run; each running subagent call with its model and reported thinking level: [ scout · <model> · <thinking>].
Status linestatusLine.commandClaude, CopilotClaude: ┌[ user@host] [ branch *] [ gcp] [ session] and └[ path] [ model] [ 55%] [ 5h 62% 2h15m] [ 7d 5% 3d4h] [ 1.84]. Copilot: [ 9%] [ 50%] [ 3 req], and each running subagent with its model and effort: [ scout · gpt-6-luna · low · 1m 3s].
opencode subagentsTUI plugin (hooks/opencode-tui.mjs, slot session_prompt_right)opencodeEach running subagent beside the prompt: [ scout · <model> · <variant>].
Agent-panel rowssubagentStatusLine.commandClaudeThe default subagent row with the subagent's model and effort before the time: ticket-103 Ticket-run #103 Sonnet 5.5 · high · 3m 49s · ↓ 286.1k tokens.

Install

/plugin install hud@giocaizzi-harness          # Claude Code
copilot plugin install hud@giocaizzi-harness   # Copilot CLI
pi install git:github.com/giocaizzi/harness      # pi (the whole repo)
make install HARNESS=opencode                    # opencode, from a clone (TUI plugin in tui.json)

Status-line settings for both CLIs: docs/hud.md.

Source 10 files
hooks/mod.mjs 352 lines
1// Claude Code mod (hooks.json `modules`; Copilot ignores the key): the token-weather band and `/weather` pane with
2// the PR and CI strip and the limit forecasts; the turn footer, tool rows and spinner, the subagent tracker and the
3// context pane register from their own files. Toasts and sounds are the `sounds` plugin's.
4import { atom, read, update } from 'claude-code'
5import { registerAgents } from './agents.mjs'
6import { registerContext } from './context.mjs'
7import { registerFooter } from './footer.mjs'
8import { linkRepo } from './links.mjs'
9import { toTree } from './markup.mjs'
10import { createFacts, fetchState, refsRule, rewrite, settings } from './rewrite.mjs'
11import { registerTools } from './tools.mjs'
12import { ICON, TTL_MS, azurePr, battery, clock, forecast, forgeOf, fullAt, githubPr, gitlabMr, glyph, repoState, sparkCells, sparkRange, tokens, when } from './weather.mjs'
13
14// The last main-loop turn: its usage, when it ended, and how many turns this conversation has had.
15const weather = atom({ plugin: 'hud', key: 'weather' }, null)
16// Context % and cache hit % of the last turns (null: unknown), oldest first.
17const history = atom({ plugin: 'hud', key: 'history' }, [])
18
19// Written here, drawn by footer.mjs and agents.mjs (each file declares the atoms it reads): the main loop's turns for
20// the footer, and the subagents for the tracker.
21const turns = atom({ plugin: 'hud', key: 'turns' }, [])
22const agents = atom({ plugin: 'hud', key: 'agents' }, [])
23const KEEP_TURNS = 200
24
25// The repository a reply's work-item references link to (links.mjs linkRepo); null: no remote or another forge.
26let linkTo = null
27// The branch's PR, unpushed work and uncommitted changes (the band says them).
28const repo = atom({ plugin: 'hud', key: 'repo' }, null)
29const REPO_REFRESH_MS = 60_000
30// Checks passed, failed, or some still running (an hourglass: nf-fa-hourglass_half).
31const CI_ICON = { pass: '\u2713', fail: '\u2717', pending: ICON.pending }
32const CI_TONE = { pass: 'ok', fail: 'bad', pending: 'warn' }
33
34const PANE = 'weather'
35const HISTORY_TURNS = 24
36const BAND_TURNS = 8
37const TONE_COLOR = { ok: undefined, warn: '#f9e2af', bad: '#f38ba8' }
38
39// A bar chart of the history as one Raster; a value of unknown context counts as empty.
40const spark = (Raster, key, values, rows, lo, hi) =>
41  Raster({ key, columns: values.length, rows, cells: new Uint8Array(Uint32Array.from(sparkCells(values, rows, lo, hi)).buffer).toBase64() })
42
43// A /clear: forget the last turn and redraw.
44async function reset($) {
45  await update($, weather, () => null)
46  await update($, history, () => [])
47  $.ui.invalidate('ui.render')
48}
49
50async function sessionCost($) {
51  try {
52    return (await $.session.usage()).cost?.usd
53  } catch {
54    return undefined // usage unavailable
55  }
56}
57
58async function contextPercent($) {
59  try {
60    return (await $.session.usage()).context.percent
61  } catch {
62    return undefined // usage unavailable
63  }
64}
65
66// The branch's PR (with its checks) from the forge the origin remote names, unpushed work and uncommitted changes;
67// each part is null where git or the forge's CLI (gh, glab, az) cannot say.
68// argv's stdout, or null where it fails or is missing.
69const runner = ($, timeoutMs = 8000) => async (argv) => {
70  try {
71    const r = await $.process.run(argv, { timeoutMs })
72    return r.exitCode === 0 ? r.stdout : null
73  } catch {
74    return null // not a repository, or the tool is missing
75  }
76}
77
78// The reply rewrites' lookups (rewrite.mjs createFacts), cached for the session: files, commits, work-item states.
79const replyFacts = ($) =>
80  createFacts({
81    stat: async (p) => {
82      const s = await $.fs.stat(p).catch(() => null)
83      return s?.kind === 'file' || s?.kind === 'dir' ? s.kind : null
84    },
85    commit: async (sha) => (await runner($, 3000)(['git', 'cat-file', '-e', `${sha}^{commit}`])) !== null,
86    state: (r, ref) => fetchState(runner($, 8000), r, ref),
87  })
88
89async function refreshRepo($) {
90  const run = runner($)
91  const json = async (argv) => {
92    const out = await run(argv)
93    try {
94      return out ? JSON.parse(out) : null
95    } catch {
96      return null // not JSON: an old CLI or a prompt
97    }
98  }
99  const forge = forgeOf((await run(['git', 'remote', 'get-url', 'origin']))?.trim())
100  linkTo = await linkRepo(run)
101  let pr = null
102  if (forge === 'github') pr = githubPr(await json(['gh', 'pr', 'view', '--json', 'number,url,state,statusCheckRollup']))
103  if (forge === 'gitlab') pr = gitlabMr(await json(['glab', 'mr', 'view', '--output', 'json']))
104  if (forge === 'azure') {
105    const branch = (await run(['git', 'branch', '--show-current']))?.trim()
106    const list = branch ? await json(['az', 'repos', 'pr', 'list', '--source-branch', branch, '--status', 'all', '--top', '1', '--output', 'json']) : null
107    const id = list?.[0]?.pullRequestId
108    pr = azurePr(list, id ? await json(['az', 'repos', 'pr', 'policy', 'list', '--id', String(id), '--output', 'json']) : null)
109  }
110  const counts = await run(['git', 'rev-list', '--left-right', '--count', '@{u}...HEAD'])
111  const shortstat = await run(['git', 'diff', '--shortstat', 'HEAD'])
112  const porcelain = await run(['git', 'status', '--porcelain'])
113  await update($, repo, () => repoState(pr, counts, shortstat, porcelain))
114}
115
116// The rate-limit windows by kind (five_hour, seven_day): percent used and the reset in epoch ms; empty when unavailable.
117async function windows($) {
118  try {
119    return Object.fromEntries((await $.session.usage()).rateLimits.map((r) => [r.kind, { pct: r.percentUsed, resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : null }]))
120  } catch {
121    return {} // usage unavailable
122  }
123}
124
125export function register(on, options = {}) {
126  let facts = null
127  let settling = false
128  registerFooter(on)
129  registerTools(on)
130  registerAgents(on)
131  registerContext(on)
132  let ttlMs = TTL_MS['5m']
133  let tick
134  let repoTick
135
136  // Draw at once (the context part needs no turn); a reload keeps $.state, so the last turn stays.
137  on('session.start', async ($, e, next) => {
138    $.ui.invalidate('ui.render')
139    try {
140      ttlMs = TTL_MS[(await $.settings.read()).promptCacheTtl] ?? TTL_MS['5m']
141    } catch {
142      // settings unreadable: the API default
143    }
144    facts = replyFacts($)
145    await refreshRepo($)
146    await $.command.register({ name: 'weather', description: 'Show the context and prompt-cache history in a pane' })
147    await $.command.register({ name: 'subagents', description: 'Show the subagents of this session in a pane' })
148    await $.command.register({ name: 'ctx', description: 'Show what fills the context window in a pane' })
149    await $.command.register({ name: 'refresh-refs', description: 'Look up the issue and PR states, files and commits in replies again' })
150    tick?.cancel()
151    repoTick?.cancel()
152    repoTick = $.clock.every(REPO_REFRESH_MS, () => refreshRepo($))
153    // The TTL countdown: one redraw a second while the band shows a last turn.
154    tick = $.clock.every(1000, async () => {
155      if ((await read($, weather)) || (await read($, agents)).some((x) => x.state === 'running')) $.ui.invalidate('ui.render')
156    })
157    return next(e)
158  })
159
160  on('session.end', async ($, e, next) => {
161    if (e.reason === 'clear') await reset($)
162    return next(e)
163  })
164
165  // A subagent's turn ends its row in the tracker; the weather and the footer follow the main loop only, since a
166  // subagent's turn says nothing about this conversation's cache.
167  on('turn.complete', async ($, e, next) => {
168    if (e.agentId) {
169      const at = await $.clock.now()
170      const u = e.usage
171      const used = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
172      const state = e.reason === 'answer' ? 'done' : 'failed'
173      await update($, agents, (a) => a.map((x) => (x.id === e.agentId ? { ...x, endedAt: at, state, tokens: x.tokens + used } : x)))
174    }
175    if (!e.agentId && e.usage) {
176      const usd = await sessionCost($)
177      await update($, turns, (t) => {
178        const prev = t.at(-1)?.usd
179        return [...t, { durationMs: e.durationMs, usage: e.usage, usd: usd ?? null, cost: usd != null && prev != null ? usd - prev : null }].slice(-KEEP_TURNS)
180      })
181      // The footer can draw before this turn lands; draw it again so it gets its numbers.
182      $.ui.invalidate('ui.render')
183      const at = await $.clock.now()
184      const lim = await windows($)
185      const { ctx, hit } = forecast({ percent: await contextPercent($), usage: e.usage, turns: 2 })
186      await update($, weather, (w) => ({ usage: e.usage, at, turns: (w?.turns ?? 0) + 1 }))
187      const sample = (w) => ({ pct: w?.pct ?? null, resetsAt: w?.resetsAt ?? null })
188      await update($, history, (h) => [...h, { at, ctx, hit, five: sample(lim.five_hour), seven: sample(lim.seven_day) }].slice(-HISTORY_TURNS))
189    }
190    if (!e.agentId) await refreshRepo($)
191    return next(e)
192  })
193
194  // Our line under whatever the plugins beneath draw (next-steps and the like), never in its place: last in the
195  // band, it sits right above the prompt, and a blank row such a plugin leads with stays above the band.
196  // The context % and its weather glyph are in the status line; the band says the work in flight and the cache facts.
197  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
198    const below = await next(e)
199    if (e.props.hasSurvey) return below
200    const w = await read($, weather)
201    const now = await $.clock.now()
202    const f = forecast({ percent: await contextPercent($), usage: w?.usage, turns: w?.turns ?? 0, ttlMs, sinceMs: w ? now - w.at : undefined, showCtx: false })
203    const all = await read($, history)
204    const h = all.slice(-BAND_TURNS)
205    const { Box, Text, Raster, Link } = $.ui.resolve(e)
206    const dim = (children) => Text({ dimColor: true, children: [children] })
207    // Each part in brackets, as the prompt and the status line draw theirs: dim while fine, coloured when not.
208    const bracket = (inner) => [dim('['), inner, dim(']')]
209    const part = (text, tone) => bracket(Text({ color: TONE_COLOR[tone], dimColor: tone === 'ok', children: [text] }))
210    const label = (x) => (ICON[x.kind] ? `${ICON[x.kind]} ${x.value}` : x.value)
211    const r = await read($, repo)
212    const c = r?.changes
213    // Uncommitted work: files changed, lines added and removed, untracked files.
214    const changes = c && (c.files || c.untracked) ? [c.files ? `~${c.files} +${c.added} -${c.removed}` : '', c.untracked ? `?${c.untracked}` : ''].filter(Boolean).join(' ') : null
215    const ahead = r && (r.ahead || r.behind) ? `${r.ahead ? `\u2191${r.ahead}` : ''}${r.ahead && r.behind ? ' ' : ''}${r.behind ? `\u2193${r.behind}` : ''}` : null
216    // Every part is `[icon value]`. The PR number opens the PR on a click; what follows it says its state or checks.
217    const prState = (pr) =>
218      pr.state === 'merged'
219        ? Text({ color: '#cba6f7', children: [` ${ICON.merged} merged`] })
220        : pr.state === 'closed'
221          ? Text({ dimColor: true, children: [' closed'] })
222          : pr.ci
223            ? Text({ color: TONE_COLOR[CI_TONE[pr.ci]], dimColor: pr.ci === 'pass', children: [` ${CI_ICON[pr.ci]} ${pr.passed}/${pr.total}`] })
224            : null
225    const prPart = r?.pr
226      ? bracket(
227          Box({
228            flexDirection: 'row',
229            children: [
230              Text({ dimColor: true, children: [`${ICON.pr} `] }),
231              r.pr.url ? Link({ href: r.pr.url, label: `#${r.pr.number}` }) : Text({ children: [`#${r.pr.number}`] }),
232              prState(r.pr),
233            ].filter(Boolean),
234          }),
235        )
236      : null
237    const running = (await read($, agents)).filter((x) => x.state === 'running').length
238    // A window that fills before it resets, at the pace of the last turns.
239    const full = [['5h', 'five'], ['7d', 'seven']].flatMap(([label, key]) => {
240      const last = all.at(-1)?.[key]
241      const at = fullAt(all.map((x) => ({ at: x.at, pct: x[key]?.pct ?? null })), last?.resetsAt)
242      return at == null ? [] : [part(`${ICON.forecast} ${label} full ~${when(at, now)}`, 'warn')]
243    })
244    const ctxs = h.map((x) => x.ctx ?? 0)
245    const parts = [
246      prPart,
247      ahead ? part(`${ICON.sync} ${ahead}`, 'ok') : null,
248      changes ? part(`${ICON.changes} ${changes}`, 'ok') : null,
249      running ? part(`${ICON.agents} ${running}`, 'ok') : null,
250      ...full,
251      ...f.segments.map((x) => part(label(x), x.tone)),
252      h.length > 1 ? bracket(Box({ flexDirection: 'row', children: [Text({ dimColor: true, children: [`${ICON.trend} `] }), spark(Raster, 'weather-spark', ctxs, 1, ...sparkRange(ctxs))] })) : null,
253    ].filter(Boolean)
254    if (!parts.length) return below
255    // One column in from the edge, as the status line is drawn.
256    const line = Box({
257      flexDirection: 'row',
258      paddingLeft: 1,
259      children: [
260        ...parts.flatMap((p, i) => [i ? Text({ children: [' '] }) : null, ...p]),
261      ].filter(Boolean),
262    })
263    // Claude Code keeps a blank row under the band; a blank row on top pairs it, unless a plugin beneath already
264    // leads its block with one (next-steps does). The engine's own drawing under the band takes no rows.
265    const alone = !below || below.type === 'engine'
266    return Box({ flexDirection: 'column', marginTop: alone ? 1 : 0, children: below ? [below, line] : [line] })
267  })
268
269  // Reply rewrites (rewrite.mjs): secrets masked, long URLs, work-item references with their state, paths and
270  // file:line, commits, timestamps and pass/fail, each a setting. Display only: the stored message keeps the model's
271  // text. A rewrite never waits: what is not known yet (a file, a commit, a state) is drawn as written, then drawn
272  // again once its lookup is in. Colour needs a tree of our own (markup.mjs), as markdown carries none.
273  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
274    const env = { NO_COLOR: await $.env.get('NO_COLOR') }
275    const o = settings(options, env, true)
276    facts ??= replyFacts($)
277    const cwd = await $.session.cwd().catch(() => null)
278    const ctx = { repo: linkTo, cwd, home: (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')), now: await $.clock.now() }
279    const text = rewrite(e.props.text, o, ctx, facts)
280    if (facts.pending && !settling) {
281      settling = true
282      void facts.settle(10_000).then(() => {
283        settling = false
284        $.ui.invalidate('ui.render')
285      })
286    }
287    if (text === e.props.text) return next(e)
288    return toTree(text, $.ui.resolve(e), { first: e.props.isFirstOfReply, columns: e.viewport?.columns }) ?? next({ ...e, props: { ...e.props, text } })
289  })
290
291  // The rule that a reference of another repository is written whole (rewrite.mjs refsRule), after the engine's
292  // sections, so a bare #N is never linked to the wrong repository.
293  on('prompt.compose', async ($, e, next) => {
294    const r = await next(e)
295    const text = refsRule(linkTo, settings(options, {}, true))
296    return text ? { sections: [...r.sections, { id: 'hud:refs', text, scope: 'session' }] } : r
297  })
298
299  // /refresh-refs: forget every lookup and redraw the replies, which ask again (rewrite.mjs createFacts).
300  on('command.run', { command: 'refresh-refs' }, async ($) => {
301    facts = replyFacts($)
302    $.ui.invalidate('ui.render')
303    return { text: 'Reference states, files and commits will be looked up again as the replies redraw.' }
304  })
305
306  // /weather: the history at full size.
307  on('command.run', { command: 'weather' }, async ($) => {
308    const opened = await $.ui.open({ id: PANE, title: 'Token weather', focus: true })
309    return { text: opened.isPlaced ? 'Token weather pane opened.' : 'Widen the terminal to see the token weather pane.' }
310  })
311
312  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
313    const { Box, Text, Button, Raster } = $.ui.resolve(e)
314    const w = await read($, weather)
315    const h = await read($, history)
316    const left = w ? ttlMs - ((await $.clock.now()) - w.at) : null
317    const filled = left == null ? 0 : Math.round((20 * Math.max(0, left)) / ttlMs)
318    return Box({
319      flexDirection: 'column',
320      children: [
321        h.length ? Text({ dimColor: true, children: ['context %, one bar per turn'] }) : Text({ dimColor: true, children: ['No turns yet.'] }),
322        h.length ? spark(Raster, 'weather-pane', h.map((x) => x.ctx ?? 0), 4) : null,
323        ...h.slice(-8).map((x, i, rows) =>
324          Text({ children: [`turn ${w.turns - (rows.length - 1 - i)}  ${glyph(x.ctx) || ' '}  ${x.ctx ?? '?'}%  ${ICON.cache} ${x.hit ?? '?'}%`] }),
325        ),
326        ...[['5h', 'five'], ['7d', 'seven']].flatMap(([label, key]) => {
327          const series = h.map((x) => x[key]?.pct ?? 0)
328          const now = h.at(-1)?.[key]?.pct
329          return now == null ? [] : [Text({ children: [`${battery(now)} ${label} ${Math.round(now)}%`] }), spark(Raster, `weather-${key}`, series, 1, 0, 100)]
330        }),
331        w ? Text({ dimColor: true, children: [`last turn: ${tokens(w.usage.input_tokens)} in · ${tokens(w.usage.cache_read_input_tokens)} read · ${tokens(w.usage.cache_creation_input_tokens)} written · ${tokens(w.usage.output_tokens)} out`] }) : null,
332        // The TTL gauge: a heavy rule for what is left, a light one for what is spent.
333        left == null
334          ? null
335          : Box({
336              flexDirection: 'row',
337              children:
338                left > 0
339                  ? [
340                      Text({ children: [`${ICON.ttl} `] }),
341                      Text({ color: TONE_COLOR[left <= 60_000 ? 'warn' : 'ok'] ?? '#a6e3a1', children: ['━'.repeat(filled)] }),
342                      Text({ dimColor: true, children: ['─'.repeat(20 - filled)] }),
343                      Text({ children: [` ${clock(left)}`] }),
344                    ]
345                  : [Text({ color: TONE_COLOR.bad, children: ['cache expired'] })],
346            }),
347        Button({ label: 'Close', hotkey: 'q', onPress: () => $.ui.close({ id: PANE }) }),
348      ].filter(Boolean),
349    })
350  })
351}
352
hooks/agents.mjs 51 lines
1// The subagent tracker: each spawned subagent with its type, model, state, time and tokens, in `/subagents`; the
2// band counts the running ones.
3import { atom, read, update } from 'claude-code'
4import { duration, tokens } from './weather.mjs'
5
6const agents = atom({ plugin: 'hud', key: 'agents' }, [])
7const PANE = 'subagents'
8const KEEP = 30
9const STATE = { running: ['…', '#f9e2af'], done: ['✓', '#a6e3a1'], failed: ['✗', '#f38ba8'] }
10
11export function registerAgents(on) {
12  on('agent.spawn', async ($, e, next) => {
13    const r = await next(e)
14    if (r.agentId) {
15      const at = await $.clock.now()
16      const row = { id: r.agentId, type: e.subagentType, description: e.description, model: r.model, startedAt: at, endedAt: null, state: 'running', tokens: 0 }
17      await update($, agents, (a) => [...a, row].slice(-KEEP))
18    }
19    return r
20  }).catch(($, e, next) => next(e)) // a failed record never blocks the spawn
21
22  on('command.run', { command: 'subagents' }, async ($) => {
23    const opened = await $.ui.open({ id: PANE, title: 'Subagents', focus: true })
24    return { text: opened.isPlaced ? 'Subagents pane opened.' : 'Widen the terminal to see the subagents pane.' }
25  })
26
27  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
28    const { Box, Text, Button } = $.ui.resolve(e)
29    const a = await read($, agents)
30    const now = await $.clock.now()
31    return Box({
32      flexDirection: 'column',
33      children: [
34        a.length ? null : Text({ dimColor: true, children: ['No subagents yet.'] }),
35        ...a.map((x) =>
36          Box({
37            key: x.id,
38            flexDirection: 'row',
39            children: [
40              Text({ color: STATE[x.state][1], children: [`${STATE[x.state][0]} `] }),
41              Text({ children: [`${x.type} `] }),
42              Text({ dimColor: true, children: [`${x.description} · ${x.model} · ${duration((x.endedAt ?? now) - x.startedAt)}${x.tokens ? ` · ${tokens(x.tokens)} tok` : ''}`] }),
43            ],
44          }),
45        ),
46        Button({ label: 'Close', hotkey: 'q', onPress: () => $.ui.close({ id: PANE }) }),
47      ].filter(Boolean),
48    })
49  })
50}
51
hooks/context.mjs 61 lines
1// `/ctx`: the context window broken down as /context breaks it, live, in a pane.
2import { tokens } from './weather.mjs'
3
4const PANE = 'ctx'
5// Catppuccin Mocha, one colour per used category in order; free space and the buffer are dim.
6const PALETTE = [0x89b4fa, 0xcba6f7, 0xa6e3a1, 0xf9e2af, 0xfab387, 0x94e2d5, 0xf38ba8, 0x74c7ec, 0xf5c2e7, 0xb4befe]
7const DIM = 0x585b70
8const hex = (n) => `#${n.toString(16).padStart(6, '0')}`
9
10// The breakdown's grid as Raster cells: a full square where a category fills it, a light one where it is free.
11export function gridCells(rows, colorOf) {
12  const out = []
13  for (const row of rows) for (const sq of row) out.push(sq.isFilled ? 0x25a0 : 0x25a1, colorOf(sq.categoryName), 0x01000000)
14  return out
15}
16
17async function breakdown($, columns) {
18  try {
19    return (await $.session.usage({ breakdown: 'summary', columns })).context.breakdown
20  } catch {
21    return undefined // usage unavailable
22  }
23}
24
25export function registerContext(on) {
26  on('command.run', { command: 'ctx' }, async ($) => {
27    const opened = await $.ui.open({ id: PANE, title: 'Context', focus: true })
28    return { text: opened.isPlaced ? 'Context pane opened.' : 'Widen the terminal to see the context pane.' }
29  })
30
31  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
32    const { Box, Text, Button, Raster } = $.ui.resolve(e)
33    const b = await breakdown($, e.props.bodyColumns)
34    if (!b) return Box({ flexDirection: 'column', children: [Text({ dimColor: true, children: ['No context figures yet.'] }), Button({ label: 'Close', hotkey: 'q', onPress: () => $.ui.close({ id: PANE }) })] })
35    const used = b.categories.filter((c) => c.kind === 'used')
36    const colorOf = (name) => {
37      const i = used.findIndex((c) => c.name === name)
38      return i < 0 ? DIM : PALETTE[i % PALETTE.length]
39    }
40    const columns = Math.max(0, ...b.gridRows.map((r) => r.length))
41    const rows = b.gridRows.map((r) => [...r, ...Array(columns - r.length).fill({ isFilled: false, categoryName: '' })])
42    return Box({
43      flexDirection: 'column',
44      children: [
45        Text({ children: [`${b.model} · ${tokens(b.totalTokens)} of ${tokens(b.maxTokens)} (${Math.round(b.percentage)}%)`] }),
46        columns ? Raster({ key: 'ctx-grid', columns, rows: rows.length, cells: new Uint8Array(Uint32Array.from(gridCells(rows, colorOf)).buffer).toBase64() }) : null,
47        ...b.categories
48          .filter((c) => c.tokens > 0)
49          .map((c) =>
50            Box({
51              key: c.name,
52              flexDirection: 'row',
53              children: [Text({ color: hex(colorOf(c.name)), children: ['■ '] }), Text({ children: [`${c.name} `] }), Text({ dimColor: true, children: [`${tokens(c.tokens)} · ${((100 * c.tokens) / b.maxTokens).toFixed(1)}%`] })],
54            }),
55          ),
56        Button({ label: 'Close', hotkey: 'q', onPress: () => $.ui.close({ id: PANE }) }),
57      ].filter(Boolean),
58    })
59  })
60}
61
hooks/footer.mjs 19 lines
1// The turn footer: the engine's `Baked for 3s` line, followed by what the turn read, wrote and cost.
2import { atom, read } from 'claude-code'
3import { footerText } from './weather.mjs'
4
5// The main loop's last turns: duration (the footer's key), usage, the session cost after it and the turn's own.
6const turns = atom({ plugin: 'hud', key: 'turns' }, [])
7
8export function registerFooter(on) {
9  // A footer carries no turn id, so its turn is the latest one of the same duration.
10  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
11    const own = await next(e)
12    const t = (await read($, turns)).findLast((x) => x.durationMs === e.props.durationMs)
13    if (!own || !t) return own
14    const { Box, Text } = $.ui.resolve(e)
15    // flexWrap moves the tail whole onto its own line when the row is narrow, instead of wrapping it in a column.
16    return Box({ flexDirection: 'row', flexWrap: 'wrap', children: [own, Text({ dimColor: true, wrap: 'truncate-end', children: [` · ${footerText(t)}`] })] })
17  })
18}
19
hooks/links.mjs 102 lines
1// Work-item references in the agent's reply made clickable: `#123`, `!45`, `owner/repo#12`, `AB#77` become links to
2// the forge of the session's remote (pickRemote: upstream, else origin). Pure but linkRepo, which takes a runner: no
3// API calls, so a `#N` on GitHub links to /issues/N, which GitHub redirects to /pull/N for a pull request.
4import { forgeOf, ICON } from './weather.mjs'
5
6// One glyph and colour per kind: an issue, a PR or MR, an Azure Boards work item (Nerd Font, as weather.mjs's ICON).
7export const KIND = {
8  issue: { icon: '', rgb: [166, 227, 161] }, // nf-oct-issue_opened, green
9  pr: { icon: ICON.pr, rgb: [203, 166, 247] }, // nf-oct-git_pull_request, mauve as the band's merged PR
10  workitem: { icon: '', rgb: [137, 180, 250] }, // nf-oct-tasklist, blue
11}
12
13// The repository a remote URL names: { forge, host, path } for GitHub (github.com or an Enterprise host) and GitLab,
14// { forge: 'azure', org, project, repo } for Azure DevOps; null for any other forge or an unreadable URL.
15// Takes https://host/path(.git), ssh://user@host[:port]/path and scp-like user@host:path.
16export function repoOf(remote) {
17  const url = remote?.trim()
18  if (!url) return null
19  const forge = forgeOf(url) ?? (/github/i.test(url) ? 'github' : null)
20  const m = /^(?:[a-z][\w+.-]*:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.+?)(?:\.git)?\/?$/i.exec(url)
21  if (!forge || !m) return null
22  const [, host, path] = m
23  if (forge !== 'azure') return { forge, host, path }
24  const p = path.split('/')
25  // dev.azure.com/org/project/_git/repo, ssh.dev.azure.com:v3/org/project/repo, org.visualstudio.com/[DefaultCollection/]project/_git/repo
26  if (p[0] === 'v3' && p.length === 4) return { forge, org: p[1], project: p[2], repo: p[3] }
27  const git = p.indexOf('_git')
28  if (git < 1 || !p[git + 1]) return null
29  const vs = /^([^.]+)\.visualstudio\.com$/i.exec(host)
30  return { forge, org: vs ? vs[1] : p[0], project: p[git - 1], repo: p[git + 1] }
31}
32
33// The remote whose forge holds the work items, from `git remote`'s names: upstream in a fork (its issues and PRs live
34// on the parent), else origin, else the first; null without one.
35export function pickRemote(names) {
36  const n = (names ?? '').split(/\s+/).filter(Boolean)
37  return n.includes('upstream') ? 'upstream' : n.includes('origin') ? 'origin' : (n[0] ?? null)
38}
39
40// The repository references link to, read through run(argv) -> stdout | null (git in the session's directory).
41export async function linkRepo(run) {
42  const name = pickRemote(await run(['git', 'remote']))
43  return name ? repoOf(await run(['git', 'remote', 'get-url', name])) : null
44}
45
46// The link and kind of one reference, or null where the forge has no such reference.
47// ref: { repo?: 'owner/repo' (another repository), sigil: '#' | '!' | 'AB#', n }.
48export function target(r, { repo, sigil, n }) {
49  if (!r) return null
50  // A GitHub repository is exactly owner/repo; a deeper path (`docs/x/y#1`) is no reference.
51  if (r.forge === 'github') return sigil === '#' && (!repo || /^[\w-]+\/[\w.-]+$/.test(repo)) ? { url: `https://${r.host}/${repo ?? r.path}/issues/${n}`, kind: 'issue' } : null
52  if (r.forge === 'gitlab') {
53    const base = `https://${r.host}/${repo ?? r.path}`
54    return sigil === '#' ? { url: `${base}/-/issues/${n}`, kind: 'issue' } : sigil === '!' ? { url: `${base}/-/merge_requests/${n}`, kind: 'pr' } : null
55  }
56  if (repo) return null // Azure DevOps has no owner/repo#N
57  const base = `https://dev.azure.com/${r.org}/${r.project}`
58  return sigil === '!' ? { url: `${base}/_git/${r.repo}/pullrequest/${n}`, kind: 'pr' } : { url: `${base}/_workitems/edit/${n}`, kind: 'workitem' }
59}
60
61// Spans left as written (code spans, markdown links, autolinks, bare URLs), then a reference: a word boundary before
62// it (not `&#1;`, `a/#1`, `x#1`), `owner/repo` (first segment without a dot, so `github.com/o/r#1` is no reference)
63// or `AB`, the sigil, digits, and no word character after (`#fff`, `#1a2b` are colours, `#!` a shebang).
64const SPAN = /(`+)[^`]*?\1|\[[^\]\n]*\]\([^)\n]*\)|<[a-z][\w+.-]*:[^>\n]*>|\b[a-z][\w+.-]*:\/\/[^\s)>]+|(?<![\w&/#!@.\\-])(?:([\w-]+(?:\/[\w.-]+)+)|(AB))?([#!])(\d+)(?![\w-])/g
65const FENCE = /^ {0,3}(```|~~~)/
66
67// The text with each reference made a link. format: 'markdown' writes `[ref](url)` for a renderer that draws links
68// (Claude Code, pi); 'ansi' writes an OSC 8 hyperlink for a raw terminal. glyph puts the kind's icon before the
69// reference; color (ansi only: markdown has no colour) paints it in the kind's colour. Fenced code is left alone.
70export function linkify(text, remote, { format = 'markdown', glyph = false, color = false } = {}) {
71  const r = typeof remote === 'string' ? repoOf(remote) : remote
72  if (!r || !text) return text
73  let fence = null
74  return text
75    .split('\n')
76    .map((line) => {
77      const f = FENCE.exec(line)?.[1]
78      if (fence) {
79        if (f === fence) fence = null
80        return line
81      }
82      if (f) {
83        fence = f
84        return line
85      }
86      return line.replace(SPAN, (all, _tick, repo, ab, sigil, n) => {
87        if (!n || (ab && sigil !== '#')) return all
88        const t = target(r, { repo, sigil: ab ? 'AB#' : sigil, n })
89        if (!t) return all
90        const k = KIND[t.kind]
91        const label = glyph ? `${k.icon} ${all}` : all
92        if (format === 'markdown') return `[${label}](${t.url})`
93        const painted = color ? `\x1b[38;2;${k.rgb.join(';')}m${label}\x1b[39m` : label
94        return `\x1b]8;;${t.url}\x1b\\${painted}\x1b]8;;\x1b\\`
95      })
96    })
97    .join('\n')
98}
99
100// Glyphs and colour unless NO_COLOR is set (https://no-color.org: any non-empty value).
101export const styled = (env) => !env?.NO_COLOR
102
hooks/markup.mjs 257 lines
1// Styled spans inside a reply's markdown, and the Claude Code tree that draws them in colour. Markdown carries no
2// colour and Claude Code refuses escape sequences in a rewritten reply, so a transform marks a span
3// (`﷐style﷑text﷒`, Unicode noncharacters, never in a model's text) and `toTree` draws each line holding
4// one as its own Text row, the rest as the engine's Markdown. Pure: the element constructors come in.
5
6const [OPEN, SEP, CLOSE] = ['﷐', '﷑', '﷒']
7export const MARK = new RegExp(`${OPEN}([^${SEP}]*)${SEP}([^${CLOSE}]*)${CLOSE}`, 'g')
8
9// A span drawn in a style (STYLE's keys); with colour off, the text alone.
10export const mark = (style, text, colour = true) => (colour && text ? `${OPEN}${style}${SEP}${text}${CLOSE}` : text)
11export const unmark = (text) => text.replace(MARK, '$2')
12
13// Theme keys where the theme has one, so a light theme stays legible; else Catppuccin Mocha, as the band.
14export const STYLE = {
15  dim: { dimColor: true },
16  name: { bold: true },
17  line: { color: '#fab387' }, // peach: a line number
18  sha: { color: '#f9e2af' }, // yellow: a commit
19  ok: { color: 'success' },
20  bad: { color: 'error' },
21  skip: { dimColor: true },
22  open: { color: 'success' },
23  merged: { color: 'merged' },
24  completed: { color: 'merged' },
25  not_planned: { color: 'inactive' },
26  closed: { color: 'error' },
27  draft: { color: 'inactive' },
28  issue: { color: '#a6e3a1' },
29  pr: { color: '#cba6f7' },
30  workitem: { color: '#89b4fa' },
31  host: { color: '#89b4fa' },
32  time: { color: '#94e2d5' }, // teal: a moment
33  secret: { color: 'warning' },
34}
35
36// Markdown's backslash escapes, which transforms write in labels (`\[`, `\_`) and a Text row draws plain.
37export const escapeMd = (s) => s.replace(/[\\`*_[\]]/g, '\\$&')
38const unescape = (s) => s.replace(/\\([\\`*_{}[\]()#+\-.!|<>~])/g, '$1')
39
40// A link the Markdown element draws (only https, http and file; any other scheme is drawn as text).
41const LINK = /\[((?:\\.|[^\]\\\n])*)\]\(([^)\s]*)\)/
42const OTHER_SCHEME = /\]\((?!https?:|file:)[a-z][\w+.-]*:/i
43const INLINE = new RegExp(`${MARK.source}|${LINK.source}|(\`+)([^\`]+?)\\5|\\*\\*(.+?)\\*\\*|(?<![\\w*])\\*(?![\\s*])(.+?)(?<![\\s*])\\*(?![\\w*])`, 'g')
44const CODE = { color: '#b4befe' } // the theme's inline code is lavender-blue
45
46// One markdown line's inline content as flat runs { text, props, href }: marked spans styled, links with their
47// target, code, bold, italic.
48function spans(s, base = {}, href = undefined, out = []) {
49  let at = 0
50  const plain = (t) => t && out.push({ text: unescape(t), props: base, href })
51  for (const m of s.matchAll(INLINE)) {
52    plain(s.slice(at, m.index))
53    at = m.index + m[0].length
54    const [, style, marked, label, link, , code, bold, italic] = m
55    if (style !== undefined) out.push({ text: unescape(marked), props: { ...base, ...STYLE[style] }, href })
56    else if (link !== undefined) spans(label, base, link, out)
57    else if (code !== undefined) out.push({ text: code, props: CODE, href })
58    else if (bold !== undefined) spans(bold, { ...base, bold: true }, href, out)
59    else spans(italic, { ...base, italic: true }, href, out)
60  }
61  plain(s.slice(at))
62  return out
63}
64
65const drawRun = (r, el, base) => (r.props === base ? r.text : el.Text({ ...r.props, children: [r.text] }))
66
67// Runs as Text children: a link's runs inside one Link.
68function draw(rs, el, base = {}) {
69  const out = []
70  for (let i = 0; i < rs.length; i++) {
71    const { href } = rs[i]
72    if (!href) {
73      out.push(drawRun(rs[i], el, base))
74      continue
75    }
76    let j = i
77    while (j < rs.length && rs[j].href === href) j++
78    out.push(el.Link({ href, children: rs.slice(i, j).map((r) => drawRun(r, el, base)) }))
79    i = j - 1
80  }
81  return out.length ? out : ['']
82}
83
84// One markdown line's inline content as Text children: marked spans styled, links as Link, code, bold, italic.
85export const inline = (s, el, base = {}) => draw(spans(s, base), el, base)
86
87// A line of its own: a heading bold, a list item with the engine's bullet, a quote dim, the rest inline.
88function row(line, el) {
89  const h = /^ {0,3}#{1,6}\s+(.*)$/.exec(line)
90  if (h) return el.Text({ bold: true, children: inline(h[1], el, { bold: true }) })
91  const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)
92  if (li) return el.Text({ children: [`${li[1]}${/\d/.test(li[2]) ? li[2] : '-'} `, ...inline(li[3], el)] })
93  const q = /^\s*>\s?(.*)$/.exec(line)
94  if (q) return el.Text({ dimColor: true, children: ['▎ ', ...inline(q[1], el)] })
95  return el.Text({ children: inline(line, el) })
96}
97
98// --- tables ------------------------------------------------------------------------------------------------------
99
100// Terminal cells a string takes: wide and fullwidth characters and emoji two, combining marks and joiners none, an
101// emoji presentation selector one more. An estimate the layout never depends on: each cell is a Box of its width.
102const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]|\p{Emoji_Presentation}/u
103const ZERO = /[\p{Mn}\p{Me}\u200B-\u200F\u2060\uFE00-\uFE0E]/u
104export const cellWidth = (s) => {
105  let w = 0
106  for (const ch of s) w += ch === '\uFE0F' ? 1 : ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1
107  return w
108}
109
110// A table row's cells; a separator row (`|---|:-:|`) is null.
111const split = (line) => line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim())
112const cells = (line) => (SEPARATOR.test(line) && line.includes('-') ? null : split(line))
113const ALIGN = { left: 'flex-start', center: 'center', right: 'flex-end' }
114const alignOf = (sep) => (/^:-+:$/.test(sep) ? 'center' : /-:$/.test(sep) ? 'right' : 'left')
115
116// Runs wrapped into lines of at most `width` cells: at spaces, a word wider than the line split by character.
117function wrap(rs, width) {
118  const lines = [[]]
119  let used = 0
120  const put = (r, text) => {
121    const line = lines.at(-1)
122    const last = line.at(-1)
123    if (last && last.props === r.props && last.href === r.href) last.text += text
124    else line.push({ ...r, text })
125    used += cellWidth(text)
126  }
127  for (const r of rs) {
128    for (const word of r.text.split(/(\s+)/)) {
129      if (!word) continue
130      const w = cellWidth(word)
131      if (/^\s/.test(word)) {
132        if (used && used + w <= width) put(r, word)
133        continue
134      }
135      if (used && used + w > width) {
136        lines.push([])
137        used = 0
138      }
139      for (const ch of w > width ? [...word] : [word]) {
140        if (used && used + cellWidth(ch) > width) {
141          lines.push([])
142          used = 0
143        }
144        put(r, ch)
145      }
146    }
147  }
148  for (const line of lines) while (line.length && /^\s*$/.test(line.at(-1).text)) line.pop()
149  return lines
150}
151
152// A markdown table drawn as the engine draws one, its cells keeping their colour and links. Columns take their widest
153// cell, and when the table is wider than the room the widest give way, their cells wrapping. Every line of a row is a
154// Box row of fixed-width cell Boxes, so the borders stay straight whatever a cell's characters measure.
155function table(lines, el, room) {
156  const rows = lines.map(cells)
157  const sep = lines.find((_l, i) => rows[i] === null)
158  const body = rows.filter(Boolean)
159  const n = Math.max(...body.map((r) => r.length))
160  const aligns = sep ? split(sep).map(alignOf) : []
161  const content = rows.map((r, i) => r && Array.from({ length: n }, (_, c) => spans(r[c] ?? '', i === 0 && rows[1] === null ? { bold: true } : {})))
162  const natural = (rs) => rs.reduce((w, r) => w + cellWidth(r.text), 0)
163  const longest = (rs) => Math.max(1, ...rs.flatMap((r) => r.text.split(/\s+/).map(cellWidth)))
164  const w = Array.from({ length: n }, (_, c) => Math.max(1, ...content.filter(Boolean).map((r) => natural(r[c]))))
165  const floor = w.map((x, c) => Math.min(x, Math.max(3, Math.min(12, Math.max(...content.filter(Boolean).map((r) => longest(r[c])))))))
166  const avail = room ? room - (3 * n + 1) : Infinity
167  while (w.reduce((a, b) => a + b, 0) > avail) {
168    const c = w.reduce((best, x, i) => (x > floor[i] && (best < 0 || x > w[best]) ? i : best), -1)
169    if (c < 0) break
170    w[c]--
171  }
172  const dim = (s) => el.Text({ dimColor: true, children: [s] })
173  const rule = (l, m, r) => dim(l + w.map((x) => '─'.repeat(x + 2)).join(m) + r)
174  const out = [rule('┌', '┬', '┐')]
175  for (const r of content) {
176    if (!r) {
177      out.push(rule('├', '┼', '┤'))
178      continue
179    }
180    const wrapped = r.map((rs, c) => wrap(rs, w[c]))
181    const height = Math.max(...wrapped.map((l) => l.length))
182    for (let k = 0; k < height; k++) {
183      const kids = [dim('│ ')]
184      wrapped.forEach((ls, c) => {
185        const line = ls[k] ?? []
186        kids.push(el.Box({ width: w[c], flexShrink: 0, justifyContent: ALIGN[aligns[c] ?? 'left'], children: [el.Text({ wrap: 'truncate-end', children: draw(line, el) })] }))
187        kids.push(dim(c < n - 1 ? ' │ ' : ' │'))
188      })
189      out.push(el.Box({ flexDirection: 'row', children: kids }))
190    }
191  }
192  out.push(rule('└', '┴', '┘'))
193  return out
194}
195
196const FENCE = /^ {0,3}(```|~~~)/
197const isTable = (l) => /^\s*\|/.test(l)
198// A table row without the leading pipe, whose header the separator row below makes a table.
199const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
200const startsTable = (lines, i) => isTable(lines[i]) || (/(?<!\\)\|/.test(lines[i]) && SEPARATOR.test(lines[i + 1] ?? '') && lines[i + 1].includes('|'))
201const own = (l) => l.includes(OPEN) || OTHER_SCHEME.test(l)
202
203// The reply drawn as the engine would, but each line with a marked span (or a link the Markdown element would not
204// draw) as a Text row of its own, and a table holding one as a table of our own. null when nothing needs it: the
205// caller then hands the engine the text. first: the reply's opening block, which carries the `⏺` bullet; columns:
206// the viewport's width, which the table fits (less the two-column gutter).
207export function toTree(text, el, { first = false, columns = 0 } = {}) {
208  if (!text.includes(OPEN)) return null
209  const lines = text.split('\n')
210  const blocks = []
211  let md = []
212  const flush = () => {
213    while (md.length && !md[0].trim()) md.shift()
214    const gap = md.length && !md.at(-1).trim()
215    while (md.length && !md.at(-1).trim()) md.pop()
216    if (md.length) blocks.push(el.Markdown({ text: md.join('\n') }))
217    if (gap) blocks.push(el.Text({ children: [' '] }))
218    md = []
219  }
220  let fence = null
221  for (let i = 0; i < lines.length; i++) {
222    const l = lines[i]
223    const f = FENCE.exec(l)?.[1]
224    if (fence || f) {
225      if (fence && f === fence) fence = null
226      else if (!fence) fence = f
227      md.push(l)
228      continue
229    }
230    if (startsTable(lines, i)) {
231      let j = i + 1
232      while (j < lines.length && lines[j].trim() && /(?<!\\)\|/.test(lines[j])) j++
233      const t = lines.slice(i, j)
234      if (t.some(own)) {
235        flush()
236        blocks.push(...table(t, el, columns && columns - 2))
237      } else md.push(...t)
238      i = j - 1
239      continue
240    }
241    if (!own(l)) {
242      // A blank line between two own rows is drawn as one; inside markdown the engine spaces its blocks.
243      if (!l.trim() && !md.length && blocks.length) blocks.push(el.Text({ children: [' '] }))
244      else md.push(l)
245      continue
246    }
247    flush()
248    blocks.push(row(l, el))
249  }
250  flush()
251  // The engine's bullet and its two-column gutter, which a tree of our own replaces.
252  return el.Box({
253    flexDirection: 'row',
254    children: [el.Text({ children: [first ? '⏺ ' : '  '] }), el.Box({ flexDirection: 'column', flexShrink: 1, children: blocks })],
255  })
256}
257
hooks/rewrite.mjs 596 lines
1// Reply rewrites: one pipeline over the agent's reply that makes it quicker to scan, shared by the Claude mod (display
2// only, in colour through markup.mjs) and pi (message_end, no colour). Each transform is a setting (DEFAULTS, the
3// plugin's userConfig), leaves code spans, fenced code and existing links alone (but secret masking, which reaches
4// everywhere), and asks the world only through `facts`: cached lookups that answer undefined until known, so a
5// rewrite never waits; the caller settles them and draws again.
6import { KIND, target } from './links.mjs'
7import { ICON } from './weather.mjs'
8import { escapeMd as esc, mark } from './markup.mjs'
9
10// The settings and their defaults, as plugin.json's userConfig declares them. A feature is `on` (the global glyph and
11// colour settings apply), `off`, `no-glyph`, `no-colour` or `plain` (neither).
12export const FEATURES = ['replySecrets', 'replyUrls', 'replyRefs', 'replyState', 'replyPaths', 'replyFileLinks', 'replyCommits', 'replyTimes', 'replyStatus']
13export const DEFAULTS = { glyphs: true, colour: true, ...Object.fromEntries(FEATURES.map((f) => [f, 'on'])), editor: 'vscode', editorUrl: '', timeFormat: 'both' }
14
15// Per feature { glyph, colour }, or null when off. env: NO_COLOR (any non-empty value) turns glyphs and colour off,
16// as it does for the reply links; canColour: the harness draws colour at all (Claude Code yes, pi no).
17export function settings(options = {}, env = {}, canColour = true) {
18  const o = { ...DEFAULTS, ...Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined && v !== null)) }
19  const plain = !!env.NO_COLOR
20  const f = {}
21  for (const k of FEATURES) {
22    const mode = o[k] === true ? 'on' : o[k] === false ? 'off' : o[k]
23    f[k] = mode === 'off' ? null : { glyph: !plain && o.glyphs !== false && mode !== 'no-glyph' && mode !== 'plain', colour: canColour && !plain && o.colour !== false && mode !== 'no-colour' && mode !== 'plain' }
24  }
25  return { ...o, f }
26}
27
28// Nerd Font glyphs, each drawn followed by a space (weather.mjs's rule).
29export const GLYPH = {
30  dir: '', // nf-fa-folder
31  file: '', // nf-fa-file
32  editor: '', // nf-oct-code
33  commit: '', // nf-oct-git_commit
34  lock: '', // nf-fa-lock
35  clock: '', // nf-fa-clock_o
36  github: '', // nf-fa-github
37  gitlab: '', // nf-fa-gitlab
38  azure: '', // nf-cod-azure_devops
39  globe: '', // nf-fa-globe
40}
41// A file-type icon by extension (nf-seti, nf-dev), the plain file icon for any other.
42const EXT = { ts: '', tsx: '', js: '', mjs: '', cjs: '', jsx: '', py: '', md: '', json: '', sh: '', bash: '', zsh: '', ps1: '', go: '', rs: '', rb: '', java: '', html: '', css: '', yml: '', yaml: '', toml: '', sql: '', lock: '' }
43export const fileGlyph = (name, kind) => (kind === 'dir' ? GLYPH.dir : (EXT[/\.([^.]+)$/.exec(name)?.[1]?.toLowerCase()] ?? GLYPH.file))
44
45// State glyphs as GitHub draws them: open green, merged and completed purple, closed red, draft and not planned grey.
46export const STATE = {
47  issue: { open: '', closed: '', completed: '', not_planned: '' }, // nf-oct-issue_opened, issue_closed, skip
48  pr: { open: ICON.pr, merged: ICON.merged, closed: '', draft: '' }, // git_pull_request(_closed, _draft), git_merge
49  workitem: { open: KIND.workitem.icon, closed: '' },
50}
51
52// --- the walk: fences and protected spans ----------------------------------------------------------------------
53
54const FENCE = /^ {0,3}(```|~~~)/
55// Spans no transform rewrites: code spans, markdown links (labels may hold escaped brackets), autolinks, bare URLs
56// and spans another transform marked.
57const PROTECT = /(`+)[^`]*?\1|\[(?:\\.|[^\]\\\n])*\]\([^)\n]*\)|<[a-z][\w+.-]*:[^>\n]*>|\b[a-z][\w+.-]*:\/\/[^\s)>]+|﷐[^﷒]*﷒/g
58
59// A table already drawn in characters (`+---+`, `┌──┬──┐`, `│ a │`): its columns are padded to the text as written.
60const BORDER = /^\s*[+┌├└╭╰┏┣┗╔╠╚╒╞╘][-─━═=+┬┼┴┐┤┘╮╯┓┫┛╗╣╝╤╪╧┳╋┻╦╬╩ ]*$/
61const BOXED = /^\s*[│┃║]/
62// The lines left as written: fenced code (also an unclosed fence, while a reply streams in) and each paragraph
63// holding a drawn table, whose columns a rewrite of another width would shift.
64export function kept(text) {
65  const all = text.split('\n')
66  const keep = all.map(() => false)
67  let fence = null
68  let start = 0
69  let drawn = false
70  const end = (i) => {
71    if (drawn) keep.fill(true, start, i)
72    start = i + 1
73    drawn = false
74  }
75  all.forEach((l, i) => {
76    const f = FENCE.exec(l)?.[1]
77    if (fence || f) {
78      if (fence && f === fence) fence = null
79      else if (!fence) fence = f
80      keep[i] = true
81      end(i)
82    } else if (!l.trim()) end(i)
83    else if ((BORDER.test(l) && /[-─━═]{3}/.test(l)) || BOXED.test(l)) drawn = true
84  })
85  end(all.length)
86  return all.map((l, i) => [l, keep[i]])
87}
88
89// Each line but the kept ones through fn(line).
90function lines(text, fn) {
91  return kept(text)
92    .map(([l, keep]) => (keep ? l : fn(l)))
93    .join('\n')
94}
95
96// Free text (outside fences and protected spans) through text.replace(re, fn), or protected spans through
97// span(s) when given.
98export function pass(text, re, fn, span) {
99  return lines(text, (line) => {
100    let out = ''
101    let at = 0
102    for (const m of line.matchAll(PROTECT)) {
103      out += line.slice(at, m.index).replace(re, fn) + (span ? span(m[0]) : m[0])
104      at = m.index + m[0].length
105    }
106    return out + line.slice(at).replace(re, fn)
107  })
108}
109
110const link = (label, url) => `[${label}](${url.replace(/[()\s]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)})`
111const glyphed = (g, ft, label) => (ft.glyph ? `${g} ${label}` : label)
112
113// --- secrets ---------------------------------------------------------------------------------------------------
114
115// Tokens by their prefix, kept; the rest is masked. Each needs a digit so a long hyphenated word is no key.
116const SECRET = new RegExp(
117  [
118    '\\b(gh[pousr]_)[A-Za-z0-9]{20,}',
119    '\\b(github_pat_)\\w{20,}',
120    '\\b(sk-ant-)(?=[\\w-]*\\d)[\\w-]{16,}',
121    '\\b(sk-(?:proj-)?)(?=[\\w-]*\\d)[\\w-]{20,}',
122    '\\b(A(?:KIA|SIA))[0-9A-Z]{16}\\b',
123    '\\b(xox[abprse]-)[\\w-]{10,}',
124    '\\b(Bearer\\s+)(?=[\\w.~+/=-]*\\d)[\\w.~+/=-]{16,}',
125  ].join('|'),
126  'g',
127)
128const KEY_BLOCK = /(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g
129const MASK = '••••••••'
130
131function secrets(text, ft) {
132  const prefix = (g) => g.find((x) => x !== undefined)
133  // Free text: a lock glyph, the prefix, the mask dimmed. Then everywhere else (code, URLs) plainly.
134  const styled = pass(text, SECRET, (_all, ...g) => mark('secret', glyphed(GLYPH.lock, ft, prefix(g)), ft.colour) + mark('dim', MASK, ft.colour))
135  return styled.replace(SECRET, (_all, ...g) => prefix(g) + MASK).replace(KEY_BLOCK, `$1\n${MASK}\n$2`)
136}
137
138// --- long URLs --------------------------------------------------------------------------------------------------
139
140const forgeOfHost = (host) => (/github/i.test(host) ? 'github' : /gitlab/i.test(host) ? 'gitlab' : /dev\.azure\.com|visualstudio\.com/i.test(host) ? 'azure' : null)
141
142// A URL's short label and its kind (issue, pr, workitem, commit, file, page), or null to leave it as written.
143// here: the session's repository (links.mjs repoOf), whose own references drop the repository name.
144export function shortUrl(href, here) {
145  let u
146  try {
147    u = new URL(href)
148  } catch {
149    return null
150  }
151  if (!/^https?:$/.test(u.protocol)) return null
152  const forge = forgeOfHost(u.host)
153  const p = u.pathname.split('/').filter(Boolean).map(decodeURIComponent)
154  const line = /^#L(\d+)/.exec(u.hash)?.[1]
155  const at = (path, sha) => `${path.split('/').at(-1)}@${sha.slice(0, 7)}`
156  const file = (path, rest) => `${path.split('/').at(-1)} › ${rest.join('/')}${line ? `:${line}` : ''}`
157  const own = (path) => here && here.host === u.host && here.path === path
158  if (forge === 'github' && p.length >= 2) {
159    const repo = `${p[0]}/${p[1]}`
160    const [kind, n] = [p[2], p[3]]
161    if ((kind === 'issues' || kind === 'pull') && /^\d+$/.test(n ?? '')) return { label: `${own(repo) ? '' : repo}#${n}`, kind: kind === 'pull' ? 'pr' : 'issue', forge }
162    if (kind === 'commit' && n) return { label: at(repo, n), kind: 'commit', forge }
163    if ((kind === 'blob' || kind === 'tree') && p.length > 4) return { label: file(repo, p.slice(4)), kind: 'file', forge }
164    if (p.length === 2) return { label: repo, kind: 'page', forge }
165  }
166  if (forge === 'gitlab') {
167    const dash = p.indexOf('-')
168    if (dash > 1) {
169      const repo = p.slice(0, dash).join('/')
170      const [kind, n] = [p[dash + 1], p[dash + 2]]
171      if ((kind === 'issues' || kind === 'merge_requests') && /^\d+$/.test(n ?? '')) return { label: `${own(repo) ? '' : repo}${kind === 'issues' ? '#' : '!'}${n}`, kind: kind === 'issues' ? 'issue' : 'pr', forge }
172      if (kind === 'commit' && n) return { label: at(repo, n), kind: 'commit', forge }
173      if ((kind === 'blob' || kind === 'tree') && p.length > dash + 3) return { label: file(repo, p.slice(dash + 3)), kind: 'file', forge }
174    }
175  }
176  if (forge === 'azure') {
177    const git = p.indexOf('_git')
178    const repo = git > 0 ? p[git + 1] : null
179    if (repo && p[git + 2] === 'pullrequest' && /^\d+$/.test(p[git + 3] ?? '')) return { label: `${repo}!${p[git + 3]}`, kind: 'pr', forge }
180    if (repo && p[git + 2] === 'commit' && p[git + 3]) return { label: at(repo, p[git + 3]), kind: 'commit', forge }
181    if (repo && u.searchParams.get('path')) return { label: `${repo} › ${u.searchParams.get('path').replace(/^\//, '')}${u.searchParams.get('line') ? `:${u.searchParams.get('line')}` : ''}`, kind: 'file', forge }
182    const wi = p.indexOf('_workitems')
183    if (wi > 0 && p[wi + 1] === 'edit' && /^\d+$/.test(p[wi + 2] ?? '')) return { label: `AB#${p[wi + 2]}`, kind: 'workitem', forge }
184  }
185  // Any other page: the host and a shortened path, only where that is shorter than the URL.
186  if (href.length <= 40) return null
187  const host = u.host.replace(/^www\./, '')
188  const path = p.length > 2 ? `/${p[0]}/…/${p.at(-1)}` : p.length ? `/${p.join('/')}` : ''
189  return { label: `${host}${path}`, kind: 'page', forge }
190}
191
192function urls(text, ft, here) {
193  const one = (href, tail = '') => {
194    const s = shortUrl(href, here)
195    if (!s) return null
196    const g = GLYPH[s.forge] ?? GLYPH.globe
197    const style = { issue: 'issue', pr: 'pr', workitem: 'workitem', commit: 'sha' }[s.kind] ?? 'host'
198    return link(mark(style, glyphed(g, ft, esc(s.label)), ft.colour), href) + tail
199  }
200  return pass(text, /(?!)/, null, (span) => {
201    // A bare URL (trailing punctuation is the sentence's), an autolink, or a link whose label is its own URL.
202    let m = /^[a-z][\w+.-]*:\/\/\S+$/i.exec(span)
203    if (m) {
204      const [, href, tail] = /^(.*?)([.,;:!?]*)$/.exec(span)
205      return one(href, tail) ?? span
206    }
207    if ((m = /^<(https?:[^>]+)>$/.exec(span))) return one(m[1]) ?? span
208    if ((m = /^\[(https?:[^\]]+)\]\((https?:[^)]+)\)$/.exec(span)) && m[1] === m[2]) return one(m[2]) ?? span
209    return span
210  })
211}
212
213// --- work-item references, with their state ---------------------------------------------------------------------
214
215const REF = /(?<![\w&/#!@.\\-])(?:([\w-]+(?:\/[\w.-]+)+)|(AB))?([#!])(\d+)(?![\w-])/g
216export const refKey = (r, { repo, sigil, n }) => `${r.forge}:${r.host ?? r.org}:${repo ?? r.path ?? r.project}:${sigil}${n}`
217
218function refs(text, ft, here, facts, st) {
219  if (!here) return text
220  return pass(text, REF, (all, repo, ab, sigil, n) => {
221    if (ab && sigil !== '#') return all
222    const ref = { repo, sigil: ab ? 'AB#' : sigil, n }
223    const t = target(here, ref)
224    if (!t) return all
225    // A GitHub #N may be an issue or a pull request: the state lookup says which.
226    const s = st ? facts.state(here, ref) : undefined
227    const kind = s?.kind ?? t.kind
228    const icon = (s && STATE[kind]?.[s.state]) || KIND[kind].icon
229    const colour = s ? st.colour : ft.colour
230    // A GitHub #N whose state is not known is drawn without a glyph: the issue one would call a pull request an issue.
231    const unsure = !s && st && here.forge === 'github' && ref.sigil === '#'
232    return link(mark(s ? s.state : kind, unsure ? esc(all) : glyphed(icon, ft, esc(all)), colour), t.url)
233  })
234}
235
236// The instruction that keeps references right: a bare #N links to the session's repository, so an item of any other
237// repository must be written whole. null when references are not rewritten or the repository is unknown.
238export function refsRule(here, o) {
239  if (!here || !o.f.replyRefs) return null
240  const name = here.forge === 'azure' ? `${here.org}/${here.project}` : here.path
241  const other = here.forge === 'gitlab' ? '`group/project#N` (`!N` for a merge request)' : here.forge === 'azure' ? 'its full URL' : '`owner/repo#N`'
242  return `Work-item references: a bare \`#N\` means an issue or pull request of ${name}, this session's repository. For one of any other repository write ${other}, never a bare \`#N\`.`
243}
244
245// --- paths and file:line ----------------------------------------------------------------------------------------
246
247// An absolute path (POSIX, `~/`, or a Windows drive) of two segments or more, or a relative file with an extension
248// and a line; either with `:line[:col]`.
249const PATH = /(?<![\w.~/\\:@$-])(~[/\\](?:[\w.@+-]+[/\\])*[\w.@+-]+|(?:[A-Za-z]:)?[/\\](?:[\w.@+-]+[/\\])+[\w.@+-]*|(?:\.{1,2}\/)?(?:[\w.@+-]+\/)*[\w@+-][\w.@+-]*\.[A-Za-z]\w*(?=:\d))(?::(\d+)(?::(\d+))?)?(?![\w/\\])/g
250
251const slash = (p) => p.replace(/\\/g, '/')
252const parent = (p) => p.slice(0, p.lastIndexOf('/')) || (p.startsWith('/') ? '/' : p)
253const isAbs = (p) => /^(\/|[A-Za-z]:\/)/.test(p)
254const base = (p) => p.slice(p.lastIndexOf('/') + 1)
255
256// A path made absolute against the session's directory and home, `.` and `..` folded; `/` separators.
257export function resolvePath(p, { cwd, home }) {
258  let s = slash(p)
259  if (s.startsWith('~/')) s = `${slash(home ?? '')}${s.slice(1)}`
260  else if (!isAbs(s)) s = `${slash(cwd ?? '')}/${s}`
261  const drive = /^[A-Za-z]:/.exec(s)?.[0] ?? ''
262  const out = []
263  for (const seg of s.slice(drive.length).split('/')) {
264    if (seg === '..') out.pop()
265    else if (seg && seg !== '.') out.push(seg)
266  }
267  return `${drive}/${out.join('/')}`
268}
269
270// The repository root above a path from the cached `.git` stats: a root, null for none, undefined while unknown.
271// Every ancestor is asked at once, so one round of lookups settles it.
272function rootOf(abs, facts) {
273  const dirs = []
274  for (let d = parent(abs); ; d = parent(d)) {
275    dirs.push(d)
276    if (parent(d) === d || /^[A-Za-z]:$/.test(d)) break
277  }
278  const dot = dirs.map((d) => facts.stat(`${d === '/' ? '' : d}/.git`))
279  for (let i = 0; i < dirs.length; i++) {
280    if (dot[i] === undefined) return undefined
281    if (dot[i]) return dirs[i]
282  }
283  return null
284}
285
286// How an absolute path is shown: under a repository `<repo> › <rel>` (a worktree in `.claude/worktrees/<x>` as
287// `<repo>[<x>]`), under home `~/<rel>`, else as written. { prefix, name } for the label; null while unknown.
288export function shortPath(abs, root, home) {
289  if (root) {
290    const wt = /^(.*)\/\.claude\/worktrees\/([^/]+)$/.exec(root)
291    const repo = wt ? `${base(wt[1])}[${wt[2]}]` : base(root)
292    const rel = abs.slice(root.length + 1)
293    return { prefix: `${repo} › ${rel.includes('/') ? `${parent(rel)}/` : ''}`, name: base(rel) }
294  }
295  const h = slash(home ?? '').replace(/\/$/, '')
296  const shown = h && abs.toLowerCase().startsWith(`${h.toLowerCase()}/`) ? `~${abs.slice(h.length)}` : abs
297  return { prefix: shown.includes('/') ? shown.slice(0, shown.lastIndexOf('/') + 1) : '', name: base(shown) }
298}
299
300// The editor URL for a file and line: a known editor's scheme, `file://`, or a template with {path} {line} {col}.
301export const EDITORS = {
302  vscode: 'vscode://file{path}:{line}:{col}',
303  cursor: 'cursor://file{path}:{line}:{col}',
304  zed: 'zed://file{path}:{line}:{col}',
305  idea: 'idea://open?file={path}&line={line}&column={col}',
306  sublime: 'subl://open?url=file://{path}&line={line}&column={col}',
307  file: 'file://{path}',
308}
309export function editorUrl(abs, line, col, { editor = 'vscode', editorUrl: custom = '' } = {}) {
310  const tpl = editor === 'custom' && custom ? custom : (EDITORS[editor] ?? EDITORS.vscode)
311  const path = encodeURI(abs.startsWith('/') ? abs : `/${abs}`)
312  return tpl.replaceAll('{path}', path).replaceAll('{line}', String(line ?? 1)).replaceAll('{col}', String(col ?? 1))
313}
314
315function paths(text, o, facts, ctx) {
316  const [fp, fl] = [o.f.replyPaths, o.f.replyFileLinks]
317  if (!fp && !fl) return text
318  const ft = { glyph: !!(fp?.glyph || fl?.glyph), colour: !!(fp?.colour || fl?.colour) }
319  return pass(text, PATH, (all, raw, line, col) => {
320    // A sentence's full stop is not the path's.
321    const dots = /\.+$/.exec(raw)?.[0] ?? ''
322    const p = raw.slice(0, raw.length - dots.length)
323    if (!p || (dots && line)) return all
324    const tail = all.slice(raw.length) // the :line[:col]
325    const abs = resolvePath(p, ctx)
326    const absolute = isAbs(slash(p)) || p.startsWith('~')
327    // Both asked before either answer is looked at: one round of lookups.
328    const kind = facts.stat(abs)
329    const root = fp && absolute ? rootOf(abs, facts) : null
330    if (!kind || root === undefined) return all // missing, or not known yet
331    const shown = fp && absolute ? shortPath(abs, root, ctx.home) : { prefix: p.slice(0, Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + 1), name: base(slash(p)) }
332    const g = line ? GLYPH.editor : fileGlyph(shown.name, kind)
333    const label =
334      (ft.glyph ? `${g} ` : '') +
335      mark('dim', esc(shown.prefix), ft.colour) +
336      mark('name', esc(shown.name), ft.colour) +
337      (tail ? mark('line', tail, ft.colour) : '')
338    if (!fl) return label + dots
339    const url = kind === 'dir' ? `file://${encodeURI(abs.startsWith('/') ? abs : `/${abs}`)}` : editorUrl(abs, line, col, o)
340    return link(label, url) + dots
341  })
342}
343
344// --- commit SHAs ------------------------------------------------------------------------------------------------
345
346// 7 to 40 hex digits holding a digit and a letter, as a word of its own (not `#1a2b3c4`, a UUID part or a path).
347const SHA = /(?<![\w#/@.:-])(?=[0-9a-f]{0,39}[a-f])(?=[0-9a-f]{0,39}\d)[0-9a-f]{7,40}(?![\w-])/g
348
349export function commitUrl(r, sha) {
350  if (!r) return null
351  if (r.forge === 'github') return `https://${r.host}/${r.path}/commit/${sha}`
352  if (r.forge === 'gitlab') return `https://${r.host}/${r.path}/-/commit/${sha}`
353  return `https://dev.azure.com/${r.org}/${r.project}/_git/${r.repo}/commit/${sha}`
354}
355
356function commits(text, ft, here, facts) {
357  return pass(text, SHA, (sha) => {
358    if (facts.commit(sha) !== true) return sha
359    const label = mark('sha', glyphed(GLYPH.commit, ft, sha), ft.colour)
360    const url = commitUrl(here, sha)
361    return url ? link(label, url) : label
362  })
363}
364
365// --- timestamps -------------------------------------------------------------------------------------------------
366
367const ISO = /(?<![\w:-])(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?(?![\w:])/g
368const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
369
370// How long ago (or until) a moment: 45s ago, 5m ago, 5h ago, 3d ago, in 2h.
371export function ago(ms, now) {
372  const d = Math.round((now - ms) / 1000)
373  const a = Math.abs(d)
374  const v = a < 60 ? `${a}s` : a < 3600 ? `${Math.floor(a / 60)}m` : a < 86400 ? `${Math.floor(a / 3600)}h` : a < 86400 * 60 ? `${Math.floor(a / 86400)}d` : a < 86400 * 730 ? `${Math.floor(a / (86400 * 30))}mo` : `${Math.floor(a / (86400 * 365))}y`
375  return d >= 0 ? `${v} ago` : `in ${v}`
376}
377
378// A local `13 Oct 20:56` (the year too when not this one).
379export function local(ms, now) {
380  const d = new Date(ms)
381  const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
382  const year = d.getFullYear() === new Date(now).getFullYear() ? '' : ` ${d.getFullYear()}`
383  return `${d.getDate()} ${MONTHS[d.getMonth()]}${year} ${hm}`
384}
385
386function times(text, ft, fmt, now) {
387  return pass(text, ISO, (all, y, mo, d, h, mi, s, zone) => {
388    // Without a zone the moment is local, as Date reads it.
389    const iso = `${y}-${mo}-${d}T${h}:${mi}:${s ?? '00'}${zone ? (zone === 'Z' ? 'Z' : zone.replace(/^([+-]\d{2})(\d{2})$/, '$1:$2')) : ''}`
390    const ms = Date.parse(iso)
391    if (Number.isNaN(ms) || +mo > 12 || +d > 31) return all
392    const abs = local(ms, now)
393    const rel = ago(ms, now)
394    const head = fmt === 'relative' ? rel : abs
395    const label = (ft.glyph ? `${GLYPH.clock} ` : '') + mark('time', head, ft.colour) + (fmt === 'both' ? ` ${mark('dim', `(${rel})`, ft.colour)}` : '')
396    return label
397  })
398}
399
400// --- pass / fail ------------------------------------------------------------------------------------------------
401
402const OK = /^(?:pass(?:ed|ing)?|ok|success(?:ful)?|succeeded|green|✓|✔|✅|✔️)$/i
403const BAD = /^(?:fail(?:ed|ing|ure)?|errou?red|error|broken|red|✗|✘|❌)$/i
404const SKIP = /^(?:skip(?:ped)?|pending|⊘|⏭|⏭️)$/i
405const STATUS_WORD = '(?:pass(?:ed|ing)?|ok|success(?:ful)?|succeeded|green|fail(?:ed|ing|ure)?|errored|error|broken|red|skip(?:ped)?|pending|✓|✔️?|✅|✗|✘|❌|⊘|⏭️?)'
406const SYMBOL = { ok: '✓', bad: '✗', skip: '⊘' }
407
408const statusOf = (w) => (OK.test(w) ? 'ok' : BAD.test(w) ? 'bad' : SKIP.test(w) ? 'skip' : null)
409
410// A status word drawn with its symbol and colour; a symbol alone becomes the canonical one.
411function badge(word, ft) {
412  const s = statusOf(word)
413  const symbolOnly = /^[^\w]+$/.test(word)
414  const text = symbolOnly ? SYMBOL[s] : ft.glyph ? `${SYMBOL[s]} ${word}` : word
415  return mark(s, text, ft.colour)
416}
417
418// The counts of a table or list: `12 ✓ · 1 ✗ · 2 ⊘`, zero counts left out.
419export const summary = (n, ft) =>
420  ['ok', 'bad', 'skip']
421    .filter((k) => n[k])
422    .map((k) => mark(k, `${n[k]} ${SYMBOL[k]}`, ft.colour))
423    .join(' · ')
424
425const ITEM = /^(\s*(?:[-*+]|\d+[.)])\s+)(.*)$/
426// An item's status: as its last word after a separator (`lint: passed`, `build — failed`, `e2e (skipped)`), or as its
427// first when that is a symbol or upper case (`✓ lint`, `PASS lint`), or a `name: status` line. Prose never: "the
428// tests passed" has no separator.
429const TAIL = new RegExp(`^(.*?\\S)(\\s*(?::|—|–|\\s-|→|->|=|\\.\\.\\.|…)\\s*|\\s+\\()(\\*{0,2})(${STATUS_WORD})(\\*{0,2})(\\)?)(\\s*\\.?)$`, 'iu')
430const HEAD = new RegExp(`^(\\*{0,2})(✓|✔️?|✅|✗|✘|❌|⊘|⏭️?|PASS(?:ED)?|FAIL(?:ED)?|OK|SKIP(?:PED)?|ERROR)(\\*{0,2})(:?\\s+)(\\S.*)$`, 'u')
431const NAMED = /^(\s*[^\s:|>#-][^:|]{0,60}:\s*)(\S+)(\s*\.?)$/
432
433function statusText(s, ft, count) {
434  let m = TAIL.exec(s)
435  if (m && (!m[6] || m[2].includes('('))) {
436    count(statusOf(m[4]))
437    return `${m[1]}${m[2]}${m[3]}${badge(m[4], ft)}${m[5]}${m[6]}${m[7]}`
438  }
439  m = HEAD.exec(s)
440  if (m) {
441    count(statusOf(m[2]))
442    return `${m[1]}${badge(m[2], ft)}${m[3]}${m[4]}${m[5]}`
443  }
444  return null
445}
446
447function status(text, ft) {
448  // A table cell or list item at a time; a table or list of two statuses or more gets its counts after it.
449  const out = []
450  let n = null
451  const close = () => {
452    if (n && n.ok + n.bad + n.skip >= 2) out.push('', summary(n, ft))
453    n = null
454  }
455  let kind = null
456  for (const [line, keep] of kept(text)) {
457    if (keep) {
458      close()
459      kind = null
460      out.push(line)
461      continue
462    }
463    const k = /^\s*\|/.test(line) ? 'table' : ITEM.test(line) ? 'list' : null
464    if (k !== kind) {
465      close()
466      kind = k
467    }
468    if (k) n ??= { ok: 0, bad: 0, skip: 0 }
469    const count = (s) => s && n && n[s]++
470    if (k === 'table') {
471      out.push(
472        line.replace(/(?<=\|)([^|]*)(?=\|)/g, (cell) => {
473          const w = cell.trim().replace(/^\*\*(.*)\*\*$/, '$1')
474          const s = statusOf(w)
475          if (!s) return cell
476          count(s)
477          return cell.replace(w, badge(w, ft))
478        }),
479      )
480      continue
481    }
482    if (k === 'list') {
483      const [, lead, body] = ITEM.exec(line)
484      const t = statusText(body, ft, count)
485      out.push(t ? lead + t : line)
486      continue
487    }
488    const m = NAMED.exec(line)
489    out.push(m && statusOf(m[2]) && !/`/.test(line) ? `${m[1]}${badge(m[2], ft)}${m[3]}` : line)
490  }
491  close()
492  return out.join('\n')
493}
494
495// --- facts --------------------------------------------------------------------------------------------------------
496
497// How long a state is believed: an item merges or closes while the session runs, and a failed lookup (a timeout, a
498// rate limit) is tried again soon rather than drawn wrong for the rest of the session.
499export const STATE_TTL_MS = 5 * 60_000
500export const MISS_TTL_MS = 30_000
501
502// Cached answers to stat, commit and state through the injected async readers; files and commits are looked up once
503// per session, states again after STATE_TTL_MS (MISS_TTL_MS when the lookup failed). A getter answers undefined
504// until its answer is in, and an expired one keeps answering the old value while the new lookup runs. settle(ms)
505// waits (at most ms) for those asked so far and resolves true when one came in.
506export function createFacts({ stat = async () => null, commit = async () => false, state = async () => null, now = Date.now } = {}) {
507  const known = new Map()
508  const pending = new Map()
509  const ask = (key, fn, ttl) => {
510    const k = known.get(key)
511    if (k && (!ttl || now() - k.at < (k.v === null ? ttl.miss : ttl.hit))) return k.v
512    if (!pending.has(key))
513      pending.set(
514        key,
515        Promise.resolve()
516          .then(fn)
517          .catch(() => null)
518          .then((v) => {
519            known.set(key, { v: v ?? null, at: now() })
520            pending.delete(key)
521          }),
522      )
523    return k?.v
524  }
525  return {
526    stat: (p) => ask(`stat:${p}`, () => stat(p)),
527    commit: (sha) => ask(`commit:${sha}`, () => commit(sha)),
528    state: (r, ref) => ask(`state:${refKey(r, ref)}`, () => state(r, ref), { hit: STATE_TTL_MS, miss: MISS_TTL_MS }),
529    get pending() {
530      return pending.size
531    },
532    async settle(ms = 2000) {
533      if (!pending.size) return false
534      let timer
535      const all = Promise.all([...pending.values()])
536      await Promise.race([all, new Promise((r) => (timer = setTimeout(r, ms)))])
537      clearTimeout(timer)
538      return true
539    },
540  }
541}
542
543// The state of a work item from the forge's CLI, through run(argv) -> stdout | null: { kind, state } where kind is
544// issue, pr or workitem and state open, merged, closed or draft; null when the CLI cannot say.
545export async function fetchState(run, r, { repo, sigil, n }) {
546  const json = async (argv) => {
547    try {
548      const out = await run(argv)
549      return out ? JSON.parse(out) : null
550    } catch {
551      return null
552    }
553  }
554  if (r.forge === 'github') {
555    const j = await json(['gh', 'api', '--hostname', r.host, `repos/${repo ?? r.path}/issues/${n}`])
556    if (!j?.state) return null
557    // Closed as completed (purple) or as not planned (grey), as GitHub draws them.
558    if (!j.pull_request) return { kind: 'issue', state: j.state !== 'closed' ? 'open' : j.state_reason === 'not_planned' ? 'not_planned' : 'completed' }
559    return { kind: 'pr', state: j.pull_request.merged_at ? 'merged' : j.state === 'closed' ? 'closed' : j.draft ? 'draft' : 'open' }
560  }
561  if (r.forge === 'gitlab') {
562    const mr = sigil === '!'
563    const j = await json(['glab', 'api', '--hostname', r.host, `projects/${encodeURIComponent(repo ?? r.path)}/${mr ? 'merge_requests' : 'issues'}/${n}`])
564    if (!j?.state) return null
565    const state = j.state === 'merged' ? 'merged' : j.state === 'closed' ? 'closed' : j.draft || j.work_in_progress ? 'draft' : 'open'
566    return { kind: mr ? 'pr' : 'issue', state }
567  }
568  const org = `https://dev.azure.com/${r.org}`
569  if (sigil === '!') {
570    const j = await json(['az', 'repos', 'pr', 'show', '--id', String(n), '--org', org, '--output', 'json'])
571    if (!j?.status) return null
572    return { kind: 'pr', state: j.status === 'completed' ? 'merged' : j.status === 'abandoned' ? 'closed' : j.isDraft ? 'draft' : 'open' }
573  }
574  const j = await json(['az', 'boards', 'work-item', 'show', '--id', String(n), '--org', org, '--output', 'json'])
575  const s = j?.fields?.['System.State']
576  return s ? { kind: 'workitem', state: /closed|done|removed|resolved|completed/i.test(s) ? 'closed' : 'open' } : null
577}
578
579// --- the pipeline ---------------------------------------------------------------------------------------------------
580
581// The reply rewritten by every feature that is on. o: settings(); ctx: { repo (links.mjs repoOf, or null), cwd,
582// home, now (epoch ms) }; facts: createFacts(). Marked spans (markup.mjs) only where a feature's colour is on.
583export function rewrite(text, o, ctx, facts) {
584  if (!text) return text
585  const f = o.f
586  let t = text
587  if (f.replySecrets) t = secrets(t, f.replySecrets)
588  if (f.replyUrls) t = urls(t, f.replyUrls, ctx.repo)
589  if (f.replyRefs) t = refs(t, f.replyRefs, ctx.repo, facts, f.replyState)
590  if (f.replyPaths || f.replyFileLinks) t = paths(t, o, facts, ctx)
591  if (f.replyCommits) t = commits(t, f.replyCommits, ctx.repo, facts)
592  if (f.replyTimes) t = times(t, f.replyTimes, o.timeFormat, ctx.now ?? Date.now())
593  if (f.replyStatus) t = status(t, f.replyStatus)
594  return t
595}
596
hooks/tools.mjs 49 lines
1// Tool rows: an icon coloured by how the call went, the lines an edit changed and how long the call took. The
2// spinner names the running tool in place of a random verb.
3import { atom, read, update } from 'claude-code'
4import { ACTIVITY, activityLabel, duration, editStat } from './weather.mjs'
5
6// The tool the model is running now, and how long each finished call took, by tool_use_id.
7const activity = atom({ plugin: 'hud', key: 'activity' }, null)
8const calls = atom({ plugin: 'hud', key: 'calls' }, {})
9const KEEP = 300
10const TONE_COLOR = { ok: '#a6e3a1', warn: '#f9e2af', bad: '#f38ba8' }
11
12export function registerTools(on) {
13  on('tool.call', async ($, e, next) => {
14    const t0 = await $.clock.now()
15    await update($, activity, () => e.tool)
16    try {
17      return await next(e)
18    } finally {
19      const ms = (await $.clock.now()) - t0
20      await update($, activity, () => null)
21      if (e.tool_use_id) await update($, calls, (c) => Object.fromEntries([...Object.entries(c).slice(1 - KEEP), [e.tool_use_id, ms]]))
22    }
23  })
24
25  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
26    const label = activityLabel(await read($, activity))
27    return next(label ? { ...e, props: { ...e.props, message: label } } : e)
28  })
29
30  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
31    const row = await next(e)
32    if (!row) return row
33    const p = e.props
34    const { Box, Text } = $.ui.resolve(e)
35    const tone = p.isErrored ? 'bad' : p.isRunning || p.isInterrupted ? 'warn' : 'ok'
36    const icon = (ACTIVITY[p.tool] ?? (String(p.tool).startsWith('mcp__') ? [''] : ['']))[0]
37    const ms = p.isRunning ? undefined : (await read($, calls))[p.tool_use_id]
38    const tail = [editStat(p.tool, p.input), ms == null ? null : duration(ms)].filter(Boolean).join(' · ')
39    return Box({
40      flexDirection: 'row',
41      children: [
42        Text({ color: TONE_COLOR[tone], children: [`${icon} `] }),
43        Box({ flexDirection: 'column', flexGrow: 1, children: [row] }),
44        tail ? Text({ dimColor: true, children: [` ${tail}`] }) : null,
45      ].filter(Boolean),
46    })
47  })
48}
49
hooks/weather.mjs 206 lines
1// Token weather facts, free of engine imports so node can test them. Pure: facts in, one display line out.
2// percent: context fullness; usage: the last main-loop turn's token counts; turns: main-loop turns since the
3// session (or /clear) began; ttlMs / sinceMs: the prompt-cache TTL and the time since that turn ended.
4export const TTL_MS = { '5m': 300_000, '1h': 3_600_000 }
5
6export const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`
7
8const BLOCKS = '▁▂▃▄▅▆▇█'
9
10// Nerd Font icons (BMP private use, so a Raster could take them too), checked present in JetBrainsMono Nerd Font,
11// which chezmoi installs and sets in Ghostty, VS Code and Windows Terminal. Each is drawn followed by a space:
12// Ghostty then draws the icon at full size instead of squeezing it into one cell.
13// One icon per part, the same in the status line and the band: every part is drawn `[icon value]`.
14export const ICON = {
15  user: '\uf007', // nf-fa-user
16  branch: '\ue725', // nf-dev-git_branch
17  cloud: '\uf0c2', // nf-fa-cloud (the GCP project)
18  session: '\uf02b', // nf-fa-tag
19  path: '\uf07c', // nf-fa-folder_open
20  model: '\uf2db', // nf-fa-microchip
21  cost: '\uf155', // nf-fa-usd
22  pr: '\uf407', // nf-oct-git_pull_request
23  merged: '\uf419', // nf-oct-git_merge
24  pending: '\uf252', // nf-fa-hourglass_half
25  sync: '\uf47f', // nf-oct-git_compare (ahead and behind)
26  changes: '\uf448', // nf-oct-pencil
27  agents: '\uf0c0', // nf-fa-users
28  forecast: '\uf244', // nf-fa-battery_empty
29  cache: '\uf472', // nf-oct-database
30  ttl: '\uf520', // nf-oct-stopwatch
31  trend: '\uf201', // nf-fa-line_chart
32  hint: '\uf071', // nf-fa-warning
33}
34
35// A weather icon for how full the context is: sunny, cloudy, showers, thunderstorm, lightning (nf-weather-*).
36export const glyph = (ctx) => (ctx == null ? '' : ctx < 25 ? '\ue30d' : ctx < 50 ? '\ue302' : ctx < 75 ? '\ue319' : ctx < 90 ? '\ue31d' : '\ue315')
37
38// A battery for how much of a rate-limit window is left (nf-fa-battery_0..4, full to empty as `used` rises).
39export const battery = (used) => String.fromCharCode(0xf244 - Math.min(4, Math.max(0, Math.round((100 - used) / 25))))
40
41// The spinner's word while a tool runs: a Nerd Font icon and a verb, null for a tool with no personality.
42export const ACTIVITY = {
43  Bash: ['\uf489', 'Running'],
44  Read: ['\uf4a5', 'Reading'],
45  Grep: ['\uf422', 'Searching'],
46  Glob: ['\uf422', 'Searching'],
47  Edit: ['\uf448', 'Editing'],
48  Write: ['\uf448', 'Editing'],
49  NotebookEdit: ['\uf448', 'Editing'],
50  WebFetch: ['\uf0ac', 'Browsing'],
51  WebSearch: ['\uf0ac', 'Browsing'],
52  Agent: ['\uf0c0', 'Delegating'],
53}
54export function activityLabel(tool) {
55  const a = ACTIVITY[tool] ?? (String(tool).startsWith('mcp__') ? ['\uf1e6', 'Calling'] : null)
56  return a ? `${a[0]} ${a[1]}` : null
57}
58
59// The forge a remote URL belongs to: github, gitlab, azure, or null for any other.
60export function forgeOf(url) {
61  if (!url) return null
62  if (/github\.com[:/]/.test(url)) return 'github'
63  if (/dev\.azure\.com|visualstudio\.com/.test(url)) return 'azure'
64  if (/gitlab/.test(url)) return 'gitlab'
65  return null
66}
67
68// A pull request as every forge's reader returns it: number, url, state (open | merged | closed) and one entry per
69// check (pass | fail | pending).
70// GitHub: `gh pr view --json number,url,state,statusCheckRollup`.
71export function githubPr(j) {
72  if (!j) return null
73  const check = (c) =>
74    ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE', 'ACTION_REQUIRED'].includes(c.conclusion ?? c.state)
75      ? 'fail'
76      : (c.status ? c.status !== 'COMPLETED' : c.state === 'PENDING')
77        ? 'pending'
78        : 'pass'
79  return { number: j.number, url: j.url ?? null, state: (j.state ?? 'OPEN').toLowerCase(), checks: (j.statusCheckRollup ?? []).map(check) }
80}
81
82// GitLab: `glab mr view --output json` (the API's merge request): its head pipeline is its one check.
83export function gitlabMr(j) {
84  if (!j) return null
85  const p = (j.head_pipeline ?? j.pipeline)?.status
86  const check = !p ? [] : p === 'success' || p === 'skipped' ? ['pass'] : ['failed', 'canceled'].includes(p) ? ['fail'] : ['pending']
87  return { number: j.iid, url: j.web_url ?? null, state: j.state === 'opened' ? 'open' : j.state === 'merged' ? 'merged' : 'closed', checks: check }
88}
89
90// Azure DevOps: `az repos pr list --source-branch <branch> --status all --top 1` and `az repos pr policy list --id <n>`:
91// each policy evaluation is a check.
92export function azurePr(list, policies) {
93  const j = list?.[0]
94  if (!j) return null
95  const check = (p) => (p.status === 'rejected' || p.status === 'broken' ? 'fail' : p.status === 'running' || p.status === 'queued' ? 'pending' : 'pass')
96  const state = j.status === 'completed' ? 'merged' : j.status === 'abandoned' ? 'closed' : 'open'
97  return { number: j.pullRequestId, url: j.repository?.webUrl ? `${j.repository.webUrl}/pullrequest/${j.pullRequestId}` : null, state, checks: (policies ?? []).map(check) }
98}
99
100// The branch's pull request (from githubPr, gitlabMr or azurePr; null: none), unpushed work from
101// `git rev-list --left-right --count @{u}...HEAD` ("behind<TAB>ahead", null: no upstream) and uncommitted changes from
102// `git diff --shortstat HEAD` and `git status --porcelain`. ci: fail | pending | pass; passed of total checks.
103export function repoState(pr, counts, shortstat = null, porcelain = null) {
104  const [behind = 0, ahead = 0] = (counts ?? '').trim().split(/\s+/).map(Number)
105  const checks = pr?.checks ?? []
106  const ci = checks.includes('fail') ? 'fail' : checks.includes('pending') ? 'pending' : checks.length ? 'pass' : null
107  const n = (re) => Number(re.exec(shortstat ?? '')?.[1] ?? 0)
108  const untracked = (porcelain ?? '').split('\n').filter((l) => l.startsWith('??')).length
109  return {
110    pr: pr ? { number: pr.number, url: pr.url, state: pr.state, ci, passed: checks.filter((c) => c === 'pass').length, total: checks.length } : null,
111    ahead: ahead || 0,
112    behind: behind || 0,
113    changes: { files: n(/(\d+) files? changed/), added: n(/(\d+) insertions?/), removed: n(/(\d+) deletions?/), untracked },
114  }
115}
116
117// Token counts as the footer and pane write them: 950, 12.3k, 1.2M.
118export const tokens = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
119
120// A duration as the transcript writes one: 850ms, 4.2s, 1m 4s.
121export const duration = (ms) => (ms < 1000 ? `${Math.round(ms)}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
122
123// The turn footer's tail after the engine's `Baked for 3s`, kept short: ↑tokens in ↓out ↺cache hit rate, and what the turn cost.
124export function footerText(t) {
125  const u = t.usage
126  const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
127  const hit = input ? Math.round((100 * u.cache_read_input_tokens) / input) : null
128  return [`↑${tokens(input)}`, `↓${tokens(u.output_tokens)}`, hit == null ? null : `↺${hit}%`, t.cost == null ? null : `$${t.cost.toFixed(2)}`].filter(Boolean).join(' ')
129}
130
131// Lines an edit adds and removes, read off the call's input: `+12 -4` (Edit, MultiEdit), `+40` (Write), else null.
132export function editStat(tool, input) {
133  const lines = (x) => (typeof x === 'string' && x ? x.split('\n').length : 0)
134  if (tool === 'Write') return `+${lines(input?.content)}`
135  const edits = tool === 'Edit' ? [input ?? {}] : tool === 'MultiEdit' ? (input?.edits ?? []) : null
136  if (!edits) return null
137  return `+${edits.reduce((n, x) => n + lines(x.new_string), 0)} -${edits.reduce((n, x) => n + lines(x.old_string), 0)}`
138}
139
140// When a rate-limit window fills at the pace of its recent samples, if that comes before it resets; else null.
141// samples: [{ at, pct }] oldest first; a drop in pct is a reset, so only the samples after the last drop count.
142export function fullAt(samples, resetsAt) {
143  let s = samples.filter((x) => x.pct != null)
144  const drop = s.findLastIndex((x, i) => i && x.pct < s[i - 1].pct)
145  if (drop > 0) s = s.slice(drop)
146  if (s.length < 2) return null
147  const [a, b] = [s[0], s.at(-1)]
148  const rate = (b.pct - a.pct) / (b.at - a.at)
149  if (!(rate > 0) || b.pct >= 100) return null
150  const at = b.at + (100 - b.pct) / rate
151  return resetsAt != null && at >= resetsAt ? null : at
152}
153
154// A moment as the band writes it: 16:40 today, Thu 16:40 later.
155export function when(ms, now) {
156  const d = new Date(ms)
157  const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
158  return new Date(now).toDateString() === d.toDateString() ? hm : `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()]} ${hm}`
159}
160
161// Cells of a bar chart, row-major [codePoint, color, background] triplets, one column per value (0-100). Heights
162// span lo..hi (the tallest bar fills `rows`, the lowest keeps one eighth so it shows); colours are 0xRRGGBB by the
163// value itself: grey while fine, amber and red under pressure. 0x01000000 is the default background.
164export const TONE_RGB = { ok: 0x7f849c, warn: 0xf9e2af, bad: 0xf38ba8 }
165export function sparkCells(values, rows, lo = 0, hi = 100) {
166  const out = []
167  for (let r = 0; r < rows; r++) {
168    for (const v of values) {
169      const share = (Math.min(hi, Math.max(lo, v)) - lo) / (hi - lo || 1)
170      const eighths = Math.max(1, Math.round(share * rows * 8)) - (rows - 1 - r) * 8
171      const level = Math.min(8, Math.max(0, eighths))
172      out.push(level ? BLOCKS.codePointAt(level - 1) : 32, v >= 80 ? TONE_RGB.bad : v >= 50 ? TONE_RGB.warn : TONE_RGB.ok, 0x01000000)
173    }
174  }
175  return out
176}
177
178// The band's sparkline range: the history's own spread, at least 10 points, so a steady context still shows a shape.
179export function sparkRange(values) {
180  const lo = Math.max(0, Math.min(...values) - 2)
181  return [lo, Math.min(100, Math.max(lo + 10, Math.max(...values)))]
182}
183
184// showCtx false leaves the context % out of `text` and `segments` (the Claude status line already shows it);
185// `segments` are the same parts with their kind, bare value and a tone (ok | warn | bad) for a drawing to colour.
186export function forecast({ percent, usage, turns = 0, ttlMs, sinceMs, showCtx = true }) {
187  const ctx = percent == null ? null : Math.round(percent)
188  const total = usage ? usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.input_tokens : 0
189  const hit = total > 0 ? Math.round((100 * usage.cache_read_input_tokens) / total) : null
190  const left = usage && ttlMs != null && sinceMs != null ? ttlMs - sinceMs : null
191  const hints = []
192  if (ctx != null && ctx >= 80) hints.push('compact soon')
193  // The first turn always writes the cache, so a low hit rate there says nothing.
194  const cold = hit != null && hit < 50 && turns > 1
195  if (cold) hints.push('cache cold')
196  if (left != null && left <= 0) hints.push('cache expired')
197  const segments = [
198    showCtx && ctx != null ? { kind: 'ctx', value: `${ctx}%`, text: `ctx ${ctx}%`, tone: ctx >= 80 ? 'bad' : ctx >= 50 ? 'warn' : 'ok' } : null,
199    hit == null ? null : { kind: 'cache', value: `${hit}%`, text: `cache ${hit}%`, tone: cold ? 'bad' : 'ok' },
200    left > 0 ? { kind: 'ttl', value: clock(left), text: `ttl ${clock(left)}`, tone: left <= 60_000 ? 'warn' : 'ok' } : null,
201    ...hints.map((text) => ({ kind: 'hint', value: text, text, tone: text === 'compact soon' ? 'warn' : 'bad' })),
202  ].filter(Boolean)
203  const text = segments.map((x) => x.text).join(' · ')
204  return { ctx, hit, left, hints, text, segments }
205}
206
types/index.d.ts 29 lines
1declare module 'claude-code' {
2  interface PluginState {
3    hud: {
4      weather: {
5        usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
6        at: number
7        turns: number
8      } | null
9      activity: string | null
10      calls: Record<string, number>
11      turns: { durationMs: number; usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }; usd: number | null; cost: number | null }[]
12      agents: { id: string; type: string; description: string; model: string; startedAt: number; endedAt: number | null; state: 'running' | 'done' | 'failed'; tokens: number }[]
13      repo: {
14        pr: { number: number; url: string | null; ci: 'pass' | 'fail' | 'pending' | null; passed: number; total: number } | null
15        ahead: number
16        behind: number
17        changes: { files: number; added: number; removed: number; untracked: number }
18      } | null
19      history: {
20        at?: number
21        ctx: number | null
22        hit: number | null
23        five?: { pct: number | null; resetsAt: number | null } | null
24        seven?: { pct: number | null; resetsAt: number | null } | null
25      }[]
26    }
27  }
28}
29