Macrodata Refinement while your agents work: find the scary numbers and bin them.

Macrodata Refinement for Claude Code. While your agents work, a pane opens with a grid of numbers. Some of them are scary. Find them, box them, and sort them into bins. The work is mysterious and important.
/plugin marketplace add magnuswiderberg/claude-code-mods
/plugin install severance-mdr@magnus-mods
The pane opens each time you submit a prompt. You can also open it yourself with /mdr.
Click the board to give it the keyboard. Esc hands the keyboard back to the prompt.
| Key | Does |
|---|---|
Arrows, hjkl, wasd | Move the cursor |
| Shift+arrows | Paint a selection |
| Space | Mark one number |
| Enter | Mark the 3x3 square around the cursor |
| Mouse drag | Box a region |
1-5 or click a bin | Send the selection to that bin |
c | Clear the selection |
n | New file, once this one is complete (N abandons it) |
Scary numbers give themselves away only when the cursor is near: they turn bold and jitter. Each bin takes a quota of each temper: WO (Woe), FC (Frolic), DR (Dread) and MA (Malice). A full bin refuses the extras, so try another bin. The file is done at 100%. Your progress is saved between sessions.
/mdr auto off stops the pane opening on every prompt. /mdr auto on turns it back on.
| Path | Holds |
|---|---|
hooks/register.tsx | /mdr, opening the pane, agent status, saving, the button board |
hooks/refiner.tsx | The animated board with keyboard and mouse input |
hooks/mdr.tsx | Game rules and drawing, shared by both boards |
types/index.d.ts | Game types and the state contract |
tests/ | Tests |
hooks/register.tsx 132 lines1// Severance MDR: a Macrodata Refinement pane that opens while your agents
2// work. Terminal and desktop draw the animated refiner (./refiner.tsx); the
3// other surfaces, which have no `Client`, play the same file with buttons.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Game } from '../types'
8import { act, asGame, drawBoard, isComplete, layout, newGame } from './mdr'
9import type { Action } from './mdr'
10
11const PANE = 'mdr'
12const TITLE = 'Macrodata Refinement'
13const busy = atom({ plugin: 'severance-mdr', key: 'busy' } as const, false)
14const board = atom({ plugin: 'severance-mdr', key: 'game' } as const, null)
15
16/** Saves a file and counts it once, the first time it is seen complete. */
17async function record($: EngineInterface, game: Game) {
18 await $.store.set('game', game)
19 if (!isComplete(game) || (await $.store.get('counted')) === game.seed) return
20 const files = Number((await $.store.get('files')) ?? 0) + 1
21 await $.store.set('counted', game.seed)
22 await $.store.set('files', files)
23 $.ui.toast(`${game.name} refined. Kier is proud of you. (${files} file${files === 1 ? '' : 's'})`)
24 $.ui.invalidate('ui.render')
25}
26
27async function isAuto($: EngineInterface) {
28 return (await $.store.get('auto')) !== false
29}
30
31export const register: Register = on => {
32 on('session.start', async ($, e, next) => {
33 await $.command.register({
34 name: 'mdr',
35 description: 'Open Macrodata Refinement (/mdr auto on|off to open it with every prompt)',
36 })
37 await update($, busy, () => false)
38
39 return next(e)
40 })
41
42 on('command.run', { command: 'mdr' }, async ($, e) => {
43 const arg = e.args.trim().toLowerCase()
44 if (arg === 'auto on' || arg === 'auto off') {
45 await $.store.set('auto', arg === 'auto on')
46 return { text: `MDR will ${arg === 'auto on' ? 'now' : 'no longer'} open while your agents work.` }
47 }
48 await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: 30 })
49
50 return { text: 'The numbers are waiting. Click the board, then refine.' }
51 })
52
53 on('prompt.submit', async ($, e, next) => {
54 await update($, busy, () => true)
55 if (await isAuto($)) void $.ui.open({ id: PANE, title: TITLE, rows: 30 })
56
57 return next(e)
58 })
59
60 on('turn.complete', async ($, e, next) => {
61 await update($, busy, () => false)
62 const panes = await $.ui.panes()
63 if (panes.some(p => p.id === PANE && p.isShown)) $.ui.toast('Your agents have finished. You may leave the severed floor.')
64
65 return next(e)
66 })
67
68 on('ui.message', { requestId: PANE }, async ($, e) => {
69 const game = asGame((e.data as { game?: unknown } | null)?.game)
70 if (game) await record($, game)
71
72 return {}
73 })
74
75 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
76 const isBusy = await read($, busy)
77 const files = Number((await $.store.get('files')) ?? 0)
78 const saved = asGame(await $.store.get('game'))
79 const seed = Math.floor(await $.clock.now())
80 const rows = Math.max(16, e.props.scroll.bodyRows)
81
82 if (e.surface === 'terminal' || e.surface === 'desktop') {
83 const { Client } = $.ui.resolve(e)
84 return (
85 <Client
86 key="refiner"
87 module="./refiner.tsx"
88 props={{ game: saved, seed, busy: isBusy, files }}
89 width={e.props.bodyColumns}
90 height={rows}
91 />
92 )
93 }
94
95 const { Box, Text, Button } = $.ui.resolve(e)
96 const shown = (await read($, board)) ?? saved ?? newGame(seed)
97 const press = (a: Action) => async () => {
98 const game = await update($, board, g => act(g ?? shown, a))
99 if (game) await record($, game)
100 }
101 const l = layout(shown, e.props.bodyColumns, Math.min(rows, 26))
102 const help = 'Use the buttons, or focus the pane for hotkeys: w a s d move, e mark, q lasso, 1-5 bin.'
103
104 return (
105 <Box flexDirection="column">
106 {drawBoard(shown, { Box, Text }, l, { tick: null, busy: isBusy, files, help })}
107 <Box flexDirection="row">
108 <Button key="up" label="↑" hotkey="w" onPress={press({ t: 'move', dx: 0, dy: -1 })} />
109 <Button key="left" label="←" hotkey="a" onPress={press({ t: 'move', dx: -1, dy: 0 })} />
110 <Button key="down" label="↓" hotkey="s" onPress={press({ t: 'move', dx: 0, dy: 1 })} />
111 <Button key="right" label="→" hotkey="d" onPress={press({ t: 'move', dx: 1, dy: 0 })} />
112 <Button key="mark" label="Mark" hotkey="e" onPress={press({ t: 'toggle' })} />
113 <Button key="lasso" label="Lasso" hotkey="q" onPress={press({ t: 'lasso' })} />
114 <Button key="clear" label="Clear" hotkey="c" onPress={press({ t: 'clear' })} />
115 </Box>
116 <Box flexDirection="row">
117 {[0, 1, 2, 3, 4].map(b => (
118 <Button key={`bin${b}`} label={`Bin 0${b + 1}`} hotkey={String(b + 1)} onPress={press({ t: 'bin', b })} />
119 ))}
120 <Button
121 key="new"
122 label="New file"
123 hotkey="n"
124 variant={isComplete(shown) ? 'primary' : 'secondary'}
125 onPress={press({ t: 'new' })}
126 />
127 </Box>
128 </Box>
129 )
130 })
131}
132hooks/mdr.tsx 376 lines1// Macrodata Refinement: the game itself, shared by the `Client` refiner
2// (terminal, desktop) and the button board (every other surface). Pure
3// functions over a `Game`, which is plain data; the board is derived from
4// its seed, so a saved file is only a few hundred numbers.
5import type { ClientElements, RenderElement } from 'claude-code'
6
7import type { Game, Tally } from '../types'
8
9export const W = 40
10export const H = 20
11export const TEMPERS = ['WO', 'FC', 'DR', 'MA'] as const
12const TEMPER_NAMES = ['Woe', 'Frolic', 'Dread', 'Malice']
13const TEMPER_COLORS = ['#8fd694', '#f4d35e', '#7aa2f7', '#f7768e']
14const BINS = 5
15/** How near the cursor a scary number must be before it stirs. */
16const STIR = 3
17
18const FILES = [
19 'Cold Harbor', 'Siena', 'Tumwater', 'Dranesville', 'Allentown', 'Moonbeam',
20 'Labrador', 'Jesup', 'Kingsport', 'Narva', 'Ocula', 'Wellington', 'Le Mars',
21 'Culpepper', 'Yakima', 'Eagan', 'Coxsackie', 'Loveland',
22]
23
24const QUOTES = [
25 'Please enjoy each number equally.',
26 'The work is mysterious and important.',
27 'Kier sees all refinement as an act of love.',
28 'A handshake is available upon request.',
29 'Remember: the scary numbers fear you more than you fear them.',
30 'Waffle party eligibility is reviewed quarterly.',
31]
32
33export type Board = {
34 digits: number[]
35 /** Temper index of each scary cell, -1 for a calm one. */
36 bad: number[]
37 quota: Tally[]
38 total: number
39}
40
41export type Action =
42 | { t: 'move'; dx: number; dy: number; extend?: boolean }
43 | { t: 'goto'; x: number; y: number }
44 | { t: 'toggle' }
45 | { t: 'lasso' }
46 | { t: 'clear' }
47 | { t: 'rect'; x0: number; y0: number; x1: number; y1: number }
48 | { t: 'bin'; b: number }
49 | { t: 'new' }
50
51function rng(seed: number): () => number {
52 let a = seed >>> 0
53 return () => {
54 a = (a + 0x6d2b79f5) >>> 0
55 let t = a
56 t = Math.imul(t ^ (t >>> 15), t | 1)
57 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
58 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
59 }
60}
61
62let cached: { seed: number; board: Board } | null = null
63
64export function boardOf(seed: number): Board {
65 if (cached?.seed === seed) return cached.board
66 const r = rng(seed)
67 const digits = Array.from({ length: W * H }, () => Math.floor(r() * 10))
68 const bad = new Array<number>(W * H).fill(-1)
69 const clusters = 12 + Math.floor(r() * 5)
70 for (let c = 0; c < clusters; c++) {
71 const temper = Math.floor(r() * 4)
72 const size = 4 + Math.floor(r() * 6)
73 let x = 1 + Math.floor(r() * (W - 2))
74 let y = 1 + Math.floor(r() * (H - 2))
75 for (let n = 0, tries = 0; n < size && tries < 40; tries++) {
76 const i = y * W + x
77 if (bad[i] === -1) {
78 bad[i] = temper
79 n++
80 }
81 x = Math.min(W - 1, Math.max(0, x + Math.floor(r() * 3) - 1))
82 y = Math.min(H - 1, Math.max(0, y + Math.floor(r() * 3) - 1))
83 }
84 }
85 const per: Tally = [0, 0, 0, 0]
86 for (const k of bad) if (k >= 0) per[k]!++
87 const quota = Array.from({ length: BINS }, (_, b) =>
88 per.map(t => Math.floor(t / BINS) + (b < t % BINS ? 1 : 0)) as Tally,
89 )
90 const board = { digits, bad, quota, total: per.reduce((s, n) => s + n, 0) }
91 cached = { seed, board }
92 return board
93}
94
95export function newGame(seed: number): Game {
96 const s = (seed >>> 0) || 1
97 return {
98 v: 1,
99 seed: s,
100 name: FILES[s % FILES.length]!,
101 cx: Math.floor(W / 2),
102 cy: Math.floor(H / 2),
103 sel: [],
104 done: [],
105 bins: Array.from({ length: BINS }, () => [0, 0, 0, 0] as Tally),
106 last: -1,
107 msg: QUOTES[s % QUOTES.length]!,
108 }
109}
110
111/** A saved value read back from storage, if it still has the shape of one. */
112export function asGame(value: unknown): Game | null {
113 const g = value as Game | null
114 return g && g.v === 1 && typeof g.seed === 'number' && Array.isArray(g.bins) ? g : null
115}
116
117export function percent(g: Game): number {
118 const { total } = boardOf(g.seed)
119 return total === 0 ? 100 : Math.floor((g.done.length / total) * 100)
120}
121
122export function isComplete(g: Game): boolean {
123 return g.done.length >= boardOf(g.seed).total
124}
125
126const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
127
128function withSel(g: Game, cells: number[]): Game {
129 const sel = new Set(g.sel)
130 for (const i of cells) sel.add(i)
131 return { ...g, sel: [...sel] }
132}
133
134export function act(g: Game, a: Action): Game {
135 switch (a.t) {
136 case 'move': {
137 const cx = clamp(g.cx + a.dx, 0, W - 1)
138 const cy = clamp(g.cy + a.dy, 0, H - 1)
139 const moved = { ...g, cx, cy }
140 return a.extend ? withSel(moved, [g.cy * W + g.cx, cy * W + cx]) : moved
141 }
142 case 'goto':
143 return { ...g, cx: clamp(a.x, 0, W - 1), cy: clamp(a.y, 0, H - 1) }
144 case 'toggle': {
145 const i = g.cy * W + g.cx
146 return g.sel.includes(i) ? { ...g, sel: g.sel.filter(j => j !== i) } : withSel(g, [i])
147 }
148 case 'lasso':
149 return act(g, { t: 'rect', x0: g.cx - 1, y0: g.cy - 1, x1: g.cx + 1, y1: g.cy + 1 })
150 case 'rect': {
151 const cells: number[] = []
152 for (let y = clamp(Math.min(a.y0, a.y1), 0, H - 1); y <= clamp(Math.max(a.y0, a.y1), 0, H - 1); y++)
153 for (let x = clamp(Math.min(a.x0, a.x1), 0, W - 1); x <= clamp(Math.max(a.x0, a.x1), 0, W - 1); x++)
154 cells.push(y * W + x)
155 return withSel(g, cells)
156 }
157 case 'clear':
158 return { ...g, sel: [] }
159 case 'bin':
160 return bin(g, a.b)
161 case 'new': {
162 const next = newGame(rng(g.seed ^ 0x9e3779b9)() * 4294967296)
163 return { ...next, msg: `File "${next.name}" opened. ${next.msg}` }
164 }
165 }
166}
167
168function bin(g: Game, b: number): Game {
169 if (b < 0 || b >= BINS) return g
170 const label = binLabel(b)
171 if (g.sel.length === 0) return { ...g, last: b, msg: `Select numbers before opening bin ${label}.` }
172 const board = boardOf(g.seed)
173 const done = new Set(g.done)
174 const scary = g.sel.filter(i => board.bad[i]! >= 0 && !done.has(i)).sort((x, y) => x - y)
175 const calm = g.sel.length - scary.length
176 if (scary.length === 0) {
177 return { ...g, sel: [], last: b, msg: 'Those numbers are not scary. Look closer.' }
178 }
179 const bins = g.bins.map(t => [...t] as Tally)
180 const kept: number[] = []
181 const full = new Set<string>()
182 for (const i of scary) {
183 const k = board.bad[i]!
184 if (bins[b]![k]! < board.quota[b]![k]!) {
185 bins[b]![k]!++
186 done.add(i)
187 } else {
188 kept.push(i)
189 full.add(TEMPER_NAMES[k]!)
190 }
191 }
192 const taken = scary.length - kept.length
193 const parts = [taken > 0 ? `Bin ${label} took ${taken}.` : `Bin ${label} refuses.`]
194 if (full.size > 0) parts.push(`It has no room for ${[...full].join(', ')}.`)
195 if (calm > 0) parts.push(`${calm} calm number${calm === 1 ? ' was' : 's were'} released.`)
196 const next = { ...g, sel: kept, done: [...done], bins, last: b, msg: parts.join(' ') }
197 return isComplete(next)
198 ? { ...next, msg: `${g.name} is 100% complete. Kier is proud of you. Press n for a new file.` }
199 : next
200}
201
202export const binLabel = (b: number) => String(b + 1).padStart(2, '0')
203
204export type Layout = {
205 columns: number
206 rows: number
207 /** Visible grid columns and rows. */
208 vc: number
209 vr: number
210 /** Board coordinates of the visible window's top-left cell. */
211 ox: number
212 oy: number
213 gridTop: number
214 binTop: number
215 binW: number
216}
217
218const CELL = 3
219const HEADER = 2
220const FOOTER = 7
221
222/** Moves a window edge only as far as keeps the cursor `margin` cells inside. */
223function follow(at: number, cursor: number, size: number, max: number): number {
224 const margin = Math.min(2, Math.floor((size - 1) / 2))
225 let n = at
226 if (cursor < n + margin) n = cursor - margin
227 if (cursor > n + size - 1 - margin) n = cursor - size + 1 + margin
228 return clamp(n, 0, max - size)
229}
230
231/**
232 * Where everything sits in a drawing `columns` by `rows`. Given the previous
233 * window (`view`), it scrolls only as the cursor nears an edge; without one
234 * it centres on the cursor.
235 */
236export function layout(g: Game, columns: number, rows: number, view?: { ox: number; oy: number } | null): Layout {
237 const cols = Math.max(30, columns)
238 const vc = Math.min(W, Math.max(5, Math.floor(cols / CELL)))
239 const vr = Math.min(H, Math.max(3, rows - HEADER - FOOTER))
240 const ox = view ? follow(view.ox, g.cx, vc, W) : clamp(g.cx - Math.floor(vc / 2), 0, W - vc)
241 const oy = view ? follow(view.oy, g.cy, vr, H) : clamp(g.cy - Math.floor(vr / 2), 0, H - vr)
242 return { columns: cols, rows, vc, vr, ox, oy, gridTop: HEADER, binTop: HEADER + vr + 1, binW: Math.floor(cols / BINS) }
243}
244
245/** The bin under a point of the drawing, or -1. */
246export function binAt(l: Layout, x: number, y: number): number {
247 if (y < l.binTop || y > l.binTop + 1) return -1
248 return clamp(Math.floor(x / l.binW), 0, BINS - 1)
249}
250
251export type DrawOptions = {
252 /** The animation frame, or null where the surface does not animate. */
253 tick: number | null
254 busy: boolean
255 files: number
256 /** A drag in progress, in board coordinates. */
257 drag?: { x0: number; y0: number; x1: number; y1: number } | null
258 help: string
259}
260
261type Style = { color?: string; bold?: boolean; inverse?: boolean; backgroundColor?: string; dimColor?: boolean }
262
263const pad = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
264
265function bar(fill: number, width: number): string {
266 const n = Math.round(clamp(fill, 0, 1) * width)
267 return '█'.repeat(n) + '░'.repeat(width - n)
268}
269
270export function drawBoard(g: Game, els: Pick<ClientElements, 'Box' | 'Text'>, l: Layout, o: DrawOptions): RenderElement {
271 const { Box, Text } = els
272 const board = boardOf(g.seed)
273 const sel = new Set(g.sel)
274 const done = new Set(g.done)
275 const pct = percent(g)
276 const tick = o.tick ?? 0
277 const d = o.drag
278 const inDrag = (x: number, y: number) =>
279 !!d && x >= Math.min(d.x0, d.x1) && x <= Math.max(d.x0, d.x1) && y >= Math.min(d.y0, d.y1) && y <= Math.max(d.y0, d.y1)
280
281 const right = `${pct}% Complete ◖LUMON◗`
282 const left = ` ▌${g.name}`
283 const header = left + ' '.repeat(Math.max(1, l.columns - left.length - right.length)) + right
284
285 const gridRows: RenderElement[] = []
286 for (let gy = 0; gy < l.vr; gy++) {
287 const y = l.oy + gy
288 const spans: RenderElement[] = []
289 let run = ''
290 let runKey = ''
291 let runStyle: Style = {}
292 const flush = () => {
293 if (run) spans.push(<Text {...runStyle}>{run}</Text>)
294 run = ''
295 }
296 for (let gx = 0; gx < l.vc; gx++) {
297 const x = l.ox + gx
298 const i = y * W + x
299 const scary = board.bad[i]! >= 0 && !done.has(i)
300 const near = Math.max(Math.abs(x - g.cx), Math.abs(y - g.cy)) <= STIR
301 const stirs = scary && near
302 const digit = String(done.has(i) ? (board.digits[i]! + 5) % 10 : board.digits[i]!)
303 let at = 1
304 if (stirs) at = o.tick === null ? i % 3 : Math.round(1 + Math.sin(tick * 0.9 + i * 1.7))
305 const text = ' '.repeat(at) + digit + ' '.repeat(CELL - 1 - at)
306 const shimmer = o.tick !== null && Math.sin(tick * 0.12 - x * 0.35 + y * 0.25) > 0.75
307 let style: Style
308 if (x === g.cx && y === g.cy) style = { inverse: true, bold: true }
309 else if (sel.has(i)) style = { color: '#ffffff', backgroundColor: '#1f6f8f', bold: stirs }
310 else if (inDrag(x, y)) style = { color: '#e8fbff', backgroundColor: '#163f50' }
311 else if (stirs) style = { color: '#f2fdff', bold: true }
312 else style = { color: shimmer ? '#b6ecfb' : '#6fb8d6' }
313 const key = JSON.stringify(style)
314 if (key !== runKey) {
315 flush()
316 runKey = key
317 runStyle = style
318 }
319 run += text
320 }
321 flush()
322 gridRows.push(<Text wrap="truncate">{spans}</Text>)
323 }
324
325 const binCells: RenderElement[] = []
326 const barCells: RenderElement[] = []
327 for (let b = 0; b < BINS; b++) {
328 const filled = g.bins[b]!.reduce((s, n) => s + n, 0)
329 const want = board.quota[b]!.reduce((s, n) => s + n, 0)
330 const fill = want === 0 ? 1 : filled / want
331 const isLast = b === g.last
332 const w = Math.max(6, l.binW - 1)
333 const title = pad(` ${binLabel(b)}${fill >= 1 ? ' ✓' : ''}`, w)
334 const pctText = `${Math.floor(fill * 100)}%`.padStart(4)
335 binCells.push(
336 <Text bold={isLast} color={isLast ? '#e8fbff' : '#6fb8d6'} inverse={isLast && o.tick !== null && tick % 6 < 3}>
337 {title}
338 </Text>,
339 )
340 barCells.push(<Text color={fill >= 1 ? '#8fd694' : '#6fb8d6'}>{pad(' ' + bar(fill, Math.max(1, w - 6)) + pctText, w + 1)}</Text>)
341 }
342
343 const detail: RenderElement[] = []
344 if (g.last >= 0) {
345 detail.push(<Text dimColor>{` Bin ${binLabel(g.last)} `}</Text>)
346 TEMPERS.forEach((t, k) =>
347 detail.push(
348 <Text color={TEMPER_COLORS[k]}>{` ${t} ${g.bins[g.last]![k]}/${board.quota[g.last]![k]} `}</Text>,
349 ),
350 )
351 } else {
352 detail.push(<Text dimColor> Numbers in bins are sorted by temper: </Text>)
353 TEMPERS.forEach((t, k) => detail.push(<Text color={TEMPER_COLORS[k]}>{` ${t} ${TEMPER_NAMES[k]} `}</Text>))
354 }
355
356 const agents = o.busy
357 ? ' ● Your agents are at work. Refine while you wait.'
358 : ' ○ Your agents are idle. You may leave the severed floor.'
359 const filesText = o.files > 0 ? ` Files refined: ${o.files}` : ''
360
361 return (
362 <Box flexDirection="column">
363 <Text bold color="#e8fbff" wrap="truncate">{header}</Text>
364 <Text color="#2f6f88" wrap="truncate">{'─'.repeat(l.columns)}</Text>
365 {gridRows}
366 <Text color="#2f6f88" wrap="truncate">{'─'.repeat(l.columns)}</Text>
367 <Text wrap="truncate">{binCells}</Text>
368 <Text wrap="truncate">{barCells}</Text>
369 <Text wrap="truncate">{detail}</Text>
370 <Text color="#e8fbff" wrap="truncate">{' ' + g.msg}</Text>
371 <Text color={o.busy ? '#f4d35e' : '#8fd694'} wrap="truncate">{agents + filesText}</Text>
372 <Text dimColor wrap="truncate">{' ' + o.help}</Text>
373 </Box>
374 )
375}
376hooks/refiner.tsx 107 lines1// The refiner terminal: a `Client` surface module, so the numbers stir on
2// the frame clock and the keys and the mouse reach the board directly. It
3// keeps the file in its local state and posts each change to the hooks
4// module, which saves it.
5import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
6
7import type { Game } from '../types'
8import { act, asGame, binAt, drawBoard, isComplete, layout, newGame } from './mdr'
9import type { Action, Layout } from './mdr'
10
11type Props = { game: Game | null; seed: number; busy: boolean; files: number }
12type Drag = { x0: number; y0: number; x1: number; y1: number }
13type Local = { game: Game; tick: number; drag: Drag | null; view: { ox: number; oy: number } | null }
14type Surface = ClientSurface<Local>
15
16const HELP = '←↑↓→ move · shift+arrows paint · space mark · enter lasso · drag to box · 1-5 bin · c clear · esc leave'
17
18const MOVES: Record<string, [number, number]> = {
19 up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0],
20 k: [0, -1], j: [0, 1], h: [-1, 0], l: [1, 0],
21 w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0],
22}
23
24function place(surface: Surface, st: Local): Layout {
25 return layout(st.game, surface.columns || 80, surface.rows || 24, st.view)
26}
27
28function commit(surface: Surface, st: Local, game: Game, rest: Partial<Local> = {}) {
29 const next = { ...st, ...rest, game }
30 const l = place(surface, next)
31 surface.setState({ ...next, view: { ox: l.ox, oy: l.oy } })
32 if (game !== st.game) surface.post({ game })
33}
34
35function onKey(surface: Surface, k: ClientKeyEvent) {
36 const st = surface.state
37 if (!st) return
38 const g = st.game
39 const move = MOVES[k.key]
40 let a: Action | null = null
41 if (move) a = { t: 'move', dx: move[0], dy: move[1], extend: k.shift && k.key.length > 1 }
42 else if (k.key === ' ' || k.key === 'space') a = { t: 'toggle' }
43 else if (k.key === 'return' || k.key === 'x') a = { t: 'lasso' }
44 else if (k.key === 'c' || k.key === 'backspace' || k.key === 'delete') a = { t: 'clear' }
45 else if (/^[1-5]$/.test(k.key)) a = { t: 'bin', b: Number(k.key) - 1 }
46 else if (k.key === 'N' || (k.key === 'n' && isComplete(g))) a = { t: 'new' }
47 else if (k.key === 'n') {
48 commit(surface, st, { ...g, msg: `Finish ${g.name} first, or press N to abandon it.` })
49 return
50 }
51 if (a) commit(surface, st, act(g, a), a.t === 'new' ? { view: null } : {})
52}
53
54function onPointer(surface: Surface, p: ClientPointerEvent) {
55 const st = surface.state
56 if (!st) return
57 const l = place(surface, st)
58 const cell = { x: l.ox + Math.floor(p.x / 3), y: l.oy + p.y - l.gridTop }
59 const onGrid = p.y >= l.gridTop && p.y < l.gridTop + l.vr
60 if (p.type === 'down' && p.button === 'left') {
61 if (onGrid) {
62 surface.setState({ ...st, drag: { x0: cell.x, y0: cell.y, x1: cell.x, y1: cell.y } })
63 return
64 }
65 const b = binAt(l, p.x, p.y)
66 if (b >= 0) commit(surface, st, act(st.game, { t: 'bin', b }))
67 return
68 }
69 const d = st.drag
70 if (!d) return
71 if (p.type === 'move') {
72 if (d.x1 !== cell.x || d.y1 !== cell.y) surface.setState({ ...st, drag: { ...d, x1: cell.x, y1: cell.y } })
73 return
74 }
75 if (p.type === 'up') {
76 const clicked = d.x0 === d.x1 && d.y0 === d.y1
77 const game = clicked
78 ? act(act(st.game, { t: 'goto', x: d.x0, y: d.y0 }), { t: 'toggle' })
79 : act(act(st.game, { t: 'rect', ...d }), { t: 'goto', x: d.x1, y: d.y1 })
80 commit(surface, st, game, { drag: null })
81 }
82}
83
84const Refiner: ClientModule<Props, Local> = (props, surface) => {
85 let st = surface.state
86 if (!st) {
87 st = { game: asGame(props.game) ?? newGame(props.seed), tick: 0, drag: null, view: null }
88 surface.setState(st)
89 surface.every(140, () => {
90 const now = surface.state
91 if (now) surface.setState({ ...now, tick: now.tick + 1 })
92 })
93 surface.onKey(k => onKey(surface, k))
94 surface.onPointer(p => onPointer(surface, p))
95 }
96 const l = place(surface, st)
97 return drawBoard(st.game, surface.elements, l, {
98 tick: st.tick,
99 busy: props.busy,
100 files: props.files,
101 drag: st.drag,
102 help: HELP,
103 })
104}
105
106export default Refiner
107types/index.d.ts 35 lines1/** One of the four tempers a scary number carries: Woe, Frolic, Dread, Malice. */
2export type Temper = 'WO' | 'FC' | 'DR' | 'MA'
3
4/** Counts per temper, in the order WO, FC, DR, MA. */
5export type Tally = [number, number, number, number]
6
7/** A refinement file in progress: everything else is derived from `seed`. */
8export type Game = {
9 v: 1
10 seed: number
11 name: string
12 cx: number
13 cy: number
14 /** Selected cell indices. */
15 sel: number[]
16 /** Refined (binned) cell indices. */
17 done: number[]
18 /** Five bins, each filled per temper. */
19 bins: Tally[]
20 /** The bin last touched, shown in the detail line; -1 for none. */
21 last: number
22 msg: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'severance-mdr': {
28 /** True while a turn runs: the agents are working. */
29 busy: boolean
30 /** The file the button-driven board (no `Client` surface) plays. */
31 game: Game | null
32 }
33 }
34}
35