@xsft/panels 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/lib/table.js +198 -125
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,11 +31,11 @@ Run the command in the folder of the product. Each judge starts in that folder,
31
31
 
32
32
  On a terminal, `panels run` shows the overview, and each judge runs in its interactive harness in a pseudo-terminal inside the command. Each judge writes `judges/N/answer.json` and stays open. The overview writes the report when each judge has an answer or an error, and the command exits from `report.json` when you quit. Without a terminal, the judges run in the background (`claude -p`, `codex exec`), and stdout prints `N/4 name` and the verdict of each criterion, then the report path. Plain mode needs `--yolo`, because a background judge cannot answer a question; without it the command exits 2.
33
33
 
34
- The overview fills the terminal: a header line with the run (panel and target at the left, the version, the harness, and the elapsed time at the right edge) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red, a grey `·/4` until the score arrives), the time the judge has run, and the tokens it used, with a total row at the bottom under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, the verdict paragraph, and at the bottom the key line and a grey fact line with the judge number, the harness, the time, and the answer file. While a judge runs, its detail shows the Cross mark, a half disc in a square frame drawn in a halftone screen that breathes, centred in the pane, with the time under it. One soft wave sweeps across the running rows and the mark. On the overview, `↑` and `↓` move the cursor, `r` restarts the judge of the cursor row with its harness (its answer is dropped and the report is written again when it finishes), and `h` flips that harness between `claude` and `codex`. The page never says pass or fail; the total is the result.
34
+ The overview fills the terminal: a header line with the run (panel and target at the left, the version, the harness, and the elapsed time at the right edge) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red, a grey `·/4` until the score arrives), the time the judge has run, and the tokens it used, with a total row at the bottom under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, then a sub tab bar, `Verdict │ Criterion`, and on the last row the key line. `tab` switches the sub tab: Verdict shows the verdict paragraph, Criterion the description and the body of the criterion as plain text. `enter` moves the focus into the pane (an orange bar marks it): `↑` and `↓` scroll one line, `PgUp` and `PgDn` a page, `esc` or `enter` return to the list. While a judge runs, the Verdict sub tab shows a piece of halftone art in a square frame, generated from a seed per criterion for the run (a half disc, a disc, a ring, a quarter disc, a band, or two discs), breathing, centred in the pane, with the time under it. One soft wave sweeps across the running rows and the art. On the overview, `↑` and `↓` move the cursor. The page never says pass or fail; the total is the result.
35
35
 
36
- The second tab, `Panel`, holds the text of the panel: the criteria at the left with the same cursor as the overview, and at the right the description and the body of the cursor criterion as plain text. `enter` moves the focus into that pane (an orange bar marks it): `↑` and `↓` scroll one line, `PgUp` and `PgDn` a page, `esc` or `enter` return to the list.
36
+ `r` opens a small modal that asks before it restarts the judge of the cursor row (the current answer is discarded; `enter` on restart runs it, `esc` cancels). `h` opens a modal that picks the harness of that judge, `claude` or `codex`, for its next restart. While a modal is open, every other key is ignored.
37
37
 
