@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.
- package/README.md +3 -3
- package/lib/table.js +198 -125
- 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,
|
|
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
|
-
|
|
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`,
|
|
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
|
|
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
|
|
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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* the screen
|
|
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
|
|
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
|
|
125
|
-
const
|
|
126
|
-
const
|
|
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 =
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
271
|
-
const
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
306
|
-
*
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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,
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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 -
|
|
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
|
|
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,
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* the
|
|
387
|
-
* On a judge tab, Enter, Esc, or ctrl-q give the focus to the
|
|
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 (
|
|
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,
|
|
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 (
|
|
402
|
-
|
|
403
|
-
if (
|
|
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) => ({
|
|
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(
|
|
411
|
-
return
|
|
477
|
+
if (key === '\r' || key === '\x1b') return strip(0);
|
|
478
|
+
return keep;
|
|
412
479
|
}
|
|
413
|
-
if (UP.includes(key)) return { ...strip(
|
|
414
|
-
if (DOWN.includes(key)) return { ...strip(
|
|
415
|
-
if (
|
|
416
|
-
return strip(
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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 -
|
|
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.
|
|
601
|
-
harnesses[next.
|
|
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();
|