@xsft/panels 0.3.3 → 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 +61 -18
- 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-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.
|
|
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
|
@@ -28,6 +28,12 @@ const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b
|
|
|
28
28
|
* final is null until the report exists, then { status, path }. confirm is true while q waits for y.
|
|
29
29
|
*/
|
|
30
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';
|
|
31
37
|
|
|
32
38
|
export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
33
39
|
const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
|
|
@@ -46,10 +52,39 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
|
|
|
46
52
|
const at4 = rows.filter((r) => r.state === 'done' && r.result.score === 4).length;
|
|
47
53
|
lines.push(` ${BOLD}${final.status.toUpperCase()}${RESET} ${at4} of ${rows.length} at 4`, ` report ${final.path}`);
|
|
48
54
|
}
|
|
49
|
-
lines.push(
|
|
55
|
+
if (confirm) lines.push(' judges still run. y quits, another key stays');
|
|
50
56
|
return lines;
|
|
51
57
|
}
|
|
52
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
|
+
|
|
53
88
|
/**
|
|
54
89
|
* The next { view, confirm } after one key, plus send: true when the key goes to the judge, or quit: true.
|
|
55
90
|
* view is 0 for the table, or N for judge N. open holds the ranks of the judges that have started.
|
|
@@ -70,11 +105,12 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
70
105
|
let rows = [];
|
|
71
106
|
let final = null;
|
|
72
107
|
let ui = { view: 0, confirm: false };
|
|
73
|
-
let
|
|
108
|
+
let tabsTimer;
|
|
109
|
+
let lastData = 0;
|
|
74
110
|
|
|
75
111
|
const judgeDir = (rank) => join(runDir, 'judges', String(rank));
|
|
76
|
-
// A judge gets
|
|
77
|
-
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 });
|
|
78
114
|
|
|
79
115
|
function state(c, i) {
|
|
80
116
|
const file = join(judgeDir(c.rank), 'answer.json');
|
|
@@ -107,7 +143,8 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
107
143
|
proc.onData((data) => term.write(data, () => {
|
|
108
144
|
if (ui.view !== rank) return;
|
|
109
145
|
out.write(data);
|
|
110
|
-
|
|
146
|
+
lastData = Date.now();
|
|
147
|
+
tabsSoon();
|
|
111
148
|
}));
|
|
112
149
|
proc.onExit(() => { j.exited = true; });
|
|
113
150
|
// The headless terminal answers the queries of the harness, such as the cursor position.
|
|
@@ -122,16 +159,18 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
122
159
|
judges[rank - 1] = j;
|
|
123
160
|
}
|
|
124
161
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const {
|
|
128
|
-
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}`);
|
|
129
168
|
}
|
|
130
169
|
|
|
131
|
-
// Waits for a pause in the output, so the
|
|
132
|
-
function
|
|
133
|
-
clearTimeout(
|
|
134
|
-
|
|
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);
|
|
135
174
|
}
|
|
136
175
|
|
|
137
176
|
function showJudge(rank) {
|
|
@@ -139,10 +178,10 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
139
178
|
j.term.write('', () => {
|
|
140
179
|
if (ui.view !== rank) return;
|
|
141
180
|
// The table already holds the alternate screen, so a judge on its alternate screen gets a clear after
|
|
142
|
-
// 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.
|
|
143
182
|
const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
|
|
144
|
-
out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows -
|
|
145
|
-
|
|
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();
|
|
146
185
|
});
|
|
147
186
|
}
|
|
148
187
|
|
|
@@ -158,7 +197,9 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
158
197
|
let frame = 0;
|
|
159
198
|
function draw() {
|
|
160
199
|
const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm: ui.confirm, width: Math.min(out.columns, 100), frame });
|
|
161
|
-
|
|
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();
|
|
162
203
|
}
|
|
163
204
|
|
|
164
205
|
function tick() {
|
|
@@ -181,7 +222,9 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }
|
|
|
181
222
|
const timer = setInterval(tick, 1000);
|
|
182
223
|
const spinner = setInterval(() => {
|
|
183
224
|
frame += 1;
|
|
184
|
-
if (
|
|
225
|
+
if (final) return;
|
|
226
|
+
if (!ui.view) draw();
|
|
227
|
+
else if (Date.now() - lastData > 60) drawTabs();
|
|
185
228
|
}, 100);
|
|
186
229
|
out.write('\x1b[?1049h\x1b[?25l');
|
|
187
230
|
process.stdin.setRawMode(true);
|