SLOPSHOPPER

labnotes

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

newpaneguardcommandtoasttool
v0.1.0MITupdated 2026-10-26thekevinscott/labnotes/skills/labnotes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · labnotes
│ ┃ labnotes ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./pane.tsx │ ⏺ 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 │ │ › /labnotes-runs │ ⎿ labnotes: labnotes pane opened. Click it to use its keys. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · labnotes
▣ client module ./pane.tsx
README

labnotes

labnotes helps you manage your experiments.

Getting Started

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.

CLI

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.

Skill

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.

Frontend

There's a frontend for interacting with labnotes:

uvx labnotes server

Pass --host and --port as CLI vars and every other uvicorn option.

Example Organizations

Three paths in .labnotes.toml say where experiments, files and runs live. Two layouts cover most projects:

  • One Folder, One Experiment: each experiment is a folder, with its notes in files and its runs in runs. uvx labnotes init writes this one.
  • One Run Tree, Many Experiments: notes stay with each experiment, and all runs go in one runs tree with a folder per experiment.

Common Organizations shows both, with the config for each.

Source 4 files
hooks/register.tsx 145 lines
1import { 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}
145
hooks/pane.tsx 204 lines
1import 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
204
hooks/runs.ts 283 lines
1import 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}
283
types/index.d.ts 34 lines
1export 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