SLOPSHOPPER

verdict

KOZMOS Verdict: test, build and lint results above the prompt. Spots the test runners, type checkers, linters and builds Claude runs (vitest, jest, pytest, go…

newpanebandguardcommand
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/verdict
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verdict
│ ┃ KOZMOS · Verdict ✕ › fix the failing auth test and add an audit log call │ ┃ ⚖ Verdict · 1 runs │ ┃ ✗ bun test 1 failed ▆ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ✗ bun test 3 passed · 1 failed · 08:53 · 20… ⏺ Update(src/auth.ts) │ ┃ $ bun test ⎿ Added 2 lines, removed 1 line │ ┃ Clear history ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /verdict │ ⎿ verdict: Verdict open. Last: bun test 3 passed · 1 failed. │ │ ⟨Claude Code's own drawing⟩ ▣ client module ./bench.tsx✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./bench.tsx✕
Pane · KOZMOS · Verdict
⚖ Verdict · 1 runs ✗ bun test 1 failed ▆ ✗ bun test 3 passed · 1 failed · 08:53 · 20ms $ bun test Clear history
README

KOZMOS Verdict

Test, build and lint results, above the prompt. Verdict watches the Bash and PowerShell commands Claude runs, recognizes test runners, type checkers, linters and builds, parses their output and keeps a short history per runner for each project.

✓ tsc 0 errors ▇▇█ · ✗ vitest 3 failed ▆▇▇█▅ · ✓ eslint 0 errors

  • Recognized: vitest, jest, mocha, bun test, pytest, go test, cargo test / build / check / clippy, dotnet test / build, maven, gradle, npm|pnpm|yarn|bun test, tsc / vue-tsc / typecheck scripts, eslint / lint scripts, npm run build, vite / next / webpack / tsup / esbuild builds, go build / vet. Words inside paths (cat vitest.config.ts) do not count.
  • Parsed: passed / failed / skipped, error and warning counts, the names of failing tests (vitest FAIL … > …, jest ●, pytest FAILED, go --- FAIL, cargo ... FAILED, dotnet Failed …, mocha 1) …, bun (fail), gradle > … FAILED) and the first error lines (tsc file:line TSxxxx, eslint file:line message (rule), rustc with its --> location, maven [ERROR]). An unknown failing build still shows its first error-like lines.
  • Terminal band: a Client surface module; chips per runner with a sparkline of the last 10 runs (green passes, red failures); a failing chip's beacon and text pulse red, and a second row types out the failing tests one after another.
  • Desktop band: one SVG of pills with sparkline bars; failing pills pulse with a red glow, and a second line cycles through the failures by CSS.
  • /verdict opens a pane: each runner's last result and sparkline, then the 25 most recent runs with their command, time, duration and the failing test names / first error lines. /verdict hide|show toggles the band (also ✕), /verdict clear forgets this project's history.
  • The band shows once a run happened in this session; history persists per project in the plugin store (60 runs).

Türkçe

Test, derleme ve lint sonuçları istemin üstünde. Verdict, Claude'un çalıştırdığı Bash ve PowerShell komutlarını izler; test koşucularını, tip denetleyicileri, linter'ları ve derlemeleri tanır, çıktılarını ayrıştırır ve her proje için koşucu başına kısa bir geçmiş tutar.

  • Tanınanlar: vitest, jest, mocha, bun, pytest, go test, cargo, dotnet, maven, gradle, npm test, tsc, eslint, npm run build, vite/next/webpack…
  • Ayrıştırılanlar: geçen / kalan / atlanan sayıları, hata ve uyarı sayıları, başarısız testlerin adları ve ilk hata satırları.
  • Terminal: koşucu başına çipler ve son 10 koşunun kıvılcım grafiği; başarısız çip kırmızı nabız atar, ikinci satır başarısız testleri tek tek yazar.
  • Masaüstü: tek SVG; başarısız haplar kırmızı parlar, hatalar CSS ile döner.
  • /verdict son koşuları ve başarısız test adlarını listeleyen bir panel açar. /verdict hide|show bandı gizler/gösterir (✕ de gizler), /verdict clear bu projenin geçmişini siler.