38
- A tab strip on the last three lines shows a hairline, the tabs (`Overview`, `Panel`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
38
+ A tab strip on the last three lines shows a hairline, the tabs (`Overview`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. The strip is the navigation area. With the focus on the strip, `←` and `→` move the active tab and switch the view, `1` to `9` jump to a started judge by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, so the strip keeps the focus there. A harness that has not seen the folder asks whether to trust it. The first judge starts alone; press `1`, answer, and the other judges start.
39
39
 
40
40
  ## Run folder
41
41
 
package/lib/table.js CHANGED
@@ -107,29 +107,65 @@ export function wrap(text, width) {
107
107
  /** The scores added as quarters: 1/4 + 1/4 + 2/4 is "1". No trailing zeros. */
108
108
  const quarters = (sum) => String(Math.round((sum / 4) * 100) / 100);
109
109
 
110
- // The Cross mark: a half disc inside a square frame, drawn in a halftone screen. Two sizes, rows by columns.
110
+ // The art of a running judge: a halftone piece inside a square frame, generated from a seed. Two sizes, rows by columns.
111
111
  const MARK_SIZES = [[11, 22], [7, 14]];
112
112
  const HALFTONE = [' ', '░', '▒', '▓'];
113
- const BREATH = 20;
113
+ const SHAPES = ['half-left', 'half-right', 'half-top', 'half-bottom', 'disc', 'ring', 'quarter-tl', 'quarter-tr', 'quarter-bl', 'quarter-br', 'band', 'twin'];
114
+
115
+ /** A small seeded random generator (mulberry32): the same seed gives the same sequence. */
116
+ export function mulberry32(seed) {
117
+ let a = seed | 0;
118
+ return () => {
119
+ a = (a + 0x6D2B79F5) | 0;
120
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
121
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
122
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
123
+ };
124
+ }
114
125
 
115
126
  /**
116
- * The lines of the Cross mark at frame: a square frame of box-drawing lines, grey, and inside it a half disc on
117
- * the left, its flat side on the frame, made of halftone cells two columns per row. The screen breathes: its
118
- * density pulses over BREATH frames, and the glimmer wave runs across the cells by screen column, where start is
119
- * the screen column of the frame. size is [rows, columns].
127
+ * The lines of the art of a judge at frame: a grey square frame of box-drawing lines, and inside it a shape in a
128
+ * halftone screen, two cells per row unit so it looks round. The seed picks the shape (a half disc on one side,
129
+ * a disc, a ring, a quarter disc in a corner, a diagonal band, or two discs), its size and place, the grain of
130
+ * the screen, and the phase and the speed of its pulse. The screen breathes, and the glimmer wave runs across the
131
+ * cells by screen column, where start is the screen column of the frame. size is [rows, columns].
120
132
  */
121
- export function renderMark({ frame = 0, start = 0, width = 80, size = MARK_SIZES[0] }) {
133
+ export function renderArt({ seed = 0, frame = 0, start = 0, width = 80, size = MARK_SIZES[0] }) {
134
+ const rng = mulberry32(seed);
135
+ const shape = SHAPES[Math.floor(rng() * SHAPES.length)];
136
+ const scale = 0.65 + rng() * 0.35;
137
+ const dx = (rng() - 0.5) * 0.3;
138
+ const dy = (rng() - 0.5) * 0.3;
139
+ const phase = rng() * Math.PI * 2;
140
+ const period = 14 + rng() * 12;
141
+ const grain = 0.4 + rng() * 0.6;
122
142
  const [rows, cols] = size;
123
143
  const inner = { rows: rows - 2, cols: cols - 2 };
124
- const cy = (inner.rows - 1) / 2;
125
- const radius = inner.rows / 2;
126
- const pulse = Math.sin((2 * Math.PI * frame) / BREATH);
144
+ const w = inner.cols / 2;
145
+ const h = inner.rows;
146
+ const r = (scale * h) / 2;
147
+ const centres = {
148
+ 'half-left': [0, h / 2], 'half-right': [w, h / 2], 'half-top': [w / 2, 0], 'half-bottom': [w / 2, h],
149
+ 'quarter-tl': [0, 0], 'quarter-tr': [w, 0], 'quarter-bl': [0, h], 'quarter-br': [w, h],
150
+ };
151
+ const [cx, cy] = centres[shape] ?? [w / 2 + dx * w, h / 2 + dy * h];
152
+ // The distance of a cell from the shape, 0 at the heart and 1 at the edge; past 1 the cell is empty.
153
+ const distance = (u, v) => {
154
+ const d = Math.hypot(u - cx, v - cy);
155
+ if (shape === 'ring') { const t = Math.abs(d - r * 0.75) / (r * 0.3); return t; }
156
+ if (shape === 'band') return Math.abs((u - cx) - (v - cy)) / (r * 0.6);
157
+ if (shape === 'twin') return Math.min(Math.hypot(u - (cx - r * 0.5), v - cy), Math.hypot(u - (cx + r * 0.5), v - cy)) / (r * 0.75);
158
+ if (shape.startsWith('quarter')) return d / (r * 1.5);
159
+ return d / r;
160
+ };
161
+ const pulse = Math.sin((2 * Math.PI * frame) / period + phase);
127
162
  const lines = [`${GREY}┌${'─'.repeat(inner.cols)}┐${RESET}`];
128
163
  for (let y = 0; y < inner.rows; y++) {
129
164
  let cells = '';
130
165
  for (let x = 0; x < inner.cols; x++) {
131
- const d = Math.hypot((x + 0.5) / 2, y - cy) / radius;
132
- cells += d > 1 ? ' ' : HALFTONE[Math.max(1, Math.min(3, Math.round(1.5 + (1 - d) * 1.5 + pulse)))];
166
+ const d = distance((x + 0.5) / 2, y + 0.5);
167
+ const noise = mulberry32(seed * 7919 + y * 1021 + x * 31)() - 0.5;
168
+ cells += d > 1 ? ' ' : HALFTONE[Math.max(1, Math.min(3, Math.round(1.5 + (1 - d) * 1.5 + pulse + noise * grain)))];
133
169
  }
134
170
  lines.push(`${GREY}│${RESET}${glimmer(cells, frame, start + 1, width)}${GREY}│${RESET}`);
135
171
  }
@@ -137,7 +173,7 @@ export function renderMark({ frame = 0, start = 0, width = 80, size = MARK_SIZES
137
173
  return lines;
138
174
  }
139
175
 
140
- /** The size of the mark that fits a pane of columnWidth by lines, or null when only the one-line form fits. */
176
+ /** The size of the art that fits a pane of columnWidth by lines, or null when only the one-line form fits. */
141
177
  export const markSize = (columnWidth, lines) => MARK_SIZES.find(([r, c]) => r + 2 <= lines && c <= columnWidth) ?? null;
142
178
 
143
179
  /**
@@ -194,11 +230,30 @@ const headerLine = (title, target, harness, elapsed, width, version) => {
194
230
 
195
231
  const paneHeight = (width, body) => (width >= TWO_COLUMNS ? body : body - Math.floor(body / 2) - 1);
196
232
 
197
- /** The number of lines of the text pane of the Panel tab that the scroll can hide. */
198
- export function panelScrollMax({ rows, cursor, width, height }) {
199
- const { columnWidth } = layout(rows, width);
200
- const window = paneHeight(width, height - 2) - 2;
201
- return window < 1 ? 0 : Math.max(0, criterionText(rows[cursor], columnWidth).length - window);
233
+ // The detail pane holds the header, the sub tab bar, its hairline, the content, then the key line on its last row.
234
+ const PANE_FRAME = 4;
235
+
236
+ /** The lines of the detail pane under its sub tab bar: the verdict (or the mark while running), or the criterion text. */
237
+ function detailContent(row, sub, columnWidth, start, width, frame, window) {
238
+ if (sub === 'criterion') return criterionText(row, columnWidth);
239
+ if (row.state === 'running' && !row.trust) {
240
+ const since = row.restarted !== null ? `restarted with ${row.harness} · ` : '';
241
+ const line = `${GREY}${since}${clock(row.time ?? 0)} · ${row.rank} to watch${RESET}`;
242
+ const size = markSize(columnWidth, window ?? MARK_SIZES[0][0] + 2);
243
+ const art = size ? renderArt({ seed: row.seed ?? 0, frame, start: start + Math.floor((columnWidth - size[1]) / 2), width, size }) : [glimmer('judging', frame, start + Math.floor((columnWidth - 7) / 2), width)];
244
+ const block = [...art, '', line].map((l) => centre(l, columnWidth));
245
+ const above = window === undefined ? 0 : Math.max(0, Math.floor((window - block.length) / 2));
246
+ return [...Array(above).fill(''), ...block];
247
+ }
248
+ const text = row.state === 'done' ? row.result.verdict : row.state === 'error' ? row.error : row.state === 'queued' ? 'The judge has not started.' : `Press ${row.rank} and answer the trust prompt.`;
249
+ return wrap(text, columnWidth);
250
+ }
251
+
252
+ /** The number of lines of the detail pane of the overview that the scroll can hide, for the sub tab sub. */
253
+ export function overviewScrollMax({ rows, cursor, sub = 'verdict', width, height }) {
254
+ const { columnWidth, start } = layout(rows, width);
255
+ const window = paneHeight(width, height - 2) - PANE_FRAME;
256
+ return window < 1 ? 0 : Math.max(0, detailContent(rows[cursor], sub, columnWidth, start, width, 0, window).length - window);
202
257
  }
203
258
 
204
259
  /**
@@ -227,17 +282,21 @@ function page({ header, width, height, left, leftBottom, pane, edge }) {
227
282
  }
228
283
 
229
284
  /**
230
- * The lines of the overview. rows hold { rank, name, state, result, error, trust, time, tokens, harness, restarted },
285
+ * The lines of the overview. rows hold { rank, name, seed, state, result, error, trust, time, tokens, harness, restarted },
231
286
  * and state is queued, running, done, or error. time is the seconds the judge has run, tokens the count it used
232
- * (null for none), harness the one its next restart uses, restarted the seconds since a restart (null for none).
287
+ * (null for none), harness the one its next restart uses, restarted the seconds since a restart (null for none),
288
+ * seed the seed of the art of the criterion.
233
289
  * cursor is the index of the row whose detail shows at the right. The header line holds the run: panel and target
234
290
  * at the left, the version, the harness, and the elapsed time at the right edge. Two columns from TWO_COLUMNS wide,
235
291
  * the list above the detail below that. height is the number of lines the overview may fill: the column divider
236
- * runs the whole height, the rows and the detail header stay at the top, and the total row and the key and fact
237
- * lines sit at the bottom. The detail of a running judge is the Cross mark, centred in the pane, with the time
238
- * under it. Without a height, or when the content is taller than the height, the lines follow each other.
292
+ * runs the whole height, the rows and the detail header stay at the top, and the total row and the key
293
+ * line sits at the bottom. Under the detail header a sub tab bar picks the content: sub 'verdict' shows the
294
+ * verdict, or the Cross mark centred in the pane with the time under it while the judge runs; sub 'criterion'
295
+ * shows the description and the body of the criterion as plain text. focus 'text' marks the pane with an orange
296
+ * edge and an orange header, and scroll hides that many lines of the content. Without a height, or when the
297
+ * content is taller than the height, the lines follow each other and nothing scrolls.
239
298
  */
240
- export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, height, frame = 0, version = VERSION }) {
299
+ export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, height, frame = 0, version = VERSION, sub = 'verdict', focus = 'list', scroll = 0 }) {
241
300
  const { leftWidth, columnWidth, start } = layout(rows, width);
242
301
  const pad = leftWidth - 27;
243
302
  const cells = (score, time, tokens) => ` ${score.padStart(3)} ${time.padStart(5)} ${tokens.padStart(6)}`;
@@ -266,29 +325,19 @@ export function renderOverview({ title, target, harness, elapsed, rows, cursor,
266
325
  const row = rows[cursor];
267
326
  const level = row.state === 'done' ? `${row.result.score}/4 ${LEVELS[row.result.score]}` : row.state === 'error' ? 'error' : row.state;
268
327
  const name = `${row.rank} ${row.name.toUpperCase()}`;
269
- const detailHeader = `${BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`;
270
- const facts = [`judge ${row.rank}`, row.harness, ...(row.time === null ? [] : [clock(row.time)]), ...(row.state === 'done' || row.state === 'error' ? [`judges/${row.rank}/answer.json`] : [])];
271
- const bottom = [`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`, '', `${GREY}${facts.join(' · ')}${RESET}`];
272
- // The pane: the header, the content, then the bottom lines at the bottom. A running judge shows the mark centred.
328
+ const detailHeader = `${focus === 'text' ? `${ORANGE}${BOLD}` : BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`;
329
+ const subTab = (label, key) => `${sub === key ? BOLD : GREY}${label}${RESET}`;
330
+ const subBar = [`${subTab('Verdict', 'verdict')}${GREY} │ ${RESET}${subTab('Criterion', 'criterion')}`, `${GREY}${'─'.repeat(columnWidth)}${RESET}`];
331
+ const bottom = [`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`];
332
+ // The pane: the header, the sub tab bar and its hairline, a window of the content that the scroll moves, then
333
+ // the key line on the last row.
273
334
  const pane = (lines) => {
274
- // The pane holds the header, a blank line, the content, a blank line, then the bottom lines.
275
- const window = lines === undefined ? undefined : lines - 2 - 1 - bottom.length;
276
- let content;
277
- if (row.state === 'running' && !row.trust) {
278
- const since = row.restarted !== null ? `restarted with ${row.harness} · ` : '';
279
- const line = `${GREY}${since}${clock(row.time ?? 0)} · ${row.rank} to watch${RESET}`;
280
- const size = markSize(columnWidth, window ?? MARK_SIZES[0][0] + 2);
281
- const art = size ? renderMark({ frame, start: start + Math.floor((columnWidth - size[1]) / 2), width, size }) : [glimmer('judging', frame, start + Math.floor((columnWidth - 7) / 2), width)];
282
- const block = [...art, '', line].map((l) => centre(l, columnWidth));
283
- const above = window === undefined ? 0 : Math.max(0, Math.floor((window - block.length) / 2));
284
- content = [...Array(above).fill(''), ...block];
285
- } else {
286
- const text = row.state === 'done' ? row.result.verdict : row.state === 'error' ? row.error : row.state === 'queued' ? 'The judge has not started.' : `Press ${row.rank} and answer the trust prompt.`;
287
- content = wrap(text, columnWidth);
288
- }
289
- const top = [detailHeader, '', ...content];
290
- if (window === undefined || content.length > window) return [...top, '', ...bottom];
291
- return [...top, ...Array(window - content.length).fill(''), '', ...bottom];
335
+ const window = lines === undefined ? undefined : lines - PANE_FRAME;
336
+ const content = detailContent(row, sub, columnWidth, start, width, frame, window);
337
+ const top = [detailHeader, ...subBar];
338
+ if (window === undefined || window < 1) return [...top, ...content, ...bottom];
339
+ const from = Math.min(scroll, Math.max(0, content.length - window));
340
+ return [...top, ...content.slice(from, from + window), ...Array(Math.max(0, window - content.length)).fill(''), ...bottom];
292
341
  };
293
342
  return page({
294
343
  header: headerLine(title, target, harness, elapsed, width, version),
@@ -297,67 +346,70 @@ export function renderOverview({ title, target, harness, elapsed, rows, cursor,
297
346
  left: { rows, lines: left },
298
347
  leftBottom: [`${GREY}${'─'.repeat(leftWidth)}${RESET}`, totalLine],
299
348
  pane,
300
- edge: `${GREY}│${RESET}`,
349
+ edge: focus === 'text' ? `${ORANGE}▌${RESET}` : `${GREY}│${RESET}`,
301
350
  });
302
351
  }
303
352
 
353
+ // The overview is view 0. The other views are judges.
354
+ export const isPage = (view) => view === 0;
355
+
356
+ const HARNESSES = ['claude', 'codex'];
357
+
304
358
  /**
305
- * The lines of the Panel tab: the same header, the criteria at the left (rank and name, the cursor row orange),
306
- * and at the right the description and the body of the cursor criterion as plain text. focus 'text' marks the
307
- * pane with an orange edge and an orange header, and scroll hides that many lines of the text.
359
+ * The modal states: { kind: 'harness', rank, options, cursor } picks the harness of judge rank; { kind: 'restart',
360
+ * rank, options, cursor } asks before a restart. options are the rows, cursor the index of the orange row.
308
361
  */
309
- export function renderPanel({ title, target, harness, elapsed, rows, cursor, width, height, version = VERSION, focus = 'list', scroll = 0 }) {
310
- const { leftWidth, columnWidth } = layout(rows, width);
311
- const pad = leftWidth - 27;
312
- const left = rows.map((r, i) => (i === cursor ? `${ORANGE}▸ ${String(r.rank).padStart(2)} ${r.name.padEnd(pad)}${RESET}` : ` ${String(r.rank).padStart(2)} ${r.name.padEnd(pad)}`));
313
- const row = rows[cursor];
314
- const name = `${row.rank} ${row.name.toUpperCase()}`;
315
- const header = `${focus === 'text' ? `${ORANGE}${BOLD}` : BOLD}${name}${RESET}`;
316
- const text = criterionText(row, columnWidth);
317
- const pane = (lines) => {
318
- const window = lines === undefined ? text.length : lines - 2;
319
- const from = Math.min(scroll, Math.max(0, text.length - window));
320
- return [header, '', ...text.slice(from, from + Math.max(0, window))];
321
- };
322
- return page({
323
- header: headerLine(title, target, harness, elapsed, width, version),
324
- width,
325
- height,
326
- left: { rows, lines: left },
327
- leftBottom: [],
328
- pane,
329
- edge: focus === 'text' ? `${ORANGE}▌${RESET}` : `${GREY}│${RESET}`,
330
- });
331
- }
362
+ export const harnessModal = (rank, current) => ({ kind: 'harness', rank, options: HARNESSES, cursor: Math.max(0, HARNESSES.indexOf(current)) });
363
+ export const restartModal = (rank) => ({ kind: 'restart', rank, options: ['restart', 'cancel'], cursor: 1 });
332
364
 
333
- // The pages: the overview (view 0) and the Panel tab (view 'panel'). The other views are judges.
334
- export const PANEL = 'panel';
335
- export const isPage = (view) => view === 0 || view === PANEL;
365
+ /**
366
+ * The lines of a modal and where they go: { top, left, lines }, centred on a screen width by height. A grey
367
+ * box-drawing frame, a bold title, a grey note when there is one, then one row per option, the cursor row orange
368
+ * with ▸. current is the harness the judge uses now, marked in the harness picker.
369
+ */
370
+ export function renderModal({ modal, current, width, height }) {
371
+ const title = modal.kind === 'harness' ? `Harness for judge ${modal.rank}` : `Restart judge ${modal.rank}?`;
372
+ const note = modal.kind === 'restart' ? `with ${current} · the current answer is discarded` : null;
373
+ const rows = modal.options.map((o) => (modal.kind === 'harness' && o === current ? `${o} (current)` : o));
374
+ const inner = Math.max(title.length, note?.length ?? 0, ...rows.map((r) => r.length + 2)) + 2;
375
+ const line = (text) => `${GREY}│${RESET} ${padTo(text, inner - 2)} ${GREY}│${RESET}`;
376
+ const lines = [`${GREY}┌${'─'.repeat(inner)}┐${RESET}`, line(`${BOLD}${title}${RESET}`)];
377
+ if (note) lines.push(line(`${GREY}${note}${RESET}`));
378
+ lines.push(line(''));
379
+ rows.forEach((r, i) => lines.push(line(i === modal.cursor ? `${ORANGE}▸ ${r}${RESET}` : ` ${r}`)));
380
+ lines.push(`${GREY}└${'─'.repeat(inner)}┘${RESET}`);
381
+ return { top: Math.max(0, Math.floor((height - lines.length) / 2)), left: Math.max(0, Math.floor((width - inner - 2) / 2)), lines };
382
+ }
336
383
 
337
384
  /**
338
385
  * The three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
339
- * the elapsed time at the left and the keys of the focus at the right. The tabs are Overview, Panel, then the
340
- * name of each criterion, with a grey │ between the tabs. A running tab glimmers. The names step down to the
341
- * first word when they do not fit the width. focus is 'strip' (the active tab is bold orange, the arrows move
342
- * it), 'text' (the text pane of the Panel tab scrolls), or 'judge' (the active tab is bold ink, the keys go to
343
- * the judge). confirm replaces the progress with the quit question.
386
+ * the elapsed time at the left and the keys of the focus at the right. The tabs are Overview, then the name of
387
+ * each criterion, with a grey │ between the tabs. A running tab glimmers. The names step down to the first word
388
+ * when they do not fit the width. focus is 'strip' (the active tab is bold orange, the arrows move it), 'text'
389
+ * (the detail pane of the overview scrolls), or 'judge' (the active tab is bold ink, the keys go to the judge).
390
+ * confirm replaces the progress with the quit question; focus 'modal' shows the keys of the open modal. The keys
391
+ * of the overview drop their last words when they do not fit beside the progress.
344
392
  */
345
393
  export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', elapsed = 0, confirm = false }) {
346
394
  const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
347
- const keys = focus === 'judge' ? 'ctrl-q tabs'
348
- : focus === 'text' ? '↑ ↓ scroll · enter list · ← → tabs · q q quit'
349
- : view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit'
350
- : view === PANEL ? '↑ ↓ criterion · enter text · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
351
- const names = (level) => ['Overview', 'Panel', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
395
+ const progress = confirm ? 'judges still run. q again quits, another key stays' : `${finished} of ${rows.length} done · ${clock(elapsed)}`;
396
+ const full = focus === 'judge' ? 'ctrl-q tabs'
397
+ : focus === 'modal' ? '↑ ↓ · enter pick · esc close'
398
+ : focus === 'text' ? '↑ ↓ scroll · enter list · ← → tabs · q q quit'
399
+ : view === 0 ? '↑ ↓ criterion · tab verdict/criterion · enter scroll · r restart · h harness · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
400
+ const parts = full.split(' · ');
401
+ while (parts.length > 1 && progress.length + 2 + parts.join(' · ').length + 2 > width) parts.pop();
402
+ const keys = parts.join(' · ');
403
+ const names = (level) => ['Overview', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
352
404
  const used = (list) => 1 + list.reduce((n, t) => n + t.length, 0) + 3 * (list.length - 1);
353
405
  const list = [0, 1].map(names).find((l) => used(l) <= width) ?? names(1);
354
- const activeIndex = view === 0 ? 0 : view === PANEL ? 1 : view + 1;
406
+ const activeIndex = view;
355
407
  let line = ' ';
356
408
  let col = 1;
357
409
  list.forEach((text, i) => {
358
410
  if (i) { line += `${GREY} │ ${RESET}`; col += 3; }
359
411
  const active = i === activeIndex;
360
- const row = rows[i - 2];
412
+ const row = rows[i - 1];
361
413
  const start = col;
362
414
  col += text.length;
363
415
  if (row?.state === 'running' && !row.trust) {
@@ -366,7 +418,6 @@ export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', ela
366
418
  }
367
419
  line += `${active ? (focus === 'judge' ? BOLD : `${ORANGE}${BOLD}`) : GREY}${text}${RESET}`;
368
420
  });
369
- const progress = confirm ? 'judges still run. q again quits, another key stays' : `${finished} of ${rows.length} done · ${clock(elapsed)}`;
370
421
  // A narrow terminal drops the keys before the progress, and then the end of the progress.
371
422
  const gap = width - progress.length - keys.length - 2;
372
423
  const status = gap >= 2 ? ` ${progress}${' '.repeat(gap)}${keys}` : ` ${progress}`;
@@ -374,61 +425,79 @@ export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', ela
374
425
  }
375
426
 
376
427
  /**
377
- * The next { view, confirm, focus, cursor, scroll } after one key, plus send: true when the key goes to the judge,
378
- * quit: true, restart: N to restart judge N, or flip: N to flip the harness of judge N. view is 0 for the
379
- * overview, PANEL for the Panel tab, or N for judge N. open holds the ranks of the judges that have started,
380
- * count the number of criteria, maxScroll the lines the text pane can hide, page the lines one PgUp or PgDn
381
- * moves. With the focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the
382
- * strip. With the focus on the strip, the arrows left and right move the active tab and switch the view, 1-9
383
- * jump to a started judge, 0 to the overview, and q quits (a second q confirms while a judge runs). On the
384
- * overview and the Panel tab, which take no text, up and down move the shared cursor; on the overview r
385
- * restarts the judge of the cursor row and h flips its harness; on the Panel tab Enter moves the focus into
386
- * the text pane, where up and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus.
387
- * On a judge tab, Enter, Esc, or ctrl-q give the focus to the judge.
428
+ * The next { view, confirm, focus, cursor, sub, scroll } after one key, plus send: true when the key goes to the
429
+ * judge, quit: true, restart: N to restart judge N, or flip: N to flip the harness of judge N. view is 0 for the
430
+ * overview, or N for judge N. open holds the ranks of the judges that have started, count the number of
431
+ * criteria, maxScroll the lines the detail pane can hide, page the lines one PgUp or PgDn moves. With the
432
+ * focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
433
+ * focus on the strip, the arrows left and right move the active tab and switch the view, 1-9 jump to a started
434
+ * judge, 0 to the overview, and q quits (a second q confirms while a judge runs). On the overview, which takes
435
+ * no text, up and down move the cursor, Tab switches the sub tab of the detail pane (verdict or criterion),
436
+ * r opens the restart modal of the judge of the cursor row, h the harness picker, and Enter moves the focus into
437
+ * the pane: there up and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus. A modal
438
+ * (state.modal) takes every key until Enter or Esc. On a judge tab, Enter, Esc, or ctrl-q give the focus to the
439
+ * judge. The result carries harness: { rank, value } when the picker picked one.
388
440
  */
389
- export function press({ view, confirm, focus, cursor, scroll = 0 }, key, { open, running, count, maxScroll: limit = 0, page: step = 10 }) {
390
- const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor, scroll });
391
- if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, scroll, send: true };
441
+ export function press({ view, confirm, focus, cursor, sub = 'verdict', scroll = 0, modal = null }, key, { open, running, count, maxScroll: limit = 0, page: step = 10, harnesses = [] }) {
442
+ const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor, sub, scroll, modal: null });
443
+ if (modal) {
444
+ // The modal takes every key: up and down move its cursor, Enter picks the row, Esc closes it, the rest is ignored.
445
+ const same = { view, confirm, focus, cursor, sub, scroll, modal };
446
+ if (UP.includes(key)) return { ...same, modal: { ...modal, cursor: Math.max(0, modal.cursor - 1) } };
447
+ if (DOWN.includes(key)) return { ...same, modal: { ...modal, cursor: Math.min(modal.options.length - 1, modal.cursor + 1) } };
448
+ if (key === '\x1b') return strip(0);
449
+ if (key === '\r') {
450
+ const pick = modal.options[modal.cursor];
451
+ if (modal.kind === 'harness') return { ...strip(0), harness: { rank: modal.rank, value: pick } };
452
+ return pick === 'restart' ? { ...strip(0), restart: modal.rank } : strip(0);
453
+ }
454
+ return same;
455
+ }
456
+ if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, sub, scroll, modal: null, send: true };
392
457
  if (confirm) return { ...strip(0), quit: key === 'q' };
393
- if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor, scroll } : { ...strip(0), quit: true };
458
+ if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor, sub, scroll, modal: null } : { ...strip(0), quit: true };
394
459
  if (RIGHT.includes(key) || LEFT.includes(key)) {
395
- const cycle = [0, PANEL, ...open];
460
+ const cycle = [0, ...open];
396
461
  const dir = RIGHT.includes(key) ? 1 : -1;
397
462
  return strip(cycle[(cycle.indexOf(view) + dir + cycle.length) % cycle.length]);
398
463
  }
399
464
  if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : { ...strip(view), focus };
400
465
  if (key === '0') return strip(0);
401
- if (isPage(view)) {
402
- if (view === 0 && key === 'r') return open.includes(cursor + 1) ? { ...strip(0), restart: cursor + 1 } : strip(0);
403
- if (view === 0 && key === 'h') return { ...strip(0), flip: cursor + 1 };
466
+ if (view === 0) {
467
+ const keep = { ...strip(0), focus };
468
+ if (key === 'r') return open.includes(cursor + 1) ? { ...keep, focus: 'modal', modal: restartModal(cursor + 1) } : keep;
469
+ if (key === 'h') return { ...keep, focus: 'modal', modal: harnessModal(cursor + 1, harnesses[cursor]) };
470
+ if (key === '\t') return { ...keep, sub: sub === 'verdict' ? 'criterion' : 'verdict', scroll: 0 };
404
471
  if (focus === 'text') {
405
- const text = (s) => ({ view, confirm: false, focus: 'text', cursor, scroll: Math.max(0, Math.min(limit, s)) });
472
+ const text = (s) => ({ ...keep, scroll: Math.max(0, Math.min(limit, s)) });
406
473
  if (UP.includes(key)) return text(scroll - 1);
407
474
  if (DOWN.includes(key)) return text(scroll + 1);
408
475
  if (key === '\x1b[5~') return text(scroll - step);
409
476
  if (key === '\x1b[6~') return text(scroll + step);
410
- if (key === '\r' || key === '\x1b') return strip(view);
411
- return text(scroll);
477
+ if (key === '\r' || key === '\x1b') return strip(0);
478
+ return keep;
412
479
  }
413
- if (UP.includes(key)) return { ...strip(view), cursor: Math.max(0, cursor - 1), scroll: 0 };
414
- if (DOWN.includes(key)) return { ...strip(view), cursor: Math.min(count - 1, cursor + 1), scroll: 0 };
415
- if (view === PANEL && key === '\r') return { view, confirm: false, focus: 'text', cursor, scroll: 0 };
416
- return strip(view);
480
+ if (UP.includes(key)) return { ...strip(0), cursor: Math.max(0, cursor - 1), scroll: 0 };
481
+ if (DOWN.includes(key)) return { ...strip(0), cursor: Math.min(count - 1, cursor + 1), scroll: 0 };
482
+ if (key === '\r') return { ...strip(0), focus: 'text', scroll: 0 };
483
+ return strip(0);
417
484
  }
418
- if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor, scroll };
485
+ if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor, sub, scroll, modal: null };
419
486
  return strip(view);
420
487
  }
421
488
 
422
489
  /** Runs the judges, shows the overview, and writes the report. Resolves when the engineer quits. */
423
490
  export function runTable({ panel, runDir, harness, cwd, target, yolo = false }) {
424
491
  const out = process.stdout;
425
- const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
492
+ // One random seed per run; each criterion keeps its own art for the whole run, through restarts.
493
+ const runSeed = Math.floor(Math.random() * 2 ** 31);
494
+ const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, seed: runSeed + i + 1, ...c }));
426
495
  const judges = [];
