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

hooks/register.tsx 221 lines1import { 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}
221hooks/fold.ts 160 lines1import 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}
160hooks/model.ts 163 lines1import 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}
163hooks/view.tsx 308 lines1import 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}
308hooks/svg.ts 48 lines1import 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}
48types/index.d.ts 36 lines1export 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