SLOPSHOPPER

pytest-live

Live view of running pytest sessions: a pane and a status line

newpanebandcommandstatusprocess
v0.1.0no licenseupdated 2026-10-19ogrodnek/claude-pytest-live
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pytest-live
│ ┃ pytest ✕ › fix the failing auth test and add an audit log call │ ┃ No pytest runs yet │ ┃ Tests Claude runs show up here live ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pytest-live │ ┃ ⎿ pytest-live: pytest-live pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pytest
No pytest runs yet Tests Claude runs show up here live
Source 6 files
hooks/register.tsx 221 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsEntry, Register } from 'claude-code'
3
4import type { PytestRun } from '../types'
5import { doneCount, duration, fold, summary, verdict } from './fold'
6import { paneModel } from './model'
7import { emptyView, type Kit, paneView } from './view'
8
9const PANE = 'pytest-live'
10const ROOT = '/tmp/claude-pytest-live'
11const POLL_MS = 500
12const KEEP_RUNS = 5
13const IDLE_BEFORE_ALIVE_CHECK_MS = 5_000
14const STATUS_AFTER_END_MS = 60_000
15
16const runs = atom({ plugin: 'pytest-live', key: 'runs' } as const, [])
17const isMuted = atom({ plugin: 'pytest-live', key: 'isMuted' } as const, false)
18
19// module state: a reload starts these over, and the next tick rebuilds them
20let dir = ''
21let ticker: { cancel: () => void } | undefined
22let isTicking = false
23let lastStatus: string | undefined
24let wasOffering = false
25const signatures = new Map<string, string>()
26const aliveCheckedAt = new Map<string, number>()
27
28async function isAlive($: EngineInterface, pid: number) {
29  try {
30    const { exitCode } = await $.process.run(['/bin/kill', '-0', String(pid)], { timeoutMs: 2_000 })
31    return exitCode === 0
32  } catch {
33    return true
34  }
35}
36
37/** Still going, or finished recently enough that the status line still shows it. */
38function isRecentRun(run: PytestRun, now: number) {
39  if (run.phase === 'collecting' || run.phase === 'running') return true
40  return now - (run.startedAt + run.elapsedMs) < STATUS_AFTER_END_MS
41}
42
43function showStatus($: EngineInterface, list: PytestRun[], now: number) {
44  const run = list.at(-1)
45  let text: string | undefined
46  if (run && (run.phase === 'collecting' || run.phase === 'running')) {
47    const failed = run.counts.failed + run.counts.error
48    text = run.total === null
49      ? 'pytest collecting…'
50      : `pytest ${doneCount(run)}/${run.total}${failed ? ` · ✗${failed}` : ''}`
51  } else if (run && isRecentRun(run, now)) {
52    const { label, isGood } = verdict(run)
53    text = `pytest ${isGood ? '✓' : '✗'} ${label} · ${summary(run)} · ${duration(run.elapsedMs)}`
54  }
55  if (text !== lastStatus) {
56    lastStatus = text
57    $.ui.status(text)
58  }
59}
60
61async function tick($: EngineInterface) {
62  if (isTicking || dir === '') return
63  isTicking = true
64  try {
65    const entries: FsEntry[] = await $.fs.list(dir).catch(() => [])
66    const files = entries
67      .filter(f => f.kind === 'file' && f.name.endsWith('.jsonl'))
68      .sort((a, b) => a.name.localeCompare(b.name))
69      .slice(-KEEP_RUNS)
70    const now = await $.clock.now()
71    const before = await read($, runs)
72    const byId = new Map(before.map(run => [run.id, run]))
73    const after: PytestRun[] = []
74
75    for (const file of files) {
76      const old = byId.get(file.name)
77      if (old && (old.phase === 'done' || old.phase === 'lost')) {
78        after.push(old)
79        continue
80      }
81      let run = old ?? null
82      const signature = `${file.size}:${file.mtimeMs}`
83      if (signatures.get(file.name) !== signature) {
84        const text = await $.fs.read(`${dir}/${file.name}`).catch(() => null)
85        if (text !== null) {
86          run = fold(file.name, text, now) ?? run
87          signatures.set(file.name, signature)
88        }
89      } else if (run) {
90        run = { ...run, elapsedMs: now - run.startedAt }
91      }
92      if (run === null) continue
93
94      const isQuiet = now - file.mtimeMs > IDLE_BEFORE_ALIVE_CHECK_MS
95      const checkedAt = aliveCheckedAt.get(file.name) ?? 0
96      if (run.phase !== 'done' && isQuiet && now - checkedAt > IDLE_BEFORE_ALIVE_CHECK_MS) {
97        aliveCheckedAt.set(file.name, now)
98        if (!(await isAlive($, run.pid))) run = { ...run, phase: 'lost', current: null }
99      }
100
101      // a run can finish between polls, so a recently finished run first seen now opens it too
102      if (old === undefined && isRecentRun(run, now) && !(await read($, isMuted))) {
103        // an unasked pane waits undrawn on a narrow terminal; the band offers to open it.
104        // Not awaited, so opening the pane can never hold up the status line.
105        void $.ui
106          .open({ id: PANE, title: 'pytest' })
107          .then(opened => opened.isPlaced || $.ui.invalidate('ui.render'))
108          .catch(() => {})
109      }
110      after.push(run)
111    }
112
113    if (JSON.stringify(after) !== JSON.stringify(before)) await update($, runs, () => after)
114    showStatus($, after, now)
115
116    // while muted the band offers the pane for a recent run; redraw it when that window ends
117    const latest = after.at(-1)
118    const isOffering = latest !== undefined && isRecentRun(latest, now) && (await read($, isMuted))
119    if (isOffering !== wasOffering) {
120      wasOffering = isOffering
121      try {
122        $.ui.invalidate('ui.render')
123      } catch {
124        // the band redraws on its own when the runs change
125      }
126    }
127  } finally {
128    isTicking = false
129  }
130}
131
132/** Opens the pane as the person asked for it, which places it at any width. */
133async function openPane($: EngineInterface) {
134  await update($, isMuted, () => false)
135  const opened = await $.ui.open({ id: PANE, title: 'pytest' })
136  try {
137    $.ui.invalidate('ui.render')
138  } catch {
139    // the band redraws on its own when the runs change
140  }
141  return opened
142}
143
144export const register: Register = on => {
145  on('session.start', async ($, e, next) => {
146    dir = `${ROOT}/${await $.session.id()}`
147    const python = `${$.plugin.root}/python`
148    const current = (await $.env.get('PYTHONPATH')) ?? ''
149    const rest = current.split(':').filter(part => part !== '' && part !== python)
150    await $.env.set('PYTHONPATH', [python, ...rest].join(':'))
151    await $.env.set('CLAUDE_PYTEST_LIVE_DIR', dir)
152
153    await $.command.register({
154      name: 'pytest-live',
155      description: 'Show live pytest results in a pane',
156    })
157
158    ticker?.cancel()
159    ticker = $.clock.every(POLL_MS, () => void tick($))
160
161    return next(e)
162  })
163
164  on('command.run', { command: 'pytest-live' }, async $ => {
165    const opened = await openPane($)
166
167    return { text: opened.isPlaced ? 'pytest-live pane opened.' : 'pytest-live: there is no room for the pane here.' }
168  })
169
170  on('ui.close', async ($, e, next) => {
171    if (e.id === PANE && e.origin.kind === 'person') await update($, isMuted, () => true)
172
173    return next(e)
174  })
175
176  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
177    if (e.props.hasSurvey) return next(e)
178    const pane = (await $.ui.panes()).find(p => p.id === PANE)
179    const latest = (await read($, runs)).at(-1)
180    if (latest === undefined || pane?.isPlaced) return next(e)
181
182    // offered when the pane the mod opened waits undrawn, or after a recent run while
183    // muted (the person closed the pane, so it no longer opens on its own)
184    const isWaiting = pane !== undefined
185    const isOffered =
186      isWaiting || ((await read($, isMuted)) && isRecentRun(latest, await $.clock.now()))
187    if (!isOffered) return next(e)
188
189    const { Box, Button, Text } = $.ui.resolve(e)
190    const { label, isGood } = verdict(latest)
191    // ✓ or ✗ only once the run has ended; a run still going is neither
192    const isGoing = latest.phase === 'collecting' || latest.phase === 'running'
193    const mark = isGoing ? '▸' : isGood ? '✓' : '✗'
194
195    return (
196      <Box>
197        <Text dimColor wrap="truncate-end">
198          pytest {mark} {label} · {summary(latest)}{' '}
199        </Text>
200        <Button key="open" label="open" onPress={() => void openPane($)} />
201      </Box>
202    )
203  })
204
205  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
206    const ui = $.ui.resolve(e)
207    const kit: Kit = {
208      Box: ui.Box,
209      Text: ui.Text,
210      // the terminal has no Svg; elsewhere the ring and bars are small images
211      Svg: 'Svg' in ui ? ui.Svg : undefined,
212      columns: Math.max(30, e.props.bodyColumns),
213      rows: e.props.scroll.bodyRows,
214    }
215    const latest = (await read($, runs)).at(-1)
216    if (latest === undefined) return emptyView(kit)
217
218    return paneView(kit, paneModel(latest, await $.clock.now()))
219  })
220}
221
hooks/fold.ts 160 lines
1import type { PytestFailure, PytestFile, PytestOutcome, PytestRun } from '../types'
2
3type Event =
4  | { e: 'start'; pid: number; args: string[]; t: number }
5  | { e: 'collected'; n: number; f?: Record<string, number>; t: number }
6  | { e: 's'; id: string; t: number }
7  | { e: 'r'; id: string; o: PytestOutcome; d: number; m?: string; t: number }
8  | { e: 'end'; x: number; t: number }
9
10/** Failures kept in detail per file; the rest are only counted (`PytestFile.failed`). */
11export const FAILURES_PER_FILE = 3
12
13export const emptyCounts = (): PytestRun['counts'] => ({
14  passed: 0,
15  failed: 0,
16  skipped: 0,
17  error: 0,
18  xfailed: 0,
19  xpassed: 0,
20})
21
22/** Builds a run's summary from the whole text of its JSON-lines file. */
23export function fold(id: string, text: string, now: number): PytestRun | null {
24  const outcomes = new Map<string, PytestOutcome>()
25  const messages = new Map<string, string>()
26  const running = new Map<string, number>()
27  let perFile: Record<string, number> = {}
28  let start: Extract<Event, { e: 'start' }> | null = null
29  let total: number | null = null
30  let end: Extract<Event, { e: 'end' }> | null = null
31
32  for (const line of text.split('\n')) {
33    if (line.trim() === '') continue
34    let event: Event
35    try {
36      event = JSON.parse(line)
37    } catch {
38      continue // a line still being written
39    }
40    if (event.e === 'start') start = event
41    else if (event.e === 'collected') {
42      total = event.n
43      perFile = event.f ?? {}
44    } else if (event.e === 's') running.set(event.id, Math.round(event.t * 1000))
45    else if (event.e === 'r') {
46      running.delete(event.id)
47      // a teardown error after a pass still marks the test as an error
48      if (outcomes.get(event.id) !== 'error') outcomes.set(event.id, event.o)
49      if (event.m) messages.set(event.id, event.m)
50    } else if (event.e === 'end') end = event
51  }
52  if (start === null) return null
53
54  const counts = emptyCounts()
55  const failures: PytestFailure[] = []
56  const keptPerFile = new Map<string, number>()
57  for (const [nodeid, outcome] of outcomes) {
58    counts[outcome] += 1
59    if (outcome !== 'failed' && outcome !== 'error') continue
60    // keep the first few per file, so every failing file has some to show however many fail
61    const kept = keptPerFile.get(fileOf(nodeid)) ?? 0
62    if (kept < FAILURES_PER_FILE) {
63      failures.push({ nodeid, outcome, message: messages.get(nodeid) ?? '' })
64      keptPerFile.set(fileOf(nodeid), kept + 1)
65    }
66  }
67
68  const isOver = end !== null
69  const [oldest] = [...running].sort((a, b) => a[1] - b[1])
70
71  const startedAt = Math.round(start.t * 1000)
72  const endedAt = end ? Math.round(end.t * 1000) : now
73
74  return {
75    id,
76    pid: start.pid,
77    args: start.args,
78    startedAt,
79    phase: end ? 'done' : total === null ? 'collecting' : 'running',
80    total,
81    counts,
82    failures,
83    elapsedMs: Math.max(0, endedAt - startedAt),
84    exitStatus: end ? end.x : null,
85    files: files(perFile, outcomes, isOver ? new Map() : running),
86    current: !isOver && oldest ? { nodeid: oldest[0], startedAt: oldest[1] } : null,
87  }
88}
89
90const fileOf = (nodeid: string) => nodeid.split('::')[0] ?? nodeid
91
92/** Each file's progress, in collection order, then files seen only through results. */
93function files(
94  perFile: Record<string, number>,
95  outcomes: Map<string, PytestOutcome>,
96  running: Map<string, number>,
97): PytestFile[] {
98  const byPath = new Map<string, PytestFile>()
99  const fileFor = (path: string) => {
100    let file = byPath.get(path)
101    if (!file) {
102      file = { path, total: perFile[path] ?? null, done: 0, failed: 0, isRunning: false }
103      byPath.set(path, file)
104    }
105    return file
106  }
107  for (const path of Object.keys(perFile)) fileFor(path)
108  for (const [nodeid, outcome] of outcomes) {
109    const file = fileFor(fileOf(nodeid))
110    file.done += 1
111    if (outcome === 'failed' || outcome === 'error') file.failed += 1
112  }
113  for (const nodeid of running.keys()) fileFor(fileOf(nodeid)).isRunning = true
114  return [...byPath.values()]
115}
116
117/** Files that failed to import: their error is reported under the file's path, with no `::test`. */
118export const collectionErrors = (run: PytestRun) => run.failures.filter(f => !f.nodeid.includes('::')).length
119
120export const doneCount = (run: PytestRun) =>
121  Object.values(run.counts).reduce((sum, n) => sum + n, 0)
122
123export function summary(run: PytestRun): string {
124  const parts: string[] = []
125  const { counts } = run
126  const broken = collectionErrors(run)
127  const testErrors = counts.error - broken
128  if (broken) parts.push(`${broken} collection error${broken === 1 ? '' : 's'}`)
129  if (counts.failed) parts.push(`${counts.failed} failed`)
130  if (testErrors) parts.push(`${testErrors} error${testErrors === 1 ? '' : 's'}`)
131  // after a collection error pytest runs nothing, so "0 passed" would read as a result
132  if (counts.passed || !broken) parts.push(`${counts.passed} passed`)
133  if (counts.skipped) parts.push(`${counts.skipped} skipped`)
134  if (counts.xfailed) parts.push(`${counts.xfailed} xfailed`)
135  if (counts.xpassed) parts.push(`${counts.xpassed} xpassed`)
136  return parts.join(', ')
137}
138
139/** pytest's exit codes: 0 all passed, 1 some failed, 2 interrupted, 5 no tests. */
140export function verdict(run: PytestRun): { label: string; isGood: boolean } {
141  if (run.phase === 'lost') return { label: 'stopped', isGood: false }
142  if (run.exitStatus !== null && collectionErrors(run) > 0) return { label: 'collection failed', isGood: false }
143  if (run.counts.failed > 0 || run.counts.error > 0) return { label: 'failed', isGood: false }
144  switch (run.exitStatus) {
145    case 0:
146      return { label: 'passed', isGood: true }
147    case 2:
148      return { label: 'interrupted', isGood: false }
149    case 5:
150      return { label: 'no tests', isGood: false }
151    default:
152      return { label: run.exitStatus === null ? 'running' : 'error', isGood: false }
153  }
154}
155
156export function duration(ms: number): string {
157  const s = Math.floor(ms / 1000)
158  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
159}
160
hooks/model.ts 163 lines
1import type { PytestFile, PytestRun } from '../types'
2import { collectionErrors, doneCount, duration, FAILURES_PER_FILE, verdict } from './fold'
3
4/** Theme keys the pane draws with, so colours follow the app's light and dark themes. */
5export type Tone = 'error' | 'success' | 'warning' | 'claude'
6
7export type Count = { n: number; word: string; tone: Tone; isBold: boolean }
8
9export type FailureModel = { name: string; message: string; isError: boolean }
10
11export type FileModel = {
12  path: string
13  /** the path without the folder every file shares, nor `test_` and `.py` */
14  label: string
15  done: number
16  total: number
17  failed: number
18  /** `notRun`: the run is over and never reached the file's remaining tests */
19  state: 'failed' | 'running' | 'done' | 'queued' | 'notRun'
20  failures: FailureModel[]
21  moreFailures: number
22}
23
24export type PaneModel = {
25  tone: Tone
26  verdict: string
27  command: string
28  progress: string
29  /** 0..1, for the status ring */
30  fraction: number
31  counts: Count[]
32  bar: { failed: number; passed: number; skipped: number; total: number }
33  files: FileModel[]
34  current: { name: string; seconds: string; isSlow: boolean } | null
35  isCollecting: boolean
36  isLost: boolean
37}
38
39const SLOW_MS = 5_000
40
41/** Strips terminal escapes and control characters, which a drawn tree refuses. */
42export const clean = (text: string) =>
43  text
44    .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
45    .replace(/[\p{Cc}\p{Cf}]/gu, ' ')
46    .trim()
47
48/** Cuts from the left, keeping the end of a path or test name. */
49export const cutLeft = (text: string, room: number) => {
50  const chars = [...text]
51  return chars.length <= room ? text : '…' + chars.slice(chars.length - Math.max(1, room - 1)).join('')
52}
53
54/** A test file's name as a row shows it: `api/test_routes.py` becomes `api/routes`. */
55export function shortLabel(path: string): string {
56  const slash = path.lastIndexOf('/')
57  const dir = path.slice(0, slash + 1)
58  const base = path.slice(slash + 1).replace(/\.py$/, '')
59  const bare = base.replace(/^test_/, '').replace(/_test$/, '')
60  return dir + (bare === '' ? base : bare)
61}
62
63/** The directory every file shares, so rows show only what tells them apart. */
64export function sharedDir(paths: string[]): string {
65  if (paths.length === 0) return ''
66  const split = paths.map(p => p.split('/').slice(0, -1))
67  const first = split[0] ?? []
68  let depth = 0
69  while (depth < first.length && split.every(parts => parts[depth] === first[depth])) depth += 1
70  return depth === 0 ? '' : first.slice(0, depth).join('/') + '/'
71}
72
73function stateOf(file: PytestFile, isOver: boolean): FileModel['state'] {
74  if (file.failed > 0) return 'failed'
75  if (file.isRunning) return 'running'
76  if (file.total === null || file.done >= file.total) return 'done'
77  return isOver ? 'notRun' : 'queued'
78}
79
80const RANK: Record<FileModel['state'], number> = { failed: 0, running: 1, done: 2, queued: 3, notRun: 4 }
81
82/** Everything the pane shows for a run, worked out once, independent of how it is drawn. */
83export function paneModel(run: PytestRun, now: number): PaneModel {
84  const { counts } = run
85  const isCollecting = run.phase === 'collecting'
86  const isRunning = run.phase === 'running'
87  const { label, isGood } = verdict(run)
88  const failedN = counts.failed + counts.error
89  const tone: Tone = failedN > 0 ? 'error' : isCollecting || isRunning ? 'claude' : isGood ? 'success' : 'warning'
90  const isOver = run.phase === 'done' || run.phase === 'lost'
91  // a collection error is reported like a result, but no test ran
92  const done = doneCount(run) - collectionErrors(run)
93  const total = run.total ?? done
94
95  const list: Count[] = []
96  const add = (n: number, word: string, t: Tone, isBold = false) => n > 0 && list.push({ n, word, tone: t, isBold })
97  add(counts.failed, 'failed', 'error', true)
98  const broken = collectionErrors(run)
99  add(broken, broken === 1 ? 'collection error' : 'collection errors', 'error', true)
100  add(counts.error - broken, counts.error - broken === 1 ? 'error' : 'errors', 'error', true)
101  add(counts.xpassed, 'xpassed', 'warning')
102  add(counts.skipped, 'skipped', 'warning')
103  add(counts.xfailed, 'xfailed', 'warning')
104  if (counts.passed > 0 || broken === 0) list.push({ n: counts.passed, word: 'passed', tone: 'success', isBold: false })
105
106  const prefix = sharedDir((run.files ?? []).map(f => f.path))
107  const files: FileModel[] = (run.files ?? [])
108    .map(file => {
109      const failures = run.failures.filter(f => f.nodeid.startsWith(`${file.path}::`) || f.nodeid === file.path)
110      return {
111        path: file.path,
112        label: clean(shortLabel(file.path.slice(prefix.length))),
113        done: file.done,
114        total: file.total ?? file.done,
115        failed: file.failed,
116        state: stateOf(file, isOver),
117        failures: failures.slice(0, FAILURES_PER_FILE).map(f => ({
118          name: clean(f.nodeid.slice(file.path.length + 2) || f.nodeid),
119          message: clean(f.message),
120          isError: f.outcome === 'error',
121        })),
122        // from the file's own count: details are kept for only the first few failures
123        moreFailures: Math.max(0, file.failed - Math.min(failures.length, FAILURES_PER_FILE)),
124      }
125    })
126    .map((file, index) => ({ file, index }))
127    .sort((a, b) => RANK[a.file.state] - RANK[b.file.state] || a.index - b.index)
128    .map(({ file }) => file)
129
130  let current: PaneModel['current'] = null
131  if (isRunning && run.current) {
132    const ms = Math.max(0, now - run.current.startedAt)
133    current = {
134      name: clean(run.current.nodeid.split('::').slice(1).join('::') || run.current.nodeid),
135      seconds: `${(ms / 1000).toFixed(1)}s`,
136      isSlow: ms >= SLOW_MS,
137    }
138  }
139
140  return {
141    tone,
142    verdict: isCollecting ? 'Collecting' : isRunning ? 'Running' : label.charAt(0).toUpperCase() + label.slice(1),
143    command: clean(['pytest', ...run.args].join(' ')),
144    progress: isCollecting
145      ? duration(run.elapsedMs)
146      : isOver && done === 0 && broken > 0
147        ? `nothing ran · ${duration(run.elapsedMs)}`
148        : `${done}/${total} · ${duration(run.elapsedMs)}`,
149    fraction: total > 0 ? Math.min(1, done / total) : isCollecting ? 0 : 1,
150    counts: list,
151    bar: {
152      failed: failedN,
153      passed: counts.passed + counts.xpassed,
154      skipped: counts.skipped + counts.xfailed,
155      total,
156    },
157    files,
158    current,
159    isCollecting,
160    isLost: run.phase === 'lost',
161  }
162}
163
hooks/view.tsx 308 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import { cutLeft, type FileModel, type PaneModel, type Tone } from './model'
4import { bar, ring } from './svg'
5
6/**
7 * The elements and room the pane draws with. `Svg` is present only where the surface
8 * has it (not the terminal), and then the ring and bars are drawn as small images.
9 */
10export type Kit = {
11  Box: Elements['terminal']['Box']
12  Text: Elements['terminal']['Text']
13  Svg?: Elements['desktop']['Svg']
14  columns: number
15  rows: number
16}
17
18// Columns, in cells: the status mark; counts and the failure tail are sized to what
19// the files need. A file's progress is drawn behind its name, not in a column.
20const MARK = 2
21const CELL_PX = 7.9
22// The app's sans font averages narrower than one cell, so names get more characters there.
23const SANS_PER_CELL = 1.25
24const MAX_FILES_SCROLLED = 60
25
26const GLYPH: Record<FileModel['state'], { glyph: string; tone?: Tone }> = {
27  failed: { glyph: '✗', tone: 'error' },
28  running: { glyph: '▸', tone: 'claude' },
29  done: { glyph: '✓', tone: 'success' },
30  queued: { glyph: '·' },
31  notRun: { glyph: '–' },
32}
33/**
34 * The tint behind a file's row, as wide as its progress: green while it runs, red once
35 * a test in it failed, a neutral surface when it is done. The terminal has no neutral
36 * surface key, so a finished file there is left untinted (its ✓ says it is done).
37 */
38function tintOf(file: FileModel, hasSurfaces: boolean): string | undefined {
39  if (file.state === 'failed') return 'diffRemovedDimmed'
40  if (file.state === 'running') return 'diffAddedDimmed'
41  if (file.state === 'done') return hasSurfaces ? 'userMessageBackground' : undefined
42  return undefined
43}
44
45/** A bar drawn in text: filled cells in the tone, the rest a dim track. */
46function textBar(kit: Kit, parts: { value: number; tone: Tone }[], total: number, cells: number): RenderElement {
47  const { Text } = kit
48  let used = 0
49  const filled = parts.map(p => {
50    const n = total > 0 ? Math.min(cells - used, Math.round((p.value / total) * cells)) : 0
51    used += n
52    return { n, tone: p.tone }
53  })
54  return (
55    <Text>
56      {filled.filter(f => f.n > 0).map(f => <Text color={f.tone}>{'━'.repeat(f.n)}</Text>)}
57      <Text dimColor>{'─'.repeat(Math.max(0, cells - used))}</Text>
58    </Text>
59  )
60}
61
62function header(kit: Kit, m: PaneModel): RenderElement {
63  const { Box, Text, Svg } = kit
64  return (
65    <Box flexDirection="row" alignItems="center" columnGap={1}>
66      <Box flexShrink={0} alignItems="center">
67        {Svg ? (
68          <Svg source={ring(m.fraction, m.tone)} alt={`${Math.round(m.fraction * 100)}% done`} width={14} height={14} />
69        ) : (
70          <Text color={m.tone}>●</Text>
71        )}
72      </Box>
73      <Box flexShrink={0}>
74        <Text bold color={m.tone}>{m.verdict}</Text>
75      </Box>
76      <Box flexGrow={1} flexShrink={1} minWidth={0}>
77        <Text dimColor wrap="truncate-end">{m.command}</Text>
78      </Box>
79      <Box flexShrink={0}>
80        <Text dimColor>{m.progress}</Text>
81      </Box>
82    </Box>
83  )
84}
85
86function counts(kit: Kit, m: PaneModel): RenderElement {
87  const { Box, Text } = kit
88  return (
89    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
90      {m.counts.map(c => (
91        <Text color={c.tone} bold={c.isBold}>{`${c.n} ${c.word}`}</Text>
92      ))}
93    </Box>
94  )
95}
96
97function overallBar(kit: Kit, m: PaneModel): RenderElement {
98  const { Box, Svg } = kit
99  const parts = [
100    { value: m.bar.failed, tone: 'error' as const },
101    { value: m.bar.passed, tone: 'success' as const },
102    { value: m.bar.skipped, tone: 'warning' as const },
103  ]
104  if (Svg) {
105    const width = Math.max(40, Math.floor(kit.columns * CELL_PX) - 4)
106    return (
107      <Box marginTop={1}>
108        <Svg source={bar(parts, m.bar.total, width, 6)} alt={`${m.progress} done`} width={width} height={6} />
109      </Box>
110    )
111  }
112  return <Box>{textBar(kit, parts, m.bar.total, kit.columns)}</Box>
113}
114
115function sectionTitle(kit: Kit, title: string, note: string): RenderElement {
116  const { Box, Text } = kit
117  return (
118    <Box flexDirection="row" columnGap={1} marginTop={1}>
119      <Text bold color="suggestion">{title}</Text>
120      <Text dimColor>{note}</Text>
121    </Box>
122  )
123}
124
125/** A row's count: `done/total`, or `not run` for a file the run never reached. */
126const countText = (file: FileModel) =>
127  file.state === 'notRun' && file.done === 0 ? 'not run' : `${file.done}/${file.total}`
128
129/** Widths every file row shares, sized to the widest count and failure tail. */
130type RowWidths = { count: number; tail: number; name: number }
131
132function rowWidths(kit: Kit, files: FileModel[]): RowWidths {
133  const count = Math.max(3, ...files.map(f => countText(f).length))
134  const tail = files.some(f => f.failed > 0) ? Math.max(...files.map(f => `✗${f.failed}`.length)) : 0
135  const gaps = (tail > 0 ? 3 : 2) + 2
136  const cells = Math.max(8, kit.columns - (MARK + count + tail + gaps))
137  return { count, tail, name: cells }
138}
139
140function fileRow(kit: Kit, file: FileModel, widths: RowWidths): RenderElement {
141  const { Box, Text, Svg } = kit
142  const { glyph, tone } = GLYPH[file.state]
143  const isQuiet = file.state === 'done' || file.state === 'queued' || file.state === 'notRun'
144  const share = file.total > 0 ? Math.min(1, file.done / file.total) : 1
145  const tint = tintOf(file, Svg !== undefined)
146
147  const mark = (
148    <Box width={MARK} flexShrink={0}>
149      {tone ? <Text color={tone} bold={file.state === 'failed'}>{glyph}</Text> : <Text dimColor>{glyph}</Text>}
150    </Box>
151  )
152  const tailCells = [
153    <Box width={widths.count} flexShrink={0} justifyContent="flex-end">
154      <Text dimColor>{countText(file)}</Text>
155    </Box>,
156    widths.tail > 0 && (
157      <Box width={widths.tail} flexShrink={0}>
158        <Text color="error" bold>{file.failed > 0 ? `✗${file.failed}` : ' '}</Text>
159      </Box>
160    ),
161  ]
162
163  if (Svg) {
164    // The tint is an absolute layer behind the row. The content sits in a Box that holds an
165    // empty absolute child: that makes the app position it, so it paints above the tint.
166    const name = cutLeft(file.label, Math.floor(widths.name * SANS_PER_CELL))
167    return (
168      <Box key={`f:${file.path}`} flexDirection="row">
169        {tint !== undefined && share > 0 && (
170          <Box
171            position="absolute"
172            top={0}
173            left={0}
174            bottom={0}
175            width={`${Math.round(share * 100)}%`}
176            backgroundColor={tint}
177            borderStyle="single"
178            borderColor={tint}
179          />
180        )}
181        <Box flexDirection="row" alignItems="center" columnGap={1} flexGrow={1} minWidth={0} paddingX={1}>
182          <Box position="absolute" top={0} left={0} />
183          {mark}
184          <Box flexGrow={1} flexShrink={1} minWidth={0}>
185            <Text wrap="truncate-end" dimColor={isQuiet}>{name}</Text>
186          </Box>
187          {tailCells}
188        </Box>
189      </Box>
190    )
191  }
192
193  // The terminal is monospace: pad the name to its column and tint the first cells.
194  const name = cutLeft(file.label, widths.name).padEnd(widths.name)
195  const filled = tint === undefined ? 0 : Math.round(share * widths.name)
196  return (
197    <Box key={`f:${file.path}`} flexDirection="row" columnGap={1}>
198      {mark}
199      <Box width={widths.name} flexShrink={0}>
200        <Text dimColor={isQuiet}>
201          {filled > 0 && <Text backgroundColor={tint}>{name.slice(0, filled)}</Text>}
202          {name.slice(filled)}
203        </Text>
204      </Box>
205      {tailCells}
206    </Box>
207  )
208}
209
210/** A file's failures, indented under its name: the test in red, its message dim below. */
211function failureBlock(kit: Kit, file: FileModel): RenderElement | null {
212  const { Box, Text, Svg } = kit
213  if (file.failures.length === 0) return null
214  const room = Math.max(8, kit.columns - 8)
215  return (
216    // on desktop the row has a 1-cell inset for its tint, so the names line up one cell further in
217    <Box flexDirection="column" marginLeft={MARK + 1 + (Svg ? 1 : 0)}>
218      {file.failures.map(f => (
219        <Box flexDirection="column">
220          <Text color="error" wrap="truncate-end">{`${f.isError ? 'E' : '✗'} ${cutLeft(f.name, room)}`}</Text>
221          {f.message !== '' && <Text dimColor wrap="truncate-end">{`  ${f.message}`}</Text>}
222        </Box>
223      ))}
224      {file.moreFailures > 0 && <Text dimColor>{`+${file.moreFailures} more`}</Text>}
225    </Box>
226  )
227}
228
229function currentRow(kit: Kit, m: PaneModel): RenderElement | null {
230  const { Box, Text } = kit
231  if (m.isLost) {
232    return (
233      <Box marginTop={1} columnGap={1}>
234        <Text color="warning" bold>!</Text>
235        <Text color="warning">pytest ended without a summary</Text>
236      </Box>
237    )
238  }
239  if (m.isCollecting || m.current === null) return null
240  return (
241    <Box flexDirection="row" marginTop={1} columnGap={1}>
242      <Box width={MARK} flexShrink={0}>
243        <Text color="claude">▸</Text>
244      </Box>
245      <Box flexGrow={1} flexShrink={1} minWidth={0}>
246        <Text wrap="truncate-end">{cutLeft(m.current.name, Math.max(8, kit.columns - 16))}</Text>
247      </Box>
248      <Box flexShrink={0} columnGap={1}>
249        <Text dimColor>{m.current.seconds}</Text>
250        {m.current.isSlow && <Text color="warning" bold>SLOW</Text>}
251      </Box>
252    </Box>
253  )
254}
255
256/** How many files fit: the terminal has a fixed height, other surfaces scroll the pane. */
257function filesThatFit(kit: Kit, m: PaneModel): number {
258  if (kit.Svg) return Math.min(m.files.length, MAX_FILES_SCROLLED)
259  let budget = kit.rows - 8
260  let n = 0
261  for (const file of m.files) {
262    const cost = 1 + file.failures.length * 2 + (file.moreFailures > 0 ? 1 : 0)
263    if (cost > budget) break
264    budget -= cost
265    n += 1
266  }
267  return Math.max(1, n)
268}
269
270export function paneView(kit: Kit, m: PaneModel): RenderElement {
271  const { Box, Text } = kit
272  const shown = filesThatFit(kit, m)
273  const widths = rowWidths(kit, m.files)
274  const doneFiles = m.files.filter(f => f.state === 'done' || f.state === 'failed').length
275  return (
276    <Box flexDirection="column">
277      {header(kit, m)}
278      {m.isCollecting ? (
279        <Text dimColor>Collecting tests…</Text>
280      ) : (
281        <Box flexDirection="column">
282          {counts(kit, m)}
283          {overallBar(kit, m)}
284          {m.files.length > 0 && sectionTitle(kit, 'Files', `${doneFiles}/${m.files.length}`)}
285          {m.files.slice(0, shown).map(file => (
286            <Box flexDirection="column">
287              {fileRow(kit, file, widths)}
288              {failureBlock(kit, file)}
289            </Box>
290          ))}
291          {shown < m.files.length && <Text dimColor>{`+${m.files.length - shown} more files`}</Text>}
292          {currentRow(kit, m)}
293        </Box>
294      )}
295    </Box>
296  )
297}
298
299export function emptyView(kit: Kit): RenderElement {
300  const { Box, Text } = kit
301  return (
302    <Box flexDirection="column" alignItems="center" justifyContent="center" height={Math.max(4, kit.rows - 2)}>
303      <Text dimColor>No pytest runs yet</Text>
304      <Text dimColor>Tests Claude runs show up here live</Text>
305    </Box>
306  )
307}
308
hooks/svg.ts 48 lines
1import type { Tone } from './model'
2
3// Mid-tone fills that read on light and dark; tracks use currentColor, which the
4// desktop app sets to its own text colour, so they follow the app's theme.
5export const HEX: Record<Tone, string> = {
6  error: '#d1453b',
7  success: '#2e9e5b',
8  warning: '#c98a00',
9  claude: '#d97757',
10}
11
12const SVG = 'xmlns="http://www.w3.org/2000/svg"'
13
14/** A small progress ring: a faint track and an arc for the share done. */
15export function ring(fraction: number, tone: Tone, size = 14): string {
16  const r = 6
17  const arc =
18    fraction >= 1
19      ? `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${HEX[tone]}" stroke-width="2.5"/>`
20      : fraction > 0
21        ? `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${HEX[tone]}" stroke-width="2.5" stroke-linecap="round" pathLength="100" stroke-dasharray="${Math.max(2, fraction * 100).toFixed(1)} 100" transform="rotate(-90 8 8)"/>`
22        : ''
23  return `<svg ${SVG} width="${size}" height="${size}" viewBox="0 0 16 16"><circle cx="8" cy="8" r="${r}" fill="none" stroke="currentColor" stroke-opacity=".18" stroke-width="2.5"/>${arc}</svg>`
24}
25
26export type BarPart = { value: number; tone: Tone }
27
28/** A rounded bar on a faint track, its parts laid end to end out of `total`. */
29export function bar(parts: BarPart[], total: number, width: number, height: number): string {
30  // a slight rounding, to match the row tints' corners
31  const r = Math.min(2, height / 2)
32  let x = 0
33  const fills = parts
34    .filter(p => p.value > 0 && total > 0)
35    .map(p => {
36      const w = (Math.min(p.value, total) / total) * width
37      const rect = `<rect x="${x.toFixed(1)}" width="${w.toFixed(1)}" height="${height}" fill="${HEX[p.tone]}"/>`
38      x += w
39      return rect
40    })
41    .join('')
42  return (
43    `<svg ${SVG} width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
44    `<clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>` +
45    `<g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="currentColor" fill-opacity=".14"/>${fills}</g></svg>`
46  )
47}
48
types/index.d.ts 36 lines
1export type PytestOutcome = 'passed' | 'failed' | 'skipped' | 'error' | 'xfailed' | 'xpassed'
2
3export type PytestFailure = { nodeid: string; outcome: 'failed' | 'error'; message: string }
4
5export type PytestFile = {
6  path: string
7  /** null for a file seen only through its results (an older run's file) */
8  total: number | null
9  done: number
10  failed: number
11  isRunning: boolean
12}
13
14export type PytestRun = {
15  id: string
16  pid: number
17  args: string[]
18  startedAt: number
19  phase: 'collecting' | 'running' | 'done' | 'lost'
20  total: number | null
21  counts: { [K in PytestOutcome]: number }
22  failures: PytestFailure[]
23  elapsedMs: number
24  exitStatus: number | null
25  /** in collection order; absent on runs folded before this field existed */
26  files?: PytestFile[]
27  /** the test that has been running longest */
28  current?: { nodeid: string; startedAt: number } | null
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'pytest-live': { runs: PytestRun[]; isMuted: boolean }
34  }
35}
36