@xsft/panels 0.3.2 → 0.4.0
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 +1 -1
- package/lib/table.js +84 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ 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 score table, 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 table 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`).
|
|
33
33
|
|
|
34
|
-
Keys in the table: `1` to `9` open the screen of that judge, and `q` quits and asks `y` while a judge still runs. In a judge, each key goes to the judge, and `ctrl-
|
|
34
|
+
A tab bar on the last three lines shows a pill per view: the scores, then each judge with its number, name, and mark (spinner, score, ✗ for an error, · while queued, ! while the harness asks whether to trust the folder). Keys in the table: `1` to `9` open the screen of that judge, and `q` quits and asks `y` while a judge still runs. In a judge, each key goes to the judge, and `ctrl-q` returns to the table. 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.
|
|
35
35
|
|
|
36
36
|
## Run folder
|
|
37
37
|
|
package/lib/table.js
CHANGED
|
@@ -8,22 +8,32 @@ import xterm from '@xterm/headless';
|
|
|
8
8
|
import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
|
|
9
9
|
import { buildReport, renderMarkdown } from './report.js';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const LIVE_JUDGES = 10;
|
|
12
|
+
// A harness asks this in a folder it has not seen. Only the first judge starts until its screen no longer asks,
|
|
13
|
+
// so the engineer answers once and the harness saves the answer for the other judges.
|
|
14
|
+
const TRUST_PROMPT = /trust this folder/i;
|
|
15
|
+
const FIRST_JUDGE_HEAD_START = 3000;
|
|
13
16
|
const ORANGE = '\x1b[38;2;255;106;61m';
|
|
14
17
|
const GREY = '\x1b[90m';
|
|
15
18
|
const BOLD = '\x1b[1m';
|
|
16
19
|
const RESET = '\x1b[0m';
|
|
17
|
-
// ctrl-
|
|
18
|
-
const BACK = ['\
|
|
20
|
+
// ctrl-q returns from a judge to the table. A terminal in the kitty keyboard mode sends it as CSI 113;5u.
|
|
21
|
+
const BACK = ['\x11', '\x1b[113;5u'];
|
|
19
22
|
// Turns off each mode that the screen of a judge can turn on, so the table gets the keys and the screen as before.
|
|
20
23
|
const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b>\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[<u\x1b[?25l\x1b[2J';
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
* The lines of the table. rows hold { rank, name, state, result }, and state is queued, running, done, or error.
|
|
26
|
+
* The lines of the table. rows hold { rank, name, state, result, trust }, and state is queued, running, done, or error.
|
|
27
|
+
* trust is true while the harness of a running judge asks whether to trust the folder.
|
|
24
28
|
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
25
29
|
*/
|
|
26
30
|
const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
31
|
+
// The tab bar takes the last three lines of the terminal, like a tmux status bar with three-line pills.
|
|
32
|
+
const TAB_ROWS = 3;
|
|
33
|
+
const BAR = '\x1b[48;5;236m';
|
|
34
|
+
const PILL = '\x1b[48;5;239m\x1b[38;5;253m';
|
|
35
|
+
const ACTIVE = '\x1b[48;2;255;106;61m\x1b[38;5;235m\x1b[1m';
|
|
36
|
+
const DIM = '\x1b[38;5;245m';
|
|
27
37
|
|
|
28
38
|
export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
29
39
|
const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
|
|
@@ -31,7 +41,8 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
|
31
41
|
const lines = [` ${BOLD}panels run${RESET} ${title}`, rule];
|
|
32
42
|
for (const r of rows) {
|
|
33
43
|
const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
|
|
34
|
-
if (r.state === 'running') lines.push(`${ORANGE}${head}${
|
|
44
|
+
if (r.state === 'running' && r.trust) lines.push(`${ORANGE}${head}press ${r.rank} and answer the trust prompt${RESET}`);
|
|
45
|
+
else if (r.state === 'running') lines.push(`${ORANGE}${head}${SPIN[frame % SPIN.length]}${RESET}`);
|
|
35
46
|
else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
|
|
36
47
|
else if (r.state === 'error') lines.push(`${head}error`);
|
|
37
48
|
else lines.push(`${head}${r.result.score} ${GREY}b${r.result.band}${RESET}`);
|
|
@@ -41,10 +52,39 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
|
41
52
|
const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
|
|
42
53
|
lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
|
|
43
54
|
}
|
|
44
|
-
lines.push(
|
|
55
|
+
if (confirm) lines.push(' judges still run. y quits, another key stays');
|
|
45
56
|
return lines;
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The three lines of the tab bar: a pill for the scores tab and one per judge, with a padding line above and
|
|
61
|
+
* below. A judge pill reads "N name mark", where the mark is the spinner, the score, ✗ for an error, · while
|
|
62
|
+
* queued, or ! while the harness asks whether to trust the folder. The names shrink to fit the width.
|
|
63
|
+
*/
|
|
64
|
+
export function renderTabs({ rows, view, final, frame = 0, width }) {
|
|
65
|
+
const mark = (r) => {
|
|
66
|
+
if (r.state === 'running') return r.trust ? '!' : SPIN[frame % SPIN.length];
|
|
67
|
+
if (r.state === 'queued') return '·';
|
|
68
|
+
if (r.state === 'error') return '✗';
|
|
69
|
+
return String(r.result.score);
|
|
70
|
+
};
|
|
71
|
+
const scores = final ? `scores ${final.status.toUpperCase()}` : 'scores';
|
|
72
|
+
// Each pill is its label plus two spaces each side and a one-space gap. A judge label is "N name m", 9 columns
|
|
73
|
+
// plus the name. The key hint sits at the right of the bottom line. A narrow terminal drops the hint, so the
|
|
74
|
+
// names keep at least four letters.
|
|
75
|
+
const fixed = 1 + scores.length + 5 + rows.length * 9;
|
|
76
|
+
const fit = (hint) => Math.max(0, Math.floor((width - fixed - hint.length) / rows.length));
|
|
77
|
+
const hint = fit('ctrl-q scores q quit ') >= 4 ? 'ctrl-q scores q quit ' : '';
|
|
78
|
+
const nameWidth = fit(hint);
|
|
79
|
+
const labels = [scores, ...rows.map((r) => `${r.rank}${nameWidth ? ` ${r.name.slice(0, nameWidth)}` : ''} ${mark(r)}`)];
|
|
80
|
+
const style = (i) => (i === view ? ACTIVE : rows[i - 1]?.state === 'queued' ? `${PILL}${DIM}` : PILL);
|
|
81
|
+
const pills = (text) => labels.map((l, i) => `${style(i)} ${text(l)} ${RESET}${BAR}`).join(' ');
|
|
82
|
+
const used = labels.reduce((n, l) => n + l.length + 5, 0);
|
|
83
|
+
const pad = `${BAR} ${pills((l) => ' '.repeat(l.length))}\x1b[K${RESET}`;
|
|
84
|
+
const bottom = `${BAR} ${pills((l) => ' '.repeat(l.length))}${' '.repeat(Math.max(0, width - used - hint.length))}${GREY}${hint}\x1b[K${RESET}`;
|
|
85
|
+
return [pad, `${BAR} ${pills((l) => l)}\x1b[K${RESET}`, bottom];
|
|
86
|
+
}
|
|
87
|
+
|
|
48
88
|
/**
|
|
49
89
|
* The next { view, confirm } after one key, plus send: true when the key goes to the judge, or quit: true.
|
|
50
90
|
* view is 0 for the table, or N for judge N. open holds the ranks of the judges that have started.
|
|
@@ -65,11 +105,12 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
65
105
|
let rows = [];
|
|
66
106
|
let final = null;
|
|
67
107
|
let ui = { view: 0, confirm: false };
|
|
68
|
-
let
|
|
108
|
+
let tabsTimer;
|
|
109
|
+
let lastData = 0;
|
|
69
110
|
|
|
70
111
|
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
71
|
-
// A judge gets
|
|
72
|
-
const size = () => ({ cols: out.columns, rows: out.rows -
|
|
112
|
+
// A judge gets the lines above the tab bar.
|
|
113
|
+
const size = () => ({ cols: out.columns, rows: out.rows - TAB_ROWS });
|
|
73
114
|
|
|
74
115
|
function state(c, i) {
|
|
75
116
|
const file = join(judgeDir(c.rank), 'answer.json');
|
|
@@ -80,7 +121,14 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
80
121
|
}
|
|
81
122
|
if (judges[i] === undefined) return { state: 'queued' };
|
|
82
123
|
if (Date.now() - judges[i].started > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
|
|
83
|
-
return { state: 'running' };
|
|
124
|
+
return { state: 'running', trust: asksTrust(judges[i]) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function asksTrust(j) {
|
|
128
|
+
const buffer = j.term.buffer.active;
|
|
129
|
+
const lines = [];
|
|
130
|
+
for (let y = 0; y < buffer.length; y++) lines.push(buffer.getLine(y).translateToString(true));
|
|
131
|
+
return TRUST_PROMPT.test(lines.join(' '));
|
|
84
132
|
}
|
|
85
133
|
|
|
86
134
|
function start(rank) {
|
|
@@ -95,7 +143,8 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
95
143
|
proc.onData((data) => term.write(data, () => {
|
|
96
144
|
if (ui.view !== rank) return;
|
|
97
145
|
out.write(data);
|
|
98
|
-
|
|
146
|
+
lastData = Date.now();
|
|
147
|
+
tabsSoon();
|
|
99
148
|
}));
|
|
100
149
|
proc.onExit(() => { j.exited = true; });
|
|
101
150
|
// The headless terminal answers the queries of the harness, such as the cursor position.
|
|
@@ -110,16 +159,18 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
110
159
|
judges[rank - 1] = j;
|
|
111
160
|
}
|
|
112
161
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const {
|
|
116
|
-
out.
|
|
162
|
+
// In a judge, the cursor goes back to where the judge left it. The table hides the cursor.
|
|
163
|
+
function drawTabs() {
|
|
164
|
+
const lines = renderTabs({ rows, view: ui.view, final, frame, width: out.columns });
|
|
165
|
+
const top = out.rows - TAB_ROWS + 1;
|
|
166
|
+
const back = ui.view ? (({ cursorX, cursorY }) => `\x1b[${cursorY + 1};${cursorX + 1}H`)(judges[ui.view - 1].term.buffer.active) : '';
|
|
167
|
+
out.write(`${RESET}${lines.map((l, i) => `\x1b[${top + i};1H${l}`).join('')}${back}`);
|
|
117
168
|
}
|
|
118
169
|
|
|
119
|
-
// Waits for a pause in the output, so the
|
|
120
|
-
function
|
|
121
|
-
clearTimeout(
|
|
122
|
-
|
|
170
|
+
// Waits for a pause in the output, so the tab bar does not land inside an escape sequence that a chunk split.
|
|
171
|
+
function tabsSoon() {
|
|
172
|
+
clearTimeout(tabsTimer);
|
|
173
|
+
tabsTimer = setTimeout(drawTabs, 50);
|
|
123
174
|
}
|
|
124
175
|
|
|
125
176
|
function showJudge(rank) {
|
|
@@ -127,10 +178,10 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
127
178
|
j.term.write('', () => {
|
|
128
179
|
if (ui.view !== rank) return;
|
|
129
180
|
// The table already holds the alternate screen, so a judge on its alternate screen gets a clear after
|
|
130
|
-
// the normal screen, which the serializer writes first. The scroll region keeps the judge off the
|
|
181
|
+
// the normal screen, which the serializer writes first. The scroll region keeps the judge off the tab bar.
|
|
131
182
|
const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
|
|
132
|
-
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows -
|
|
133
|
-
|
|
183
|
+
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows - TAB_ROWS}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
|
|
184
|
+
drawTabs();
|
|
134
185
|
});
|
|
135
186
|
}
|
|
136
187
|
|
|
@@ -146,14 +197,19 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
146
197
|
let frame = 0;
|
|
147
198
|
function draw() {
|
|
148
199
|
const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm: ui.confirm, width: Math.min(out.columns, 100), frame });
|
|
149
|
-
|
|
200
|
+
while (lines.length < out.rows - TAB_ROWS) lines.push('');
|
|
201
|
+
out.write(lines.slice(0, out.rows - TAB_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
|
|
202
|
+
drawTabs();
|
|
150
203
|
}
|
|
151
204
|
|
|
152
205
|
function tick() {
|
|
153
206
|
if (!final) {
|
|
154
207
|
rows = criteria.map((c, i) => ({ ...c, ...state(c, i) }));
|
|
155
208
|
const live = rows.filter((r) => r.state === 'running').length;
|
|
156
|
-
|
|
209
|
+
const first = judges[0];
|
|
210
|
+
const hold = first && !first.exited && (Date.now() - first.started < FIRST_JUDGE_HEAD_START || rows[0].trust);
|
|
211
|
+
for (const r of rows.filter((x) => x.state === 'queued').slice(0, first ? LIVE_JUDGES - live : 1)) {
|
|
212
|
+
if (hold) break;
|
|
157
213
|
start(r.rank);
|
|
158
214
|
r.state = 'running';
|
|
159
215
|
}
|
|
@@ -166,7 +222,9 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
166
222
|
const timer = setInterval(tick, 1000);
|
|
167
223
|
const spinner = setInterval(() => {
|
|
168
224
|
frame += 1;
|
|
169
|
-
if (
|
|
225
|
+
if (final) return;
|
|
226
|
+
if (!ui.view) draw();
|
|
227
|
+
else if (Date.now() - lastData > 60) drawTabs();
|
|
170
228
|
}, 100);
|
|
171
229
|
out.write('\x1b[?1049h\x1b[?25l');
|
|
172
230
|
process.stdin.setRawMode(true);
|