427
496
  // The harness of each judge: the run harness, until h flips it.
428
497
  const harnesses = criteria.map(() => harness);
429
498
  let rows = [];
430
499
  let final = null;
431
- let ui = { view: 0, confirm: false, focus: 'strip', cursor: 0, scroll: 0 };
500
+ let ui = { view: 0, confirm: false, focus: 'strip', cursor: 0, sub: 'verdict', scroll: 0, modal: null };
432
501
  let stripTimer;
433
502
  let lastData = 0;
434
503
  const started = Date.now();
@@ -545,10 +614,14 @@ export function runTable({ panel, runDir, harness, cwd, target, yolo = false })
545
614
 
546
615
  let frame = 0;
547
616
  function draw() {
548
- const common = { title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, height: out.rows - STRIP_ROWS };
549
- const lines = ui.view === PANEL ? renderPanel({ ...common, focus: ui.focus === 'text' ? 'text' : 'list', scroll: ui.scroll }) : renderOverview({ ...common, frame });
617
+ const lines = renderOverview({ title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, height: out.rows - STRIP_ROWS, frame, sub: ui.sub, focus: ui.focus === 'text' ? 'text' : 'list', scroll: ui.scroll });
550
618
  while (lines.length < out.rows - STRIP_ROWS) lines.push('');
551
619
  out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
620
+ // The modal sits over the overview; the next draw without it shows the overview again.
621
+ if (ui.modal) {
622
+ const { top, left, lines: box } = renderModal({ modal: ui.modal, current: harnesses[ui.modal.rank - 1], width: out.columns, height: out.rows - STRIP_ROWS });
623
+ out.write(box.map((l, i) => `\x1b[${top + i + 1};${left + 1}H${l}`).join(''));
624
+ }
552
625
  drawStrip();
553
626
  }
