A pane that shows and steers this project's labnotes runs, and a notice when they finish

labnotes helps you manage your experiments.
You can use labnotes without installation:
uvx labnotes
labnotes is designed to work entirely with local files.
labnotes organizes its data largely by folder; folders structure how it views files. (Config can overrule these but this will be the default.)
Let's say you have the following structure:
/data
/experiment-one
/files
/readme.md
/plan.md
/runs
/experiment-two
/files
/readme.md
/runs
You'll want to define a configuration file mapping your files to a SQL layout; start by defining a config file:
uvx labnotes init
labnotes will automatically pick up .labnotes.toml when run from the same folder, or specify it with uvx labnotes -c /path/to/.labnotes.toml.
init maps the structure above to three SQL tables, experiments, files, and runs:
[experiments]
path = "{experiment}"
[files]
path = "{experiment}/files/"
[runs]
path = "{experiment}/runs/{run}"
Change the paths to match how your files are organized.
All commands take a --format flag, with table (default) and json.
View experiments with:
uvx labnotes experiments
Which returns name, last run, created, files (num), runs (num).
View a particular experiment with:
uvx labnotes experiments foo
Which shows metadata about the experiment along with the above fields. You can see runs with:
uvx labnotes experiments foo runs
You can run an experiment with:
uvx labnotes experiments foo run
To run an experiment needs a script and parameters defined. This should be defined in an experiment.toml file in the experiment folder; it can also be provided on the command line (uvx labnotes experiments foo run --experiment-config ./foo.toml).
The TOML file defines the dynamic parameters along with the script to run:
command = "uv run python run.py --output {run_dir}"
repeats = 5
[params]
model = ["opus", "sonnet"]
effort = ["low", "high"]
The script can be any command. Each combination of parameters runs repeats times. Dynamic parameters will be passed as arguments to the script (--model opus --effort low). The script runs from the project root. Each run gets its own run folder; {run_dir} in the command becomes that folder's absolute path, so the script knows where to write its output.
Dynamic parameters get recorded for a particular run in a run.json file in the run folder, along with when it started, when it ended, and its exit code. The script's stdout and stderr are saved alongside.
Running an experiment opens a TUI that shows a table of all runs. Pending runs are slightly grayed. Runs default to 1 at a time but concurrency can be added with a --concurrency flag. The TUI lets you filter runs by a parameter, raise or lower concurrency as runs go, and kill specific runs. Failed runs show in red.
ctrl-c quits. If runs are going, it asks first: enter (or a second ctrl-c) leaves them running, s stops them, and esc goes back.
You can install the labnotes skill with:
npx skills add thekevinscott/labnotes
In Claude Code the skill also brings a pane with the run table and its keys. The agent opens it with its show_runs tool. q goes back (or puts a moved run back) and X stops all runs there, since the pane never sees escape or ctrl-c.
There's a frontend for interacting with labnotes:
uvx labnotes server
Pass --host and --port as CLI vars and every other uvicorn option.
Three paths in .labnotes.toml say where experiments, files and runs live. Two layouts cover most projects:
files and its runs in runs. uvx labnotes init writes this one.runs tree with a folder per experiment.Common Organizations shows both, with the config for each.
hooks/register.tsx 145 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PaneMessage } from './pane'
5import type { Log } from '../types'
6import { applyBlock, applyEvent, cleanLine, describeDone, parseEvent, splitLines } from './runs'
7import type { LabnotesEvent } from './runs'
8
9const PANE = 'labnotes'
10const TOOL = 'show_runs'
11const NOT_OPENED = 'The labnotes pane could not open.'
12const STATUS = ['uvx', 'labnotes', 'run', 'status', '--follow', '--json']
13const CONTROL = ['uvx', 'labnotes', 'run', 'control']
14const TAIL = ['tail', '-q', '-n', '500', '-F', 'stdout.log', 'stderr.log']
15const BLOCK_TAIL = ['tail', '-n', '+1', '-F', 'log.jsonl']
16const LOG_LINES = 200
17const experiments = atom({ plugin: 'labnotes', key: 'experiments' } as const, [])
18const log = atom({ plugin: 'labnotes', key: 'log' } as const, null)
19
20const isLoneAdhoc = (event: LabnotesEvent) =>
21 event.event === 'planned' && event.mode === 'adhoc' && (event.runs as unknown[]).length === 1
22
23let stopTail = () => {}
24
25const openPane = async ($: EngineInterface) => {
26 const opened = await $.ui.open({ id: PANE, title: 'labnotes' })
27 return opened.isPlaced
28 ? 'labnotes pane opened. Click it to use its keys.'
29 : `labnotes pane opened but not shown: ${opened.reason}`
30}
31
32const follow = ($: EngineInterface, run: string, folder: string) => {
33 stopTail()
34 const tail = $.process.spawn({ argv: TAIL, cwd: folder })
35 const blockTail = $.process.spawn({ argv: BLOCK_TAIL, cwd: folder })
36 stopTail = () => {
37 void tail.return(undefined as never)
38 void blockTail.return(undefined as never)
39 }
40 const read = async (from: typeof tail, add: (now: Log, lines: string[]) => Log) => {
41 let held = ''
42 for await (const { stream, text } of from) {
43 if (stream !== 'stdout') continue
44 const [lines, rest] = splitLines(held, text)
45 held = rest
46 await update($, log, now => (now?.run === run ? add(now, lines) : now))
47 }
48 }
49 void (async () => {
50 await update($, log, now => (now?.run === run ? { run, lines: [], blocks: [] } : now))
51 await Promise.all([
52 read(tail, (now, lines) => ({ ...now, lines: [...now.lines, ...lines.map(cleanLine)].slice(-LOG_LINES) })),
53 read(blockTail, (now, lines) => ({ ...now, blocks: lines.reduce(applyBlock, now.blocks).slice(-LOG_LINES) })),
54 ])
55 })().catch(() => {})
56}
57
58export const register: Register = on => {
59 on('session.start', async ($, e, next) => {
60 const started = await next(e)
61 await $.tool.register({
62 name: TOOL,
63 description:
64 "Open the labnotes pane: a live table of this project's runs, with keys to steer them (the same view as the terminal's `labnotes experiments NAME run`). Call it after starting runs, or when the person asks to watch them.",
65 })
66 await $.command.register({
67 name: 'labnotes-runs',
68 description: "Open the labnotes pane: a live table of this project's runs",
69 immediate: true,
70 })
71 void (async () => {
72 let held = ''
73 for await (const { stream, text } of $.process.spawn({ argv: STATUS })) {
74 if (stream !== 'stdout') continue
75 const [lines, rest] = splitLines(held, text)
76 held = rest
77 for (const line of lines) {
78 const event = parseEvent(line)
79 if (event === undefined) continue
80 let said: string | undefined
81 const now = await $.clock.now()
82 await update($, experiments, list => {
83 const changed = applyEvent(list, event, now)
84 said = describeDone(changed, event)
85 return changed
86 })
87 if (said !== undefined) $.ui.toast(said, { timeoutMs: 10000 })
88 if (isLoneAdhoc(event)) {
89 const [only] = event.runs as { run: string }[]
90 stopTail()
91 await update($, log, () => ({ run: only?.run ?? '', lines: [], blocks: [] }))
92 }
93 const viewing = await read($, log)
94 if (event.event === 'started' && viewing?.run === event.run) {
95 follow($, String(event.run), String(event.folder))
96 }
97 }
98 }
99 })().catch(() => {})
100 return started
101 })
102
103 on('tool.call', { tool: 'mcp__labnotes__show_runs' }, async $ => {
104 const said = await openPane($)
105 return { result: said, text: said }
106 }).catch(() => ({ result: NOT_OPENED, text: NOT_OPENED, isError: true as const }))
107
108 on('command.run', { command: 'labnotes-runs' }, async $ => ({ text: await openPane($) })).catch(() => ({
109 text: NOT_OPENED,
110 }))
111
112 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
113 const message = e.data as PaneMessage
114 if ('leave' in message) {
115 stopTail()
116 await update($, log, () => null)
117 } else if ('view' in message) {
118 const run = message.view
119 await update($, log, () => ({ run, lines: [], blocks: [] }))
120 const folder = (await read($, experiments)).flatMap(one => one.rows).find(row => row.run === run)?.folder
121 if (folder === undefined) stopTail()
122 else follow($, run, folder)
123 } else {
124 void (async () => {
125 for (const { experiment, action, args } of message.controls) {
126 const { exitCode, stderr } = await $.process.run([...CONTROL, experiment, action, ...args])
127 const reason = stderr.trim().split('\n').pop()
128 if (exitCode !== 0) $.ui.toast(reason || `labnotes run control ${action} failed.`, { timeoutMs: 10000 })
129 }
130 })().catch(() => {})
131 }
132 return {}
133 })
134
135 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
136 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
137 const { Text } = $.ui.resolve(e)
138 return <Text>The labnotes pane needs a terminal.</Text>
139 }
140 const { Client } = $.ui.resolve(e)
141 const props = { experiments: await read($, experiments), log: await read($, log), rows: e.props.scroll.bodyRows }
142 return <Client key="runs" module="./pane.tsx" props={props} flexGrow={1} />
143 })
144}
145hooks/pane.tsx 204 lines1import type { ClientKeyEvent, ClientModule, JsonValue } from 'claude-code'
2
3import type { Experiment, Log, Row } from '../types'
4import { SPINNER, blockRows, rowCells } from './runs'
5
6export type PaneProps = { experiments: Experiment[]; log: Log | null; rows: number }
7
8export type Control = { experiment: string; action: string; args: string[] }
9
10export type PaneMessage = { controls: Control[] } | { view: string } | { leave: true }
11
12type PaneState = {
13 cursor: number
14 filter: string
15 isTyping: boolean
16 frame: number
17 now: number
18 opened: string[]
19}
20
21type Shown = { experiment: Experiment; row: Row }
22
23const COLUMNS = ['run', 'params', 'status', 'time'] as const
24
25const KEYS = 'enter log · x stop · X stop all · +/- at once · / filter · q back'
26
27const shownRows = (experiments: Experiment[], state: PaneState): Shown[] =>
28 experiments.flatMap(experiment =>
29 experiment.rows
30 .filter(row => row.params.includes(state.filter))
31 .map(row => ({ experiment, row })),
32 )
33
34const isTypable = (event: ClientKeyEvent) => event.key.length === 1 && !event.ctrl && !event.meta
35
36const Pane: ClientModule<PaneProps, PaneState> = (props, surface) => {
37 const { Box, Text } = surface.elements
38 const { log } = props
39 const state: PaneState = surface.state ?? {
40 cursor: 0,
41 filter: '',
42 isTyping: false,
43 frame: 0,
44 now: Date.now(),
45 opened: [],
46 }
47 const { experiments } = props
48 const shown = shownRows(experiments, state)
49 const set = (change: Partial<PaneState>) => surface.setState({ ...state, ...change })
50 const post = (message: PaneMessage) => surface.post(message as JsonValue)
51 const control = (experiment: Experiment, action: string, ...args: string[]) =>
52 post({ controls: [{ experiment: experiment.name, action, args }] })
53
54 const cursor = Math.max(0, Math.min(state.cursor, shown.length - 1))
55 const current = shown[cursor]
56
57 const stopAll = (experiment: Experiment | undefined) => {
58 if (experiment !== undefined) control(experiment, experiment.isIdle ? 'finish' : 'interrupt')
59 }
60
61 const onKey = (event: ClientKeyEvent) => {
62 if (state.isTyping) {
63 if (event.key === 'return') set({ isTyping: false })
64 else if (event.key === 'backspace' || event.key === 'delete') set({ filter: state.filter.slice(0, -1) })
65 else if (event.key === 'space') set({ filter: `${state.filter} ` })
66 else if (isTypable(event)) set({ filter: state.filter + event.key })
67 return
68 }
69 if (log !== null) {
70 if (event.key === 'q') post({ leave: true })
71 if (event.key === 'X') stopAll(experiments.find(one => one.rows.some(row => row.run === log.run)))
72 return
73 }
74 switch (event.key) {
75 case 'j':
76 case 'down':
77 return set({ cursor: Math.min(cursor + 1, shown.length - 1) })
78 case 'k':
79 case 'up':
80 return set({ cursor: Math.max(cursor - 1, 0) })
81 case 'g':
82 return set({ cursor: event.shift ? shown.length - 1 : 0 })
83 case 'G':
84 return set({ cursor: shown.length - 1 })
85 case 'return':
86 if (current !== undefined) post({ view: current.row.run })
87 return
88 case 'x':
89 if (current !== undefined) control(current.experiment, 'kill', current.row.run)
90 return
91 case 'X':
92 return stopAll(current?.experiment ?? (experiments.length === 1 ? experiments[0] : undefined))
93 case '+':
94 case '=':
95 if (current !== undefined)
96 control(current.experiment, 'set_concurrency', String(current.experiment.concurrency + 1))
97 return
98 case '-':
99 if (current !== undefined)
100 control(current.experiment, 'set_concurrency', String(current.experiment.concurrency - 1))
101 return
102 case '/':
103 return set({ isTyping: true })
104 case 'q':
105 return set({ filter: '', isTyping: false })
106 }
107 }
108
109 if (surface.state === undefined) {
110 surface.setState(state)
111 surface.every(100, () => {
112 const latest = surface.state ?? state
113 surface.setState({ ...latest, frame: (latest.frame + 1) % SPINNER.length, now: Date.now() })
114 })
115 }
116 surface.onKey(onKey)
117 surface.onPointer(() => {})
118
119 if (log !== null) {
120 const room = Math.max(props.rows - 2, 1)
121 const width = surface.columns || 80
122 const allBlockRows = log.blocks.flatMap((block, index) => {
123 const key = `${log.run}#${index}`
124 return blockRows(block, state.opened.includes(key), width).map(text => ({ key, text }))
125 })
126 const shownBlocks = allBlockRows.slice(-Math.floor(room / 2) || allBlockRows.length)
127 surface.onPointer(event => {
128 const key = event.type === 'down' ? shownBlocks[event.y - 1]?.key : undefined
129 if (key === undefined) return
130 set({ opened: state.opened.includes(key) ? state.opened.filter(one => one !== key) : [...state.opened, key] })
131 })
132 return (
133 <Box flexDirection="column">
134 <Text bold>{log.run}</Text>
135 {shownBlocks.map(({ key, text }, index) => (
136 <Text key={`${key}:${index}`} wrap="truncate-end">
137 {text === '' ? ' ' : text}
138 </Text>
139 ))}
140 {log.lines.slice(-(room - shownBlocks.length) || log.lines.length).map((line, index) => (
141 <Text key={String(index)} wrap="truncate-end">
142 {line === '' ? ' ' : line}
143 </Text>
144 ))}
145 <Text dimColor>q back · X stop all{log.blocks.length > 0 ? ' · click a block to open it' : ''}</Text>
146 </Box>
147 )
148 }
149
150 const cells = shown.map(({ row }) => rowCells(row, state.now))
151 const widths = COLUMNS.map(column =>
152 Math.max(column.length, ...cells.map(cell => (column === 'status' ? (cell.status ?? ' ') : cell[column]).length)),
153 )
154 const pad = (text: string, column: number) => text.padEnd(widths[column] ?? 0)
155
156 let at = 0
157 return (
158 <Box flexDirection="column">
159 {experiments.length === 0 && <Text dimColor>No labnotes runs yet.</Text>}
160 {experiments.map(experiment => {
161 const own = cells.slice(at, at + shown.filter(one => one.experiment === experiment).length)
162 const first = at
163 at += own.length
164 return (
165 <Box flexDirection="column" key={experiment.name}>
166 <Box justifyContent="space-between">
167 <Text bold>{experiment.title}</Text>
168 <Text>{experiment.concurrency} at a time</Text>
169 </Box>
170 <Text bold>{COLUMNS.map((column, index) => pad(column, index)).join(' ')}</Text>
171 {own.map((cell, index) => (
172 <Text key={shown[first + index]?.row.run} inverse={first + index === cursor} wrap="truncate-end">
173 <Text dimColor={cell.dim}>
174 {pad(cell.run, 0)} {pad(cell.params, 1)}
175 </Text>
176 {' '}
177 <Text dimColor={cell.statusStyle === 'dim'} color={cell.statusStyle === 'red' ? 'error' : undefined}>
178 {pad(cell.status ?? SPINNER[state.frame] ?? '|', 2)}
179 </Text>
180 {' '}
181 {cell.time}
182 </Text>
183 ))}
184 {experiment.message !== '' && <Text dimColor>{experiment.message}</Text>}
185 </Box>
186 )
187 })}
188 {(state.isTyping || state.filter !== '') && (
189 <Text>
190 /{state.filter}
191 {state.isTyping ? '█' : ''}
192 </Text>
193 )}
194 {experiments.length > 0 && (
195 <Text dimColor wrap="truncate-end">
196 {KEYS}
197 </Text>
198 )}
199 </Box>
200 )
201}
202
203export default Pane
204hooks/runs.ts 283 lines1import type { Block, Experiment, Row, RunStatus } from '../types'
2
3const RETRY_EXIT_CODE = 75
4
5export type LabnotesEvent = { event: string; [field: string]: unknown }
6
7export type Cells = {
8 run: string
9 params: string
10 status: string | undefined
11 statusStyle: '' | 'dim' | 'red'
12 dim: boolean
13 time: string
14}
15
16export const GLYPHS: Record<Exclude<RunStatus, 'running' | 'failed'>, string> = {
17 pending: '○',
18 passed: '✓',
19 skipped: '–',
20}
21export const SPINNER = '|/-\\'
22
23export const parseEvent = (line: string): LabnotesEvent | undefined => {
24 try {
25 const event: unknown = JSON.parse(line)
26 if (typeof event === 'object' && event !== null && 'event' in event) {
27 return event as LabnotesEvent
28 }
29 } catch {}
30 return undefined
31}
32
33export const splitLines = (held: string, text: string): [string[], string] => {
34 const lines = (held + text).split('\n')
35 const rest = lines.pop() ?? ''
36 return [lines, rest]
37}
38
39// Python's json.dumps: ", " and ": " separators, non-ASCII escaped.
40const pythonJson = (value: unknown): string => {
41 if (Array.isArray(value)) return `[${value.map(pythonJson).join(', ')}]`
42 if (typeof value === 'object' && value !== null) {
43 const pairs = Object.entries(value).map(([key, item]) => `${pythonJson(key)}: ${pythonJson(item)}`)
44 return `{${pairs.join(', ')}}`
45 }
46 return (JSON.stringify(value) ?? 'null').replace(
47 /[\u007f-]/g,
48 char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
49 )
50}
51
52const paramText = (value: unknown): string => {
53 if (typeof value === 'boolean') return value ? '1' : '0'
54 if (typeof value === 'string') return value
55 if (typeof value === 'object' && value !== null) return pythonJson(value)
56 return String(value)
57}
58
59export const paramArgs = (params: unknown): string =>
60 Object.entries((params ?? {}) as Record<string, unknown>)
61 .flatMap(([name, value]) => [`--${name}`, paramText(value)])
62 .join(' ')
63
64export const formatElapsed = (seconds: number): string => {
65 const whole = Math.trunc(seconds)
66 const rest = whole % 60
67 const minutes = Math.trunc(whole / 60) % 60
68 const hours = Math.trunc(whole / 3600)
69 const pad = (count: number) => String(count).padStart(2, '0')
70 if (hours) return `${hours}h ${pad(minutes)}m`
71 if (minutes) return `${minutes}m ${pad(rest)}s`
72 return `${rest}s`
73}
74
75export const shortName = (run: string) => run.slice(run.lastIndexOf('/') + 1)
76
77export const describeEvent = (event: LabnotesEvent): string => {
78 switch (event.event) {
79 case 'planned':
80 return `${(event.runs as unknown[]).length} runs to go, ${event.concurrency} at a time.`
81 case 'started':
82 return `${event.run} started ${paramArgs(event.params)}`.trimEnd()
83 case 'ended':
84 return `${event.run} ${event.error || `exited ${event.exit_code}`}`
85 case 'stopping run':
86 return `Stopping ${event.run}.`
87 case 'killing run':
88 return `Killing ${event.run}.`
89 case 'retried':
90 return `${event.run} retried as ${event.retry}.`
91 case 'retrying':
92 return `${event.run} retries in ${formatElapsed(Number(event.seconds))} (retry ${event.attempt} of ${event.retries}).`
93 case 'gave up':
94 return `${event.run} gave up (exit ${RETRY_EXIT_CODE}) after ${event.retries} retries.`
95 case 'retry cancelled':
96 return `${event.run} won't retry.`
97 case 'idle':
98 return 'No runs left.'
99 case 'concurrency':
100 return `${event.concurrency} at a time.`
101 case 'stopping':
102 return 'Stopping runs. X again to kill them.'
103 case 'killing':
104 return 'Killing runs.'
105 default:
106 return ''
107 }
108}
109
110export const summarize = (experiment: Experiment): string => {
111 const ended = experiment.rows.filter(row => row.endedAt !== undefined).length
112 const failed = experiment.rows.filter(row => row.status === 'failed').length
113 return `${ended - failed} passed, ${failed} failed.`
114}
115
116const planned = (run: unknown): Row => {
117 const { run: name, params } = run as { run: string; params: unknown }
118 return { run: name, params: paramArgs(params), status: 'pending', dim: true }
119}
120
121const setRow = (rows: Row[], name: unknown, change: Partial<Row>): Row[] =>
122 rows.map(row => (row.run === name ? { ...row, ...change } : row))
123
124const say = (experiment: Experiment, event: LabnotesEvent, rows = experiment.rows): Experiment => ({
125 ...experiment,
126 rows,
127 message: describeEvent(event),
128})
129
130const applyRun = (experiment: Experiment, event: LabnotesEvent, now: number): Experiment => {
131 const { rows } = experiment
132 switch (event.event) {
133 case 'started': {
134 return {
135 ...experiment,
136 isIdle: false,
137 rows: setRow(rows, event.run, {
138 params: paramArgs(event.params),
139 status: 'running',
140 dim: false,
141 startedAt: now,
142 folder: String(event.folder),
143 }),
144 }
145 }
146 case 'ended': {
147 const reason = event.error || (event.exit_code ? `exited ${event.exit_code}` : undefined)
148 const status = reason === undefined ? 'passed' : 'failed'
149 return { ...experiment, rows: setRow(rows, event.run, { status, reason: reason as string, endedAt: now }) }
150 }
151 case 'retried':
152 return say(experiment, event, [...rows, planned({ run: event.retry, params: event.params })])
153 case 'idle': {
154 const idle = { ...experiment, isIdle: true }
155 return { ...idle, message: summarize(idle) }
156 }
157 case 'concurrency':
158 return { ...experiment, concurrency: Number(event.concurrency) }
159 case 'stopping':
160 return say(
161 experiment,
162 event,
163 rows.map(row => (row.status === 'pending' ? { ...row, status: 'skipped' } : row)),
164 )
165 case 'done': {
166 const error = (event.error as string | null) ?? null
167 return {
168 ...experiment,
169 isFinished: true,
170 error,
171 message: error === null ? experiment.message : `Stopped: ${error}`,
172 }
173 }
174 case 'stopping run':
175 case 'killing run':
176 case 'killing':
177 case 'retrying':
178 case 'gave up':
179 case 'retry cancelled':
180 return say(experiment, event)
181 default:
182 return experiment
183 }
184}
185
186export const applyEvent = (
187 experiments: readonly Experiment[],
188 event: LabnotesEvent,
189 now: number,
190): Experiment[] => {
191 const name = event.experiment
192 if (typeof name !== 'string') return [...experiments]
193 if (event.event === 'planned') {
194 const fresh: Experiment = {
195 name,
196 title: String(event.title ?? name),
197 concurrency: Number(event.concurrency ?? 1),
198 rows: ((event.runs as unknown[]) ?? []).map(planned),
199 message: '',
200 isIdle: false,
201 isFinished: false,
202 error: null,
203 }
204 return [...experiments.filter(one => one.name !== name), fresh]
205 }
206 return experiments.map(one => (one.name === name ? applyRun(one, event, now) : one))
207}
208
209export const rowCells = (row: Row, now: number): Cells => ({
210 run: shortName(row.run),
211 params: row.params,
212 status:
213 row.status === 'running'
214 ? undefined
215 : row.status === 'failed'
216 ? `✗ ${row.reason}`
217 : GLYPHS[row.status],
218 statusStyle: row.status === 'failed' ? 'red' : row.status === 'running' || row.status === 'passed' ? '' : 'dim',
219 dim: row.dim,
220 time: row.startedAt === undefined ? '' : formatElapsed(((row.endedAt ?? now) - row.startedAt) / 1000),
221})
222
223export const describeDone = (
224 experiments: readonly Experiment[],
225 event: LabnotesEvent,
226): string | undefined => {
227 if (event.event !== 'done') return undefined
228 const experiment = experiments.find(one => one.name === event.experiment)
229 if (experiment === undefined) return undefined
230 if (experiment.error !== null) return `${experiment.name} stopped: ${experiment.error}`
231 return `${experiment.name} finished, ${summarize(experiment)}`
232}
233
234export const cleanLine = (line: string): string =>
235 line
236 .replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)?/g, '')
237 .split('\r')
238 .filter(part => part !== '')
239 .pop()
240 ?.replace(/\t/g, ' ')
241 .replace(/[\x00-\x1f\x7f]/g, '') ?? ''
242
243const parseRecord = (line: string): Record<string, unknown> | undefined => {
244 try {
245 const record: unknown = JSON.parse(line)
246 if (typeof record === 'object' && record !== null && !Array.isArray(record)) {
247 return record as Record<string, unknown>
248 }
249 } catch {}
250 return undefined
251}
252
253// The TUI's rules: start and delta open a block by id, delta and stop grow it, stop closes it.
254export const applyBlock = (blocks: readonly Block[], line: string): Block[] => {
255 const record = parseRecord(line)
256 if (record === undefined) return [...blocks]
257 const { phase } = record
258 const id = JSON.stringify(record.id ?? null)
259 const body = record.content === undefined ? '' : String(record.content)
260 const at = blocks.findLastIndex(block => block.isOpen && block.id === id)
261 if (at !== -1 && (phase === 'delta' || phase === 'stop')) {
262 return blocks.map((block, index) =>
263 index === at ? { id, body: block.body + body, isOpen: phase === 'delta' } : block,
264 )
265 }
266 if (phase === 'stop') return [...blocks]
267 return [...blocks, { id, body, isOpen: phase === 'start' || phase === 'delta' }]
268}
269
270const chunks = (line: string, width: number): string[] => {
271 if (line === '') return ['']
272 const size = Math.max(width, 1)
273 return Array.from({ length: Math.ceil(line.length / size) }, (_, index) =>
274 line.slice(index * size, (index + 1) * size),
275 )
276}
277
278export const blockRows = (block: Block, isExpanded: boolean, width: number): string[] => {
279 const lines = block.body.split('\n').map(cleanLine)
280 if (!isExpanded) return [`▸ ${lines.join(' ')}`]
281 return `▾ ${lines.join('\n')}`.split('\n').flatMap(line => chunks(line, width))
282}
283types/index.d.ts 34 lines1export type RunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
2
3export type Row = {
4 run: string
5 params: string
6 status: RunStatus
7 reason?: string
8 dim: boolean
9 startedAt?: number
10 endedAt?: number
11 folder?: string
12}
13
14export type Experiment = {
15 name: string
16 title: string
17 concurrency: number
18 rows: Row[]
19 message: string
20 isIdle: boolean
21 isFinished: boolean
22 error: string | null
23}
24
25export type Block = { id: string; body: string; isOpen: boolean }
26
27export type Log = { run: string; lines: string[]; blocks: Block[] }
28
29declare module 'claude-code' {
30 interface PluginState {
31 labnotes: { experiments: Experiment[]; log: Log | null }
32 }
33}
34