Source 6 files
hooks/register.tsx 225 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { VerdictRun, VerdictSnap } from '../types'
5import { KZ, clip, hash, pxOf } from './lib/kz.ts'
6import { detect, judge } from './parse.ts'
7import { bandAlt, bandSvg, chipsOf, clock, duration, failureLines, paneCard, runSummary } from './view.ts'
8
9const PANE = 'kz-verdict'
10const TITLE = 'KOZMOS · Verdict'
11const KEEP = 60
12
13const snapAtom = atom({ plugin: 'verdict', key: 'snap' } as const, null)
14const hiddenAtom = atom({ plugin: 'verdict', key: 'isHidden' } as const, false)
15
16// Live history; the band and pane draw the snapshot in $.state.
17let runs: VerdictRun[] = []
18let sessionStartedAt = 0
19let storeKey = 'runs'
20
21async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
22  await update($, hiddenAtom, () => isHidden)
23  try {
24    await $.store.set('hidden', isHidden)
25  } catch {
26    // Not persisted; this session still honours it.
27  }
28}
29
30async function publish($: EngineInterface): Promise<void> {
31  const snap: VerdictSnap = { runs: [...runs], sessionStartedAt }
32  await update($, snapAtom, () => snap)
33}
34
35async function persist($: EngineInterface): Promise<void> {
36  try {
37    await $.store.set(storeKey, runs)
38  } catch {
39    // History stays in memory for this session.
40  }
41}
42
43async function togglePane($: EngineInterface): Promise<boolean> {
44  if ((await $.ui.panes()).some(p => p.id === PANE)) {
45    await $.ui.close({ id: PANE })
46    return false
47  }
48  await $.ui.open({ id: PANE, title: TITLE })
49  return true
50}
51
52function isRun(x: unknown): x is VerdictRun {
53  const r = x as VerdictRun
54  return typeof r === 'object' && r !== null && typeof r.runner === 'string' && typeof r.at === 'number' && Array.isArray(r.failures)
55}
56
57/** The shell output of a Bash or PowerShell result: stdout and stderr, else the text the model read. */
58function outputOf(result: unknown, text: string | undefined): string {
59  const r = (result ?? {}) as Record<string, unknown>
60  const parts = [r.stdout, r.stderr].filter((s): s is string => typeof s === 'string' && s.length > 0)
61  if (parts.length) return parts.join('\n')
62  if (typeof result === 'string') return result
63  return text ?? ''
64}
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    const started = await next(e)
69    sessionStartedAt = await $.clock.now()
70    storeKey = `runs-${hash(e.cwd || 'default').toString(36)}`
71    runs = []
72    await $.command.register({ name: 'verdict', description: 'KOZMOS: open the Verdict pane of test/build/lint runs (hide, show, clear)', argumentHint: '[hide|show|clear]', immediate: true })
73    try {
74      const stored = await $.store.get(storeKey)
75      if (Array.isArray(stored)) runs = stored.filter(isRun).slice(-KEEP)
76      const hidden = await $.store.get('hidden')
77      if (typeof hidden === 'boolean') await update($, hiddenAtom, () => hidden)
78    } catch {
79      // A fresh store.
80    }
81    await publish($).catch(() => undefined)
82    return started
83  })
84
85  on('command.run', { command: 'verdict' }, async ($, e) => {
86    const arg = e.args.trim().toLowerCase()
87    if (arg === 'hide' || arg === 'show') {
88      await setHidden($, arg === 'hide')
89      return { text: arg === 'hide' ? 'Verdict band hidden.' : 'Verdict band shown.' }
90    }
91    if (arg === 'clear') {
92      await clearRuns($)
93      return { text: 'Verdict history cleared for this project.' }
94    }
95    if (await read($, hiddenAtom)) await setHidden($, false)
96    const isOpen = await togglePane($)
97    const last = runs[runs.length - 1]
98    return { text: isOpen ? `Verdict open.${last ? ` Last: ${last.runner} ${runSummary(last)}.` : ' No runs yet.'}` : 'Verdict closed.' }
99  })
100
101  on('tool.call', async ($, e, next) => {
102    if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
103    const command = e.command
104    const background = (e as { run_in_background?: unknown }).run_in_background === true
105    if (background || !detect(command)) return next(e)
106    const startedAt = await $.clock.now()
107    const ran = await next(e)
108    if (ran.deny !== undefined) return ran
109    const result = ran.result as { backgroundTaskId?: string; interrupted?: boolean } | undefined
110    if (result?.backgroundTaskId || result?.interrupted) return ran
111    // judge knows the command: detect() above already did.
112    const o = judge(command, outputOf(ran.result, ran.text), ran.isError === true)!
113    const at = await $.clock.now()
114    runs = [...runs, { ...o, id: (runs[runs.length - 1]?.id ?? 0) + 1, at, ms: at - startedAt, command: clip(command, 120) }].slice(-KEEP)
115    await publish($).catch(() => undefined)
116    await persist($)
117    return ran
118  }).catch(($, e, next) => next(e))
119
120  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121    const drawn = await next(e)
122    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
123    const snap = await read($, snapAtom)
124    if (!snap) return drawn
125    const chips = chipsOf(snap.runs, snap.sessionStartedAt)
126    if (!chips.length) return drawn
127
128    const ui = $.ui.resolve(e)
129    const { Box, Button } = ui
130    const hide = <Button key="verdict-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
131    const failures = failureLines(chips)
132    const rows = failures.length && e.props.maxRows >= 8 ? 2 : 1
133
134    if ('Svg' in ui && e.surface !== 'terminal') {
135      const { Svg } = ui
136      const W = pxOf(e.props.bodyColumns) - 28
137      const { source, height } = bandSvg(chips, W, rows >= 2)
138      return (
139        <Box flexDirection="column">
140          {drawn}
141          <Box flexDirection="row" alignItems="flex-start">
142            <Svg source={source} alt={bandAlt(chips)} width={W} height={height} />
143            {hide}
144          </Box>
145        </Box>
146      )
147    }
148
149    // Every surface but the terminal has Svg and drew above.
150    const { Client } = ui as Elements['terminal']
151    const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
152    return (
153      <Box flexDirection="column">
154        {drawn}
155        <Box flexDirection="row">
156          <Client key="verdict" module="./bench.tsx" width={cols} height={rows} props={{ cols, rows, chips, failures }} />
157          {hide}
158        </Box>
159      </Box>
160    )
161  })
162
163  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
164    const snap = await read($, snapAtom)
165    const all = snap?.runs ?? []
166    const chips = chipsOf(all, 0)
167    const ui = $.ui.resolve(e)
168    const { Box, Text, Button } = ui
169    const recent = [...all].reverse().slice(0, 25)
170    const list = recent.map(r => (
171      <Box key={`run-${r.id}`} flexDirection="column" marginTop={1}>
172        <Text wrap="truncate-end">
173          <Text color={r.ok ? KZ.green : KZ.red} bold>{r.ok ? '✓' : '✗'} {r.runner}</Text>
174          <Text> {runSummary(r)}</Text>
175          <Text dimColor> · {clock(r.at)} · {duration(r.ms)}</Text>
176        </Text>
177        <Text dimColor wrap="truncate-end">  $ {r.command}</Text>
178        {r.failures.map((f, i) => (
179          <Text key={`f${i}`} color={r.ok ? KZ.amber : KZ.red} wrap="truncate-end">
180            {'  '}{r.kind === 'test' && r.fail ? '✗' : '!'} {f}
181          </Text>
182        ))}
183      </Box>
184    ))
185    const clear = all.length ? <Button key="verdict-clear" label="Clear history" plain onPress={() => void clearRuns($)} /> : null
186
187    if ('Svg' in ui && e.surface !== 'terminal') {
188      const { Svg } = ui
189      const W = pxOf(e.props.bodyColumns, 48)
190      const { source, height } = paneCard(chips, W)
191      return (
192        <Box flexDirection="column">
193          <Svg source={source} alt={chips.map(c => c.text).join(', ') || 'No runs yet'} width={W} height={height} />
194          {list}
195          {clear}
196        </Box>
197      )
198    }
199
200    return (
201      <Box flexDirection="column">
202        <Text bold color={KZ.violet}>⚖ Verdict <Text dimColor>· {all.length} runs</Text></Text>
203        {chips.length === 0 && <Text dimColor>No test, type-check, lint or build runs yet.</Text>}
204        {chips.map(c => (
205          <Text key={`sum-${c.runner}`} wrap="truncate-end">
206            <Text color={c.ok ? KZ.green : KZ.red}>{c.text}</Text>
207            <Text> </Text>
208            {c.spark.map((p, i) => (
209              <Text key={`p${i}`} color={p.ok ? KZ.green : KZ.red}>{'▁▂▃▄▅▆▇█'[Math.max(0, Math.min(7, Math.round(p.h * 7)))]}</Text>
210            ))}
211          </Text>
212        ))}
213        {list}
214        {clear}
215      </Box>
216    )
217  })
218}
219
220async function clearRuns($: EngineInterface): Promise<void> {
221  runs = []
222  await persist($)
223  await publish($)
224}
225
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/parse.ts 429 lines
1// Verdict's parsers: which shell commands run tests, type checks, linters or
2// builds, and what their output says (passed / failed / skipped, error
3// counts, the failing tests' names and the first error lines). Pure.
4
5export type Kind = 'test' | 'types' | 'lint' | 'build'
6
7export type Outcome = {
8  runner: string
9  kind: Kind
10  ok: boolean
11  pass: number
12  fail: number
13  skip: number
14  errors: number
15  warnings: number
16  /** Failing test names or first error lines, at most MAX_FAILURES, each clipped. */
17  failures: string[]
18  /** False when no summary line was recognized (the verdict is the exit status alone). */
19  parsed: boolean
20}
21
22export const MAX_FAILURES = 8
23const MAX_LINE = 160
24
25/** Removes ANSI escapes and carriage-return overprints. */
26export function clean(text: string): string {
27  return text
28    .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
29    .replace(/\u001b\][^\u0007]*\u0007/g, '')
30    .split('\n')
31    .map(l => l.slice(l.lastIndexOf('\r') + 1))
32    .join('\n')
33}
34
35const clip = (s: string): string => {
36  const one = s.replace(/\s+/g, ' ').trim()
37  return one.length > MAX_LINE ? one.slice(0, MAX_LINE - 1) + '…' : one
38}
39
40function uniq(xs: string[]): string[] {
41  const out: string[] = []
42  for (const x of xs.map(clip)) if (x && !out.includes(x)) out.push(x)
43  return out.slice(0, MAX_FAILURES)
44}
45
46const num = (re: RegExp, s: string): number => Number(re.exec(s)?.[1] ?? 0) || 0
47
48/** Every match of `re`: the patterns here come without the g flag and never match an empty string. */
49function all(re: RegExp, s: string): RegExpExecArray[] {
50  const g = new RegExp(re.source, re.flags + 'g')
51  const out: RegExpExecArray[] = []
52  let m: RegExpExecArray | null
53  while ((m = g.exec(s)) !== null) out.push(m)
54  return out
55}
56
57// ---------------------------------------------------------------------------
58// Detection.
59
60type Rule = { re: RegExp; runner: string; kind: Kind }
61
62/** A word that is a command here, not part of a path or file name. */
63const w = (word: string): string => `(?<![\\w./\\\\-])(?:\\.[\\\\/])?${word}(?![\\w.-])`
64const PM = '(?:npm|pnpm|yarn|bun)'
65
66const RULES: Rule[] = [
67  { re: new RegExp(w('vitest')), runner: 'vitest', kind: 'test' },
68  { re: new RegExp(w('jest')), runner: 'jest', kind: 'test' },
69  { re: new RegExp(`${w('pytest')}|${w('py\\.test')}|python[\\d.]*(?:\\.exe)? -m pytest`), runner: 'pytest', kind: 'test' },
70  { re: new RegExp(`${w('go')} test(?![\\w.-])`), runner: 'go test', kind: 'test' },
71  { re: new RegExp(`${w('cargo')} (?:test|nextest)(?![\\w.-])`), runner: 'cargo test', kind: 'test' },
72  { re: new RegExp(`${w('dotnet')} test(?![\\w.-])`), runner: 'dotnet test', kind: 'test' },
73  { re: new RegExp(w('mocha')), runner: 'mocha', kind: 'test' },
74  { re: new RegExp(`${w('bun')} test(?![\\w.-])`), runner: 'bun test', kind: 'test' },
75  { re: new RegExp(`${w('mvnw?')}(?: [^|&;]*)? (?:test|verify)(?![\\w.-])`), runner: 'maven', kind: 'test' },
76  { re: new RegExp(`${w('gradlew?')}(?: [^|&;]*)? (?::?\\w+:)?test(?![\\w.-])`), runner: 'gradle', kind: 'test' },
77  { re: new RegExp(`${w(PM)} (?:run )?test(?![\\w.-])|${w('npx?')} test(?![\\w.-])|${w(PM)} t(?![\\w.-])`), runner: 'test', kind: 'test' },
78  { re: new RegExp(`${w('tsc')}|${w('vue-tsc')}|${w(PM)} (?:run )?(?:typecheck|type-check|tsc)(?![\\w.-])`), runner: 'tsc', kind: 'types' },
79  { re: new RegExp(`${w('eslint')}|${w(PM)} (?:run )?lint(?![\\w.-])`), runner: 'eslint', kind: 'lint' },
80  { re: new RegExp(`${w('cargo')} (?:build|check|clippy)(?![\\w.-])`), runner: 'cargo build', kind: 'build' },
81  { re: new RegExp(`${w('go')} (?:build|vet)(?![\\w.-])`), runner: 'go build', kind: 'build' },
82  { re: new RegExp(`${w('dotnet')} build(?![\\w.-])`), runner: 'dotnet build', kind: 'build' },
83  { re: new RegExp(w('mvnw?')), runner: 'maven', kind: 'build' },
84  { re: new RegExp(w('gradlew?')), runner: 'gradle', kind: 'build' },
85  { re: new RegExp(`${w(PM)} (?:run )?build(?![\\w.-])|${w('vite')} build|${w('next')} build|${w('webpack')}|${w('tsup')}|${w('esbuild')}`), runner: 'build', kind: 'build' },
86]
87
88/** The runner a shell command invokes, or undefined when it runs none we know. */
89export function detect(command: string): { runner: string; kind: Kind } | undefined {
90  const cmd = command.replace(/\s+/g, ' ')
91  for (const r of RULES) if (r.re.test(cmd)) return { runner: r.runner, kind: r.kind }
92  return undefined
93}
94
95// ---------------------------------------------------------------------------
96// Parsers, one per family. Each returns undefined when its summary is absent.
97
98type Part = Omit<Outcome, 'runner' | 'kind' | 'ok' | 'parsed'> & { runner?: string; buildFailed?: boolean }
99
100const zero = (): Part => ({ pass: 0, fail: 0, skip: 0, errors: 0, warnings: 0, failures: [] })
101
102export function parseVitest(out: string): Part | undefined {
103  const tests = /^\s*Tests\s+(.+?)\s*\((\d+)\)\s*$/m.exec(out)
104  if (!tests) return undefined
105  const s = tests[1]!
106  const p = zero()
107  p.fail = num(/(\d+) failed/, s)
108  p.pass = num(/(\d+) passed/, s)
109  p.skip = num(/(\d+) skipped/, s) + num(/(\d+) todo/, s)
110  const errs = num(/^\s*Errors\s+(\d+) errors?/m, out)
111  p.errors = errs
112  const fails = all(/^\s*FAIL\s+(.+?)\s*$/m, out).map(m => m[1]!)
113  const crosses = all(/^\s*[×✗]\s+(.+?)(?:\s+\d+m?s)?\s*$/m, out).map(m => m[1]!)
114  p.failures = uniq(fails.length ? fails : crosses)
115  p.runner = 'vitest'
116  return p
117}
118
119export function parseJest(out: string): Part | undefined {
120  const tests = /^Tests:\s+(.*?)(\d+) total/m.exec(out)
121  if (!tests) return undefined
122  const s = tests[1]!
123  const p = zero()
124  p.fail = num(/(\d+) failed/, s)
125  p.pass = num(/(\d+) passed/, s)
126  p.skip = num(/(\d+) skipped/, s) + num(/(\d+) todo/, s)
127  p.failures = uniq(all(/^\s*● (?!Console)(.+?)\s*$/m, out).map(m => m[1]!).filter(x => !/^Test suite failed to run/.test(x)))
128  if (!p.failures.length) p.failures = uniq(all(/^\s*✕ (.+?)(?: \(\d+ ?m?s\))?\s*$/m, out).map(m => m[1]!))
129  p.runner = 'jest'
130  return p
131}
132
133export function parsePytest(out: string): Part | undefined {
134  const lines = all(/^(?:=+ )?((?:\d+ (?:passed|failed|skipped|errors?|xfailed|xpassed|deselected|warnings?)(?:, )?)+) in [\d.]+s(?: \([\d:]+\))?(?: =+)?\s*$/m, out)
135  const last = lines[lines.length - 1]
136  if (!last) {
137    if (/^=+ no tests ran/m.test(out)) return zero()
138    return undefined
139  }
140  const s = last[1]!
141  const p = zero()
142  p.pass = num(/(\d+) passed/, s) + num(/(\d+) xpassed/, s)
143  p.fail = num(/(\d+) failed/, s)
144  p.skip = num(/(\d+) skipped/, s) + num(/(\d+) xfailed/, s) + num(/(\d+) deselected/, s)
145  p.errors = num(/(\d+) errors?/, s)
146  p.warnings = num(/(\d+) warnings?/, s)
147  p.failures = uniq([
148    ...all(/^FAILED (\S+)(?: - (.*))?$/m, out).map(m => (m[2] ? `${m[1]} — ${m[2]}` : m[1]!)),
149    ...all(/^ERROR (\S+)(?: - (.*))?$/m, out).map(m => (m[2] ? `${m[1]} — ${m[2]}` : m[1]!)),
150  ])
151  p.runner = 'pytest'
152  return p
153}
154
155export function parseGo(out: string): Part | undefined {
156  const pass = all(/^\s*--- PASS: /m, out).length
157  const failNames = all(/^\s*--- FAIL: (\S+)/m, out).map(m => m[1]!)
158  const skip = all(/^\s*--- SKIP: /m, out).length
159  const okPkgs = all(/^ok\s+\S+/m, out).length
160  const failPkgs = all(/^FAIL\s+(\S+)(?:\s+\[build failed\]|\s+[\d.]+s)?\s*$/m, out).map(m => m[1]!)
161  const compile = all(/^(\S+\.go:\d+:\d+: .+)$/m, out).map(m => m[1]!)
162  if (!pass && !failNames.length && !skip && !okPkgs && !failPkgs.length && !compile.length) return undefined
163  const p = zero()
164  if (pass || failNames.length || skip) {
165    p.pass = pass
166    p.fail = failNames.length
167    p.skip = skip
168  } else {
169    p.pass = okPkgs
170    p.fail = failPkgs.length
171  }
172  p.errors = compile.length
173  p.failures = uniq([...failNames, ...compile, ...(failNames.length ? [] : failPkgs.map(x => `package ${x}`))])
174  p.runner = 'go test'
175  return p
176}
177
178/** rustc diagnostics: `error[E0308]: mismatched types` with its `--> src/x.rs:3:5` line. */
179function rustErrors(out: string): { errors: string[]; warnings: number } {
180  const errors: string[] = []
181  const lines = out.split('\n')
182  lines.forEach((l, i) => {
183    const m = /^error(\[E\d+\])?: (.+)$/.exec(l)
184    if (!m || /^(could not compile|aborting due to|test failed|build failed|\d+ previous errors?)/.test(m[2]!)) return
185    const at = /^\s*--> (.+)$/.exec(lines[i + 1] ?? '')?.[1]
186    errors.push(at ? `${at} ${m[1] ?? ''} ${m[2]}` : `${m[1] ?? ''} ${m[2]}`)
187  })
188  const warnings = all(/^warning: (?!.*generated \d+ warnings?)(?!unused manifest)/m, out).length
189  return { errors, warnings }
190}
191
192export function parseCargo(out: string): Part | undefined {
193  const results = all(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored/m, out)
194  const rust = rustErrors(out)
195  if (!results.length && !rust.errors.length && !/Finished|Compiling/.test(out)) return undefined
196  const p = zero()
197  for (const r of results) {
198    p.pass += Number(r[1])
199    p.fail += Number(r[2])
200    p.skip += Number(r[3])
201  }
202  p.errors = rust.errors.length
203  p.warnings = rust.warnings
204  p.failures = uniq([...all(/^test (\S+) \.\.\. FAILED$/m, out).map(m => m[1]!), ...rust.errors])
205  return p
206}
207
208export function parseDotnet(out: string): Part | undefined {
209  const p = zero()
210  const modern = all(/(?:Failed|Passed)!\s+-\s+Failed:\s+(\d+),\s+Passed:\s+(\d+),\s+Skipped:\s+(\d+)/m, out)
211  const builds = /(\d+) Error\(s\)/.exec(out)
212  if (modern.length) {
213    for (const m of modern) {
214      p.fail += Number(m[1])
215      p.pass += Number(m[2])
216      p.skip += Number(m[3])
217    }
218  } else if (/^\s*Total tests: \d+/m.test(out)) {
219    p.pass = num(/^\s*Passed: (\d+)/m, out)
220    p.fail = num(/^\s*Failed: (\d+)/m, out)
221    p.skip = num(/^\s*Skipped: (\d+)/m, out)
222  } else if (!builds && !/: error [A-Z]+\d+:/.test(out)) {
223    return undefined
224  }
225  const errLines = all(/^\s*(\S.*?: error [A-Z]+\d+: .+?)(?: \[[^\]]+\])?\s*$/m, out).map(m => m[1]!)
226  p.errors = builds ? Number(builds[1]) : uniq(errLines).length
227  p.warnings = num(/(\d+) Warning\(s\)/, out)
228  p.failures = uniq([...all(/^\s*Failed (\S+) \[/m, out).map(m => m[1]!), ...errLines])
229  return p
230}
231
232export function parseMocha(out: string): Part | undefined {
233  if (!/^\s*\d+ passing/m.test(out) && !/^\s*\d+ failing/m.test(out)) return undefined
234  const p = zero()
235  p.pass = num(/^\s*(\d+) passing/m, out)
236  p.fail = num(/^\s*(\d+) failing/m, out)
237  p.skip = num(/^\s*(\d+) pending/m, out)
238  const at = out.search(/^\s*\d+ failing/m)
239  const tail = at >= 0 ? out.slice(at).split('\n').slice(1) : []
240  const names: string[] = []
241  for (let i = 0; i < tail.length; i++) {
242    const m = /^\s+\d+\) (.+)$/.exec(tail[i]!)
243    if (!m) continue
244    const path = [m[1]!]
245    // The title continues on deeper-indented lines until one ends with a colon.
246    while (!/:$/.test(path[path.length - 1]!) && i + 1 < tail.length && /^\s{4,}\S/.test(tail[i + 1]!) && path.length < 6) path.push(tail[++i]!.trim())
247    names.push(path.join(' › ').replace(/:$/, ''))
248  }
249  p.failures = uniq(names)
250  p.runner = 'mocha'
251  return p
252}
253
254export function parseBun(out: string): Part | undefined {
255  if (!/^\s*\d+ pass\s*$/m.test(out) && !/^\s*\d+ fail\s*$/m.test(out)) return undefined
256  const p = zero()
257  p.pass = num(/^\s*(\d+) pass\s*$/m, out)
258  p.fail = num(/^\s*(\d+) fail\s*$/m, out)
259  p.skip = num(/^\s*(\d+) skip\s*$/m, out) + num(/^\s*(\d+) todo\s*$/m, out)
260  p.failures = uniq(all(/^\(fail\) (.+?)(?: \[[\d.]+m?s\])?\s*$/m, out).map(m => m[1]!))
261  p.runner = 'bun test'
262  return p
263}
264
265export function parseTsc(out: string): Part | undefined {
266  const lines = [
267    ...all(/^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/m, out),
268    ...all(/^(.+?):(\d+):(\d+) - error (TS\d+): (.+)$/m, out),
269  ].map(m => `${m[1]}:${m[2]} ${m[4]} ${m[5]}`)
270  const found = /Found (\d+) errors?/.exec(out)
271  if (!found && !lines.length) return undefined
272  const p = zero()
273  p.errors = found ? Number(found[1]) : lines.length
274  p.failures = uniq(lines)
275  return p
276}
277
278export function parseEslint(out: string): Part | undefined {
279  const summary = /✖ (\d+) problems? \((\d+) errors?, (\d+) warnings?\)/.exec(out)
280  const p = zero()
281  let file = ''
282  const errs: string[] = []
283  for (const l of out.split('\n')) {
284    if (/^(?:[A-Za-z]:)?[/\\]?\S.*\.\w+$/.test(l) && !/^\s/.test(l)) file = l.trim()
285    const m = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)(?:\s{2,}(\S+))?\s*$/.exec(l)
286    if (m && m[3] === 'error') errs.push(`${file ? file.split(/[\\/]/).pop() + ':' : ''}${m[1]} ${m[4]}${m[5] ? ` (${m[5]})` : ''}`)
287  }
288  if (!summary && !errs.length) return undefined
289  p.errors = summary ? Number(summary[2]) : errs.length
290  p.warnings = summary ? Number(summary[3]) : 0
291  p.failures = uniq(errs)
292  return p
293}
294
295export function parseMaven(out: string): Part | undefined {
296  const runs = all(/Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)/m, out)
297  const verdict = /BUILD (SUCCESS|FAILURE)/.exec(out)
298  if (!runs.length && !verdict) return undefined
299  const p = zero()
300  const last = runs[runs.length - 1]
301  if (last) {
302    const total = Number(last[1])
303    p.fail = Number(last[2]) + Number(last[3])
304    p.skip = Number(last[4])
305    p.pass = Math.max(0, total - p.fail - p.skip)
306  }
307  const errLines = all(/^\[ERROR\] (.+)$/m, out).map(m => m[1]!).filter(x => x.trim() && !/^(->|Re-run|To see the full|For more information|\[Help|Failed to execute goal|Tests run:|Failures:|Errors:|Run \d|$)/.test(x.trim()))
308  p.failures = uniq(errLines)
309  p.errors = verdict?.[1] === 'FAILURE' && p.fail === 0 ? Math.max(1, all(/^\[ERROR\] .+\.(?:java|kt):\[?\d+/m, out).length) : 0
310  p.buildFailed = verdict?.[1] === 'FAILURE'
311  return p
312}
313
314export function parseGradle(out: string): Part | undefined {
315  const verdict = /BUILD (SUCCESSFUL|FAILED)/.exec(out)
316  const tests = /(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/.exec(out)
317  if (!verdict && !tests) return undefined
318  const p = zero()
319  if (tests) {
320    const total = Number(tests[1])
321    p.fail = Number(tests[2]) || 0
322    p.skip = Number(tests[3]) || 0
323    p.pass = Math.max(0, total - p.fail - p.skip)
324  }
325  const compile = [...all(/^e: (.+)$/m, out).map(m => m[1]!), ...all(/^(.+\.java:\d+: error: .+)$/m, out).map(m => m[1]!)]
326  const wrong = /\* What went wrong:\n(.+)/.exec(out)?.[1]
327  p.errors = compile.length || (verdict?.[1] === 'FAILED' && p.fail === 0 ? 1 : 0)
328  p.failures = uniq([...all(/^(\S+ > .+) FAILED$/m, out).map(m => m[1]!), ...compile, ...(wrong ? [wrong] : [])])
329  p.buildFailed = verdict?.[1] === 'FAILED'
330  return p
331}
332
333/** Error-looking lines of an unknown build tool, for a failed build. */
334function genericErrors(out: string): string[] {
335  return uniq(out.split('\n').filter(l => /\berror\b|\bERR!|✘|\bfailed\b/i.test(l) && !/^\s*(?:npm )?ERR! (?:A complete log|code|path|errno)/.test(l)))
336}
337
338// ---------------------------------------------------------------------------
339
340const BY_RUNNER: Record<string, ((out: string) => Part | undefined)[]> = {
341  vitest: [parseVitest],
342  jest: [parseJest],
343  pytest: [parsePytest],
344  'go test': [parseGo],
345  'go build': [parseGo],
346  'cargo test': [parseCargo],
347  'cargo build': [parseCargo],
348  'dotnet test': [parseDotnet],
349  'dotnet build': [parseDotnet],
350  mocha: [parseMocha],
351  'bun test': [parseBun],
352  maven: [parseMaven],
353  gradle: [parseGradle],
354  test: [parseVitest, parseJest, parseBun, parseMocha, parsePytest, parseGo, parseCargo, parseDotnet],
355  tsc: [parseTsc],
356  eslint: [parseEslint],
357  build: [parseTsc, parseEslint],
358}
359
360/**
361 * The verdict of one run: `command` as given to the shell, `output` its
362 * stdout and stderr, `isError` whether the tool reported a failure (a
363 * non-zero exit). Undefined when the command runs nothing Verdict knows.
364 */
365export function judge(command: string, output: string, isError: boolean): Outcome | undefined {
366  const hit = detect(command)
367  if (!hit) return undefined
368  const out = clean(output)
369  let part: Part | undefined
370  // Every rule's runner has its parsers.
371  for (const parse of BY_RUNNER[hit.runner]!) {
372    part = parse(out)
373    if (part) break
374  }
375  const runner = hit.runner === 'test' || hit.runner === 'build' ? part?.runner ?? (hit.runner === 'test' ? detectByOutput(out) : 'build') : hit.runner
376  const p = part ?? zero()
377  if (!part && isError) {
378    p.failures = genericErrors(out)
379    if (hit.kind !== 'test') p.errors = Math.max(1, Math.min(p.failures.length, 99))
380  }
381  if (part && hit.kind === 'build' && isError && p.errors === 0 && p.fail === 0) {
382    p.errors = 1
383    if (!p.failures.length) p.failures = genericErrors(out)
384  }
385  const ok = hit.kind === 'test'
386    ? !isError && p.fail === 0 && p.errors === 0
387    : !isError && p.errors === 0 && p.buildFailed !== true
388  return {
389    runner,
390    kind: hit.kind,
391    ok,
392    pass: p.pass,
393    fail: p.fail,
394    skip: p.skip,
395    errors: p.errors,
396    warnings: p.warnings,
397    failures: p.failures,
398    parsed: part !== undefined,
399  }
400}
401
402function detectByOutput(out: string): string {
403  if (/\bvitest\b/i.test(out)) return 'vitest'
404  if (/\bjest\b/i.test(out)) return 'jest'
405  return 'test'
406}
407
408/** `✓ vitest 142`, `✗ vitest 3 failed`, `✓ tsc 0 errors`, `✗ eslint 4 errors`, `✓ build`. */
409export function chipText(o: Pick<Outcome, 'runner' | 'kind' | 'ok' | 'pass' | 'fail' | 'errors' | 'warnings' | 'parsed'>): string {
410  const mark = o.ok ? '✓' : '✗'
411  if (o.kind === 'test') {
412    if (o.fail > 0) return `${mark} ${o.runner} ${o.fail} failed`
413    if (!o.ok) return o.errors > 0 ? `${mark} ${o.runner} ${o.errors} ${o.errors === 1 ? 'error' : 'errors'}` : `${mark} ${o.runner} failed`
414    return `${mark} ${o.runner} ${o.parsed ? o.pass : 'passed'}`
415  }
416  if (o.kind === 'build') {
417    if (o.ok) return `${mark} ${o.runner}${o.warnings ? ` · ${o.warnings} warn` : ''}`
418    return o.errors > 1 || o.parsed ? `${mark} ${o.runner} ${o.errors} ${o.errors === 1 ? 'error' : 'errors'}` : `${mark} ${o.runner} failed`
419  }
420  return `${mark} ${o.runner} ${o.errors} ${o.errors === 1 ? 'error' : 'errors'}${o.warnings ? ` · ${o.warnings} warn` : ''}`
421}
422
423/** 0..1 for a sparkline: the share of tests passing, or 1 / (1 + errors). */
424export function health(o: Pick<Outcome, 'kind' | 'ok' | 'pass' | 'fail' | 'errors'>): number {
425  if (o.kind === 'test' && o.pass + o.fail > 0) return o.pass / (o.pass + o.fail)
426  if (o.errors > 0) return 1 / (1 + o.errors)
427  return o.ok ? 1 : 0.1
428}
429
hooks/view.ts 158 lines
1// Verdict's views: the chips per runner, the desktop SVG band and the pane's
2// header card. Pure: no `$` here.
3import type { VerdictRun } from '../types'
4import { KZ, fitText, svg, svgText, textWidth } from './lib/kz.ts'
5import { chipText, health } from './parse.ts'
6
7export type Chip = {
8  runner: string
9  text: string
10  ok: boolean
11  /** The runner's last runs, oldest first: health 0..1 and whether each passed. */
12  spark: { h: number; ok: boolean }[]
13  failures: string[]
14  at: number
15}
16
17export const SPARK_RUNS = 10
18
19/** One chip per runner seen since `since`, the most recent first; sparklines span all history. */
20export function chipsOf(runs: readonly VerdictRun[], since: number): Chip[] {
21  const byRunner = new Map<string, VerdictRun[]>()
22  for (const r of runs) {
23    const list = byRunner.get(r.runner) ?? []
24    list.push(r)
25    byRunner.set(r.runner, list)
26  }
27  const chips: Chip[] = []
28  for (const [runner, list] of byRunner) {
29    const last = list[list.length - 1]! // a runner is listed with its first run
30    if (last.at < since) continue
31    chips.push({
32      runner,
33      text: chipText(last),
34      ok: last.ok,
35      spark: list.slice(-SPARK_RUNS).map(r => ({ h: health(r), ok: r.ok })),
36      failures: last.ok ? [] : last.failures,
37      at: last.at,
38    })
39  }
40  return chips.sort((a, b) => b.at - a.at)
41}
42
43const BARS = '▁▂▃▄▅▆▇█'
44export function sparkBars(spark: readonly { h: number; ok: boolean }[]): { s: string; ok: boolean }[] {
45  return spark.map(p => ({ s: BARS[Math.max(0, Math.min(7, Math.round(p.h * 7)))]!, ok: p.ok }))
46}
47
48/** The failures to cycle through on the band: the failing chips', newest first. */
49export function failureLines(chips: readonly Chip[]): string[] {
50  return chips.flatMap(c => c.failures.map(f => `${c.runner}: ${f}`)).slice(0, 12)
51}
52
53// ---------------------------------------------------------------------------
54// Desktop band: pills with sparklines; failing ones pulse red; a second line
55// cycles through the failing tests by CSS.
56
57export function bandSvg(chips: readonly Chip[], W: number, withFailures: boolean): { source: string; height: number } {
58  const fails = withFailures ? failureLines(chips).slice(0, 6) : []
59  const H = fails.length ? 54 : 30
60  const parts: string[] = []
61  const css = `
62.pz{animation:pz 1.4s ease-in-out infinite}@keyframes pz{0%,100%{stroke-opacity:.95;stroke-width:1.6}50%{stroke-opacity:.25;stroke-width:4}}
63.bk{animation:bk 1.4s ease-in-out infinite}@keyframes bk{50%{opacity:.3}}
64.cy{opacity:0;animation:cy ${Math.max(1, fails.length) * 3}s linear infinite}@keyframes cy{0%{opacity:0}${(2 / Math.max(1, fails.length)).toFixed(1)}%{opacity:1}${(100 / Math.max(1, fails.length) - 2).toFixed(1)}%{opacity:1}${(100 / Math.max(1, fails.length)).toFixed(1)}%{opacity:0}100%{opacity:0}}`
65  let x = 0
66  let hidden = 0
67  for (const c of chips) {
68    const tw = textWidth(c.text, 12)
69    const sw = c.spark.length * 4
70    const w = 14 + tw + 8 + sw + 12
71    if (x + w > W) {
72      hidden++
73      continue
74    }
75    const color = c.ok ? KZ.green : KZ.red
76    parts.push(`<rect class="p" x="${x + 1}" y="2" width="${w - 2}" height="26" rx="13"/>`)
77    parts.push(`<rect x="${x + 1}" y="2" width="${w - 2}" height="26" rx="13" fill="none" stroke="${color}" stroke-opacity="${c.ok ? 0.45 : 0.9}" class="${c.ok ? '' : 'pz'}"/>`)
78    parts.push(`<circle cx="${x + 12}" cy="15" r="3.5" fill="${color}" class="${c.ok ? '' : 'bk'}"/>`)
79    parts.push(svgText(x + 20, 19.5, c.text.replace(/^[✓✗] /, ''), { size: 12, weight: 650, fill: c.ok ? undefined : KZ.red, cls: 't' }))
80    let bx = x + 20 + tw - textWidth('✓ ', 12) + 8
81    for (const p of c.spark) {
82      const bh = 3 + p.h * 13
83      parts.push(`<rect x="${bx.toFixed(1)}" y="${(22 - bh).toFixed(1)}" width="3" height="${bh.toFixed(1)}" rx="1" fill="${p.ok ? KZ.green : KZ.red}" opacity="${p.ok ? 0.75 : 0.95}"/>`)
84      bx += 4
85    }
86    x += w + 6
87  }
88  if (hidden > 0) parts.push(svgText(Math.min(x + 4, W - 40), 19.5, `+${hidden}`, { cls: 'm', size: 11 }))
89  fails.forEach((f, i) => {
90    parts.push(`<g class="cy" style="animation-delay:${i * 3}s">${svgText(14, 46, fitText(`↳ ${f}`, 11.5, W - 20), { size: 11.5, fill: '#ef4444', mono: true })}</g>`)
91  })
92  return { source: svg(W, H, parts.join(''), css), height: H }
93}
94
95export function bandAlt(chips: readonly Chip[]): string {
96  return chips.map(c => c.text).join(' · ')
97}
98
99// ---------------------------------------------------------------------------
100// Pane header: one tile per runner with its last ten runs as bars.
101
102export function paneCard(chips: readonly Chip[], W: number): { source: string; height: number } {
103  if (!chips.length) {
104    const body = `<rect class="p" width="${W}" height="56" rx="12"/>${svgText(16, 25, 'No runs yet', { size: 14, weight: 650 })}${svgText(16, 43, 'Tests, type checks, lints and builds Claude runs land here.', { cls: 's', size: 11 })}`
105    return { source: svg(W, 56, body), height: 56 }
106  }
107  const cols = W >= 520 ? 3 : W >= 340 ? 2 : 1
108  const tw = (W - (cols - 1) * 8) / cols
109  const th = 64
110  const rows = Math.ceil(chips.length / cols)
111  const H = rows * (th + 8) - 8
112  const parts: string[] = []
113  const css = `.pz{animation:pz 1.4s ease-in-out infinite}@keyframes pz{50%{stroke-opacity:.2}}`
114  chips.forEach((c, i) => {
115    const x = (i % cols) * (tw + 8)
116    const y = Math.floor(i / cols) * (th + 8)
117    const color = c.ok ? KZ.green : KZ.red
118    parts.push(`<rect class="p" x="${x}" y="${y}" width="${tw}" height="${th}" rx="12"/>`)
119    parts.push(`<rect x="${x + 0.5}" y="${y + 0.5}" width="${tw - 1}" height="${th - 1}" rx="12" fill="none" stroke="${color}" stroke-opacity=".8" class="${c.ok ? '' : 'pz'}"/>`)
120    parts.push(svgText(x + 12, y + 20, c.runner.toUpperCase(), { cls: 's', size: 10, weight: 700 }))
121    parts.push(svgText(x + 12, y + 42, fitText(c.text, 14, tw - 24), { size: 14, weight: 700, fill: color }))
122    const n = c.spark.length
123    c.spark.forEach((p, j) => {
124      const bh = 3 + p.h * 14
125      parts.push(`<rect x="${(x + tw - 12 - (n - j) * 6).toFixed(1)}" y="${(y + 56 - bh).toFixed(1)}" width="4" height="${bh.toFixed(1)}" rx="1.5" fill="${p.ok ? KZ.green : KZ.red}" opacity=".85"/>`)
126    })
127  })
128  return { source: svg(W, H, parts.join(''), css), height: H }
129}
130
131export function clock(at: number): string {
132  const d = new Date(at)
133  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
134}
135
136export function duration(ms: number): string {
137  if (ms < 1000) return `${Math.round(ms)}ms`
138  if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
139  return `${Math.floor(ms / 60_000)}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`
140}
141
142/** One run in words, for the pane: `142 passed · 3 failed · 2 skipped`. */
143export function runSummary(r: VerdictRun): string {
144  const bits: string[] = []
145  if (r.kind === 'test') {
146    if (r.pass || r.parsed) bits.push(`${r.pass} passed`)
147    if (r.fail) bits.push(`${r.fail} failed`)
148    if (r.skip) bits.push(`${r.skip} skipped`)
149    if (r.errors) bits.push(`${r.errors} ${r.errors === 1 ? 'error' : 'errors'}`)
150    if (!bits.length) bits.push(r.ok ? 'passed' : 'failed')
151  } else {
152    bits.push(r.ok && !r.errors ? (r.kind === 'build' ? 'built' : '0 errors') : `${r.errors || 1} ${r.errors === 1 || !r.errors ? 'error' : 'errors'}`)
153    if (r.warnings) bits.push(`${r.warnings} ${r.warnings === 1 ? 'warning' : 'warnings'}`)
154  }
155  return bits.join(' · ')
156}
157
158
hooks/bench.tsx 88 lines
1// Verdict's terminal band: result chips with sparklines; a failing chip's
2// beacon and text pulse red, and a second row types out the failing tests one
3// after another. ~30 fps on the surface; no `$` here.
4import type { ClientModule, RenderElement } from 'claude-code'
5
6import { KZ, mix } from './lib/kz.ts'
7import { sparkBars } from './view.ts'
8import type { Chip } from './view.ts'
9
10export type BenchProps = { cols: number; rows: number; chips: Chip[]; failures: string[] }
11type S = { t: number }
12
13const FRAME = 33
14const DWELL = 75
15
16const Bench: ClientModule<BenchProps, S> = (props, surface) => {
17  if (surface.state === undefined) {
18    surface.setState({ t: 0 })
19    surface.every(FRAME, () => surface.setState({ t: (surface.state?.t ?? 0) + 1 }))
20  }
21  const t = surface.state?.t ?? 0
22  const cols = Math.max(10, surface.columns || props.cols)
23  const pulse = (Math.sin(t / 4.5) + 1) / 2
24  const { Box, Text } = surface.elements
25
26  // Row 1: the chips, as many as fit.
27  const nodes: RenderElement[] = []
28  let used = 0
29  let hidden = 0
30  props.chips.forEach((c, i) => {
31    const bars = sparkBars(c.spark)
32    const len = c.text.length + 1 + bars.length + (i ? 3 : 0)
33    if (used + len > cols - 4) {
34      hidden++
35      return
36    }
37    used += len
38    if (i) nodes.push(<Text key={`sep${i}`} dimColor> · </Text>)
39    const color = c.ok ? KZ.green : mix('#f87171', '#fee2e2', pulse * 0.75)
40    const beacon = c.ok ? '' : pulse > 0.5 ? '◉' : '○'
41    nodes.push(
42      <Text key={`c${i}`} color={color} bold={!c.ok}>
43        {beacon ? c.text.replace(/^✗/, beacon) : c.text}
44      </Text>,
45    )
46    nodes.push(<Text key={`s${i}`}> </Text>)
47    bars.forEach((b, j) => {
48      const isLast = j === bars.length - 1
49      nodes.push(
50        <Text key={`b${i}-${j}`} color={b.ok ? (isLast ? KZ.green : '#16a34a') : isLast ? color : '#dc2626'}>
51          {b.s}
52        </Text>,
53      )
54    })
55  })
56  if (hidden) nodes.push(<Text key="more" dimColor>{`  +${hidden}`}</Text>)
57
58  // Row 2: the failing tests, typed out one at a time.
59  let detail: RenderElement | null = null
60  if (props.rows >= 2 && props.failures.length) {
61    const n = props.failures.length
62    const k = Math.floor(t / DWELL) % n
63    const line = props.failures[k]! // k < n
64    const shown = Math.min(line.length, Math.floor(((t % DWELL) + 1) * 3))
65    const head = n > 1 ? `${k + 1}/${n} ` : ''
66    const room = Math.max(4, cols - 4 - head.length)
67    const text = line.slice(0, shown)
68    const clipped = text.length > room ? text.slice(0, room - 1) + '…' : text
69    detail = (
70      <Text key="fail" wrap="truncate-end">
71        <Text color="#ef4444">{'  ↳ '}</Text>
72        <Text dimColor>{head}</Text>
73        <Text color="#fca5a5">{clipped}</Text>
74        <Text color="#fca5a5">{shown < line.length && t % 8 < 4 ? '▌' : ''}</Text>
75      </Text>
76    )
77  }
78
79  return (
80    <Box flexDirection="column">
81      <Text key="chips" wrap="truncate-end">{nodes}</Text>
82      {detail}
83    </Box>
84  )
85}
86
87export default Bench
88
types/index.d.ts 38 lines
1export type VerdictKind = 'test' | 'types' | 'lint' | 'build'
2
3/** One recorded run of a test runner, type checker, linter or build. */
4export type VerdictRun = {
5  id: number
6  /** When it finished (ms). */
7  at: number
8  /** How long it ran (ms). */
9  ms: number
10  runner: string
11  kind: VerdictKind
12  ok: boolean
13  pass: number
14  fail: number
15  skip: number
16  errors: number
17  warnings: number
18  /** Failing test names or first error lines. */
19  failures: string[]
20  /** The shell command, clipped. */
21  command: string
22  /** False when only the exit status was known. */
23  parsed: boolean
24}
25
26export type VerdictSnap = {
27  /** This project's recent runs, oldest first (persisted across sessions). */
28  runs: VerdictRun[]
29  /** Runs at or after this time belong to this session (the band shows them). */
30  sessionStartedAt: number
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    verdict: { snap: VerdictSnap | null; isHidden: boolean }
36  }
37}
38