554
627
 
@@ -581,9 +654,9 @@ export function runTable({ panel, runDir, harness, cwd, target, yolo = false })
581
654
  const key = data.toString();
582
655
  const from = ui.view;
583
656
  const height = out.rows - STRIP_ROWS;
584
- const limit = rows.length ? panelScrollMax({ rows, cursor: ui.cursor, width: out.columns, height }) : 0;
585
- const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running'), count: criteria.length, maxScroll: limit, page: Math.max(1, height - 7) });
586
- ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor, scroll: next.scroll };
657
+ const limit = rows.length ? overviewScrollMax({ rows, cursor: ui.cursor, sub: ui.sub, width: out.columns, height }) : 0;
658
+ const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running'), count: criteria.length, maxScroll: limit, page: Math.max(1, height - PANE_FRAME), harnesses });
659
+ ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor, sub: next.sub, scroll: next.scroll, modal: next.modal };
587
660
  if (next.send) {
588
661
  judges[from - 1].proc.write(key);
589
662
  } else if (next.quit) {
@@ -597,8 +670,8 @@ export function runTable({ panel, runDir, harness, cwd, target, yolo = false })
597
670
  } else if (next.restart) {
598
671
  restart(next.restart);
599
672
  tick();
600
- } else if (next.flip) {
601
- harnesses[next.flip - 1] = harnesses[next.flip - 1] === 'claude' ? 'codex' : 'claude';
673
+ } else if (next.harness) {
674
+ harnesses[next.harness.rank - 1] = next.harness.value;
602
675
  tick();
603
676
  } else if (!isPage(ui.view) && ui.view === from) {
604
677
  drawStrip();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
6
  "type": "module",