SLOPSHOPPER

severance-mdr

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

newpanecommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · severance-mdr
│ ┃ Macrodata Refinement ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./refiner.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 │ │ › /mdr │ ⎿ severance-mdr: The numbers are waiting. Click the board, then re │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Macrodata Refinement
▣ client module ./refiner.tsx
README

Severance MDR

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.

Install

/plugin marketplace add magnuswiderberg/claude-code-mods
/plugin install severance-mdr@magnus-mods

Play

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.

KeyDoes
Arrows, hjkl, wasdMove the cursor
Shift+arrowsPaint a selection
SpaceMark one number
EnterMark the 3x3 square around the cursor
Mouse dragBox a region
1-5 or click a binSend the selection to that bin
cClear the selection
nNew 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.

Good to know

  • The animated board with mouse support runs in a terminal and in the Claude desktop app. The VS Code extension shows a simpler button version of the same game.
  • The plugin API this mod uses is early access and may change between Claude Code releases. If the pane stops showing after an update, that's probably why.

Files

PathHolds
hooks/register.tsx/mdr, opening the pane, agent status, saving, the button board
hooks/refiner.tsxThe animated board with keyboard and mouse input
hooks/mdr.tsxGame rules and drawing, shared by both boards
types/index.d.tsGame types and the state contract
tests/Tests
Source 4 files
hooks/register.tsx 132 lines
1// 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}
132
hooks/mdr.tsx 376 lines
1// 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}
376
hooks/refiner.tsx 107 lines
1// 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
107
types/index.d.ts 35 lines
1/** 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