@xsft/panels 0.4.0 → 0.5.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 +1 -1
- package/lib/table.js +114 -64
- 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
|
-
A tab
|
|
34
|
+
A tab strip on the last three lines shows a hairline, a tab per view, and a status line with the progress, the elapsed time, and the keys. The tabs are the scores, then each judge with its number, name, and mark (score, ✗ for an error, · while queued, ! while the harness asks whether to trust the folder). The label of a running judge glimmers. 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, `0` to the scores, `enter` gives the focus to the judge, and `q` quits and asks `y` while a judge still runs. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The scores take no input, 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.
|
|
35
35
|
|
|
36
36
|
## Run folder
|
|
37
37
|
|
package/lib/table.js
CHANGED
|
@@ -17,8 +17,12 @@ const ORANGE = '\x1b[38;2;255;106;61m';
|
|
|
17
17
|
const GREY = '\x1b[90m';
|
|
18
18
|
const BOLD = '\x1b[1m';
|
|
19
19
|
const RESET = '\x1b[0m';
|
|
20
|
-
// ctrl-q
|
|
21
|
-
const
|
|
20
|
+
// ctrl-q moves the focus between a judge and the tab strip. A terminal in the kitty keyboard mode sends it as CSI 113;5u.
|
|
21
|
+
const TOGGLE = ['\x11', '\x1b[113;5u'];
|
|
22
|
+
const RIGHT = ['\x1b[C', '\x1bOC'];
|
|
23
|
+
const LEFT = ['\x1b[D', '\x1bOD'];
|
|
24
|
+
// Enter, Esc, or ctrl-q give the focus back to the judge of the active tab.
|
|
25
|
+
const BACK = ['\r', '\x1b', ...TOGGLE];
|
|
22
26
|
// Turns off each mode that the screen of a judge can turn on, so the table gets the keys and the screen as before.
|
|
23
27
|
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';
|
|
24
28
|
|
|
@@ -27,13 +31,21 @@ const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b
|
|
|
27
31
|
* trust is true while the harness of a running judge asks whether to trust the folder.
|
|
28
32
|
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
29
33
|
*/
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// The glimmer on the text of a running judge: grey text, and a bright highlight three characters wide that
|
|
35
|
+
// sweeps from left to right one character per frame and wraps.
|
|
36
|
+
const GLIMMER = { base: '\x1b[38;5;244m', mid: '\x1b[38;5;250m', high: '\x1b[38;5;255m' };
|
|
37
|
+
const GLIMMER_ORANGE = { base: ORANGE, mid: '\x1b[38;2;255;170;140m', high: '\x1b[38;5;255m' };
|
|
38
|
+
|
|
39
|
+
/** text with the glimmer at frame. colours is { base, mid, high }; prefix (such as bold) goes before each piece. */
|
|
40
|
+
export function glimmer(text, frame, colours = GLIMMER, prefix = '') {
|
|
41
|
+
const centre = frame % text.length;
|
|
42
|
+
return [...text].map((c, i) => {
|
|
43
|
+
const d = Math.min(Math.abs(i - centre), text.length - Math.abs(i - centre));
|
|
44
|
+
return `${prefix}${d === 0 ? colours.high : d === 1 ? colours.mid : colours.base}${c}`;
|
|
45
|
+
}).join('') + RESET;
|
|
46
|
+
}
|
|
47
|
+
// The tab strip takes the last three lines of the terminal: a hairline, the tabs, then the status and the keys.
|
|
48
|
+
const STRIP_ROWS = 3;
|
|
37
49
|
|
|
38
50
|
export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
39
51
|
const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
|
|
@@ -42,7 +54,7 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
|
42
54
|
for (const r of rows) {
|
|
43
55
|
const head = `${String(r.rank).padStart(3)} ${r.name.padEnd(pad)}`;
|
|
44
56
|
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(`${
|
|
57
|
+
else if (r.state === 'running') lines.push(`${String(r.rank).padStart(3)} ${glimmer(r.name, frame)}`);
|
|
46
58
|
else if (r.state === 'queued') lines.push(`${head}${GREY}queued${RESET}`);
|
|
47
59
|
else if (r.state === 'error') lines.push(`${head}error`);
|
|
48
60
|
else lines.push(`${head}${r.result.score} ${GREY}b${r.result.band}${RESET}`);
|
|
@@ -57,44 +69,74 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
|
-
* The three lines of the tab
|
|
61
|
-
*
|
|
62
|
-
*
|
|
72
|
+
* The three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
|
|
73
|
+
* the elapsed time at the left and the keys of the focus at the right. The first tab is the scores; each judge
|
|
74
|
+
* tab reads "N name mark". A running judge has no mark and its label glimmers. The mark is the score (bold at
|
|
75
|
+
* 4), ✗ for an error, · while queued, or ! while the harness asks whether to trust the folder. The names step
|
|
76
|
+
* down to fit the width: the whole name, the first word, or no name. focus is 'strip' (the active tab is bold
|
|
77
|
+
* orange, the arrows move it) or 'judge' (the active tab is bold ink, the keys go to the judge).
|
|
63
78
|
*/
|
|
64
|
-
export function
|
|
79
|
+
export function renderStrip({ rows, view, final, frame = 0, width, focus = 'strip', elapsed = 0 }) {
|
|
65
80
|
const mark = (r) => {
|
|
66
|
-
if (r.state === 'running') return r.trust ? '!' :
|
|
81
|
+
if (r.state === 'running') return r.trust ? '!' : '';
|
|
67
82
|
if (r.state === 'queued') return '·';
|
|
68
83
|
if (r.state === 'error') return '✗';
|
|
69
84
|
return String(r.result.score);
|
|
70
85
|
};
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
|
|
87
|
+
const clock = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
|
|
88
|
+
const keys = focus === 'strip' ? '← → tabs · enter back · q quit' : 'ctrl-q tabs';
|
|
89
|
+
const tabs = (level) => [
|
|
90
|
+
{ rank: '', name: final ? `scores ${final.status.toUpperCase()}` : 'scores', mark: '' },
|
|
91
|
+
...rows.map((r) => ({ rank: String(r.rank), name: level === 0 ? r.name : level === 1 ? r.name.split(' ')[0] : '', mark: mark(r) })),
|
|
92
|
+
];
|
|
93
|
+
const plain = (t) => [t.rank, t.name, t.mark].filter(Boolean).join(' ');
|
|
94
|
+
const used = (list) => 1 + list.reduce((n, t) => n + plain(t).length, 0) + 2 * (list.length - 1);
|
|
95
|
+
const list = [0, 1, 2].map(tabs).find((l) => used(l) <= width) ?? tabs(2);
|
|
96
|
+
let line = ' ';
|
|
97
|
+
list.forEach((t, i) => {
|
|
98
|
+
if (i) line += ' ';
|
|
99
|
+
const active = i === view;
|
|
100
|
+
const base = active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY;
|
|
101
|
+
const text = plain(t);
|
|
102
|
+
const row = rows[i - 1];
|
|
103
|
+
if (row?.state === 'running' && !row.trust) {
|
|
104
|
+
line += glimmer(text, frame, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const pieces = [];
|
|
108
|
+
if (t.rank) pieces.push(`${base}${t.rank}${RESET}`);
|
|
109
|
+
if (t.name) pieces.push(`${base}${t.name}${RESET}`);
|
|
110
|
+
if (t.mark) pieces.push(`${row.state === 'done' && row.result.score === 4 ? `${base}${BOLD}` : base}${t.mark}${RESET}`);
|
|
111
|
+
line += pieces.join(' ');
|
|
112
|
+
});
|
|
113
|
+
const progress = `${finished} of ${rows.length} done · ${clock}`;
|
|
114
|
+
const status = ` ${progress}${' '.repeat(Math.max(2, width - progress.length - keys.length - 2))}${keys}`;
|
|
115
|
+
return [`${GREY}${'─'.repeat(width)}${RESET}`, line, `${GREY}${status}${RESET}`];
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
/**
|
|
89
|
-
* The next { view, confirm } after one key, plus send: true when the key goes to the judge, or quit: true.
|
|
90
|
-
* view is 0 for the
|
|
119
|
+
* The next { view, confirm, focus } after one key, plus send: true when the key goes to the judge, or quit: true.
|
|
120
|
+
* view is 0 for the scores, or N for judge N. open holds the ranks of the judges that have started. With the
|
|
121
|
+
* focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
|
|
122
|
+
* focus on the strip, the arrows move the active tab and switch the view, 1-9 jump to a started judge, 0 to the
|
|
123
|
+
* scores, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (y confirms while a judge runs).
|
|
124
|
+
* The scores take no input, so the strip keeps the focus there.
|
|
91
125
|
*/
|
|
92
|
-
export function press({ view, confirm }, key, { open, running }) {
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
if (
|
|
96
|
-
if (
|
|
97
|
-
|
|
126
|
+
export function press({ view, confirm, focus }, key, { open, running }) {
|
|
127
|
+
const strip = (v) => ({ view: v, confirm: false, focus: 'strip' });
|
|
128
|
+
if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, send: true };
|
|
129
|
+
if (confirm) return { ...strip(0), quit: key === 'y' };
|
|
130
|
+
if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip' } : { ...strip(0), quit: true };
|
|
131
|
+
if (RIGHT.includes(key) || LEFT.includes(key)) {
|
|
132
|
+
const cycle = [0, ...open];
|
|
133
|
+
const step = RIGHT.includes(key) ? 1 : -1;
|
|
134
|
+
return strip(cycle[(cycle.indexOf(view) + step + cycle.length) % cycle.length]);
|
|
135
|
+
}
|
|
136
|
+
if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : strip(view);
|
|
137
|
+
if (key === '0') return strip(0);
|
|
138
|
+
if (BACK.includes(key) && view) return { view, confirm: false, focus: 'judge' };
|
|
139
|
+
return strip(view);
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
/** Runs the judges, shows the table, and writes the report. Resolves when the engineer quits. */
|
|
@@ -104,13 +146,14 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
104
146
|
const judges = [];
|
|
105
147
|
let rows = [];
|
|
106
148
|
let final = null;
|
|
107
|
-
let ui = { view: 0, confirm: false };
|
|
108
|
-
let
|
|
149
|
+
let ui = { view: 0, confirm: false, focus: 'strip' };
|
|
150
|
+
let stripTimer;
|
|
109
151
|
let lastData = 0;
|
|
152
|
+
const started = Date.now();
|
|
110
153
|
|
|
111
154
|
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
112
|
-
// A judge gets the lines above the tab
|
|
113
|
-
const size = () => ({ cols: out.columns, rows: out.rows -
|
|
155
|
+
// A judge gets the lines above the tab strip.
|
|
156
|
+
const size = () => ({ cols: out.columns, rows: out.rows - STRIP_ROWS });
|
|
114
157
|
|
|
115
158
|
function state(c, i) {
|
|
116
159
|
const file = join(judgeDir(c.rank), 'answer.json');
|
|
@@ -144,7 +187,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
144
187
|
if (ui.view !== rank) return;
|
|
145
188
|
out.write(data);
|
|
146
189
|
lastData = Date.now();
|
|
147
|
-
|
|
190
|
+
stripSoon();
|
|
148
191
|
}));
|
|
149
192
|
proc.onExit(() => { j.exited = true; });
|
|
150
193
|
// The headless terminal answers the queries of the harness, such as the cursor position.
|
|
@@ -159,18 +202,23 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
159
202
|
judges[rank - 1] = j;
|
|
160
203
|
}
|
|
161
204
|
|
|
162
|
-
// In a judge, the cursor
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
205
|
+
// In a judge, the strip hides the cursor while it draws and then puts it back where the judge had it. A cursor
|
|
206
|
+
// past the last column waits for the next character to wrap; a move there would lose that, so the strip waits.
|
|
207
|
+
function drawStrip() {
|
|
208
|
+
const lines = renderStrip({ rows, view: ui.view, final, frame, width: out.columns, focus: ui.focus, elapsed: Math.floor((Date.now() - started) / 1000) });
|
|
209
|
+
const top = out.rows - STRIP_ROWS + 1;
|
|
210
|
+
const strip = lines.map((l, i) => `\x1b[${top + i};1H${l}\x1b[K`).join('');
|
|
211
|
+
if (!ui.view) return void out.write(`${RESET}${strip}`);
|
|
212
|
+
const j = judges[ui.view - 1];
|
|
213
|
+
const { cursorX, cursorY } = j.term.buffer.active;
|
|
214
|
+
if (cursorX >= out.columns) return;
|
|
215
|
+
out.write(`\x1b[?25l${RESET}${strip}\x1b[${cursorY + 1};${cursorX + 1}H\x1b[?25${j.modes[25]}`);
|
|
168
216
|
}
|
|
169
217
|
|
|
170
|
-
// Waits for a pause in the output, so the
|
|
171
|
-
function
|
|
172
|
-
clearTimeout(
|
|
173
|
-
|
|
218
|
+
// Waits for a pause in the output, so the strip does not land inside an escape sequence that a chunk split.
|
|
219
|
+
function stripSoon() {
|
|
220
|
+
clearTimeout(stripTimer);
|
|
221
|
+
stripTimer = setTimeout(drawStrip, 50);
|
|
174
222
|
}
|
|
175
223
|
|
|
176
224
|
function showJudge(rank) {
|
|
@@ -178,10 +226,10 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
178
226
|
j.term.write('', () => {
|
|
179
227
|
if (ui.view !== rank) return;
|
|
180
228
|
// The table already holds the alternate screen, so a judge on its alternate screen gets a clear after
|
|
181
|
-
// the normal screen, which the serializer writes first. The scroll region keeps the judge
|
|
229
|
+
// the normal screen, which the serializer writes first. The scroll region keeps the judge above the strip.
|
|
182
230
|
const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
|
|
183
|
-
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows -
|
|
184
|
-
|
|
231
|
+
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows - STRIP_ROWS}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
|
|
232
|
+
drawStrip();
|
|
185
233
|
});
|
|
186
234
|
}
|
|
187
235
|
|
|
@@ -197,9 +245,9 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
197
245
|
let frame = 0;
|
|
198
246
|
function draw() {
|
|
199
247
|
const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm: ui.confirm, width: Math.min(out.columns, 100), frame });
|
|
200
|
-
while (lines.length < out.rows -
|
|
201
|
-
out.write(lines.slice(0, out.rows -
|
|
202
|
-
|
|
248
|
+
while (lines.length < out.rows - STRIP_ROWS) lines.push('');
|
|
249
|
+
out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
|
|
250
|
+
drawStrip();
|
|
203
251
|
}
|
|
204
252
|
|
|
205
253
|
function tick() {
|
|
@@ -224,7 +272,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
224
272
|
frame += 1;
|
|
225
273
|
if (final) return;
|
|
226
274
|
if (!ui.view) draw();
|
|
227
|
-
else if (Date.now() - lastData > 60)
|
|
275
|
+
else if (Date.now() - lastData > 60) drawStrip();
|
|
228
276
|
}, 100);
|
|
229
277
|
out.write('\x1b[?1049h\x1b[?25l');
|
|
230
278
|
process.stdin.setRawMode(true);
|
|
@@ -232,7 +280,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
232
280
|
const key = data.toString();
|
|
233
281
|
const from = ui.view;
|
|
234
282
|
const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running') });
|
|
235
|
-
ui = { view: next.view, confirm: next.confirm };
|
|
283
|
+
ui = { view: next.view, confirm: next.confirm, focus: next.focus };
|
|
236
284
|
if (next.send) {
|
|
237
285
|
judges[from - 1].proc.write(key);
|
|
238
286
|
} else if (next.quit) {
|
|
@@ -245,11 +293,13 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
245
293
|
process.stdin.setRawMode(false);
|
|
246
294
|
process.stdin.pause();
|
|
247
295
|
done();
|
|
248
|
-
} else if (ui.view) {
|
|
249
|
-
|
|
296
|
+
} else if (ui.view && ui.view === from) {
|
|
297
|
+
drawStrip();
|
|
250
298
|
} else {
|
|
299
|
+
// A judge can leave its modes on (mouse, bracketed paste, the kitty keyboard). The next view starts clean.
|
|
251
300
|
if (from) out.write(TABLE_MODES);
|
|
252
|
-
|
|
301
|
+
if (ui.view) showJudge(ui.view);
|
|
302
|
+
else draw();
|
|
253
303
|
}
|
|
254
304
|
});
|
|
255
305
|
out.on('resize', () => {
|