@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/lib/table.js +84 -26
  3. 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-]` returns to the table.
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
- // More parallel sessions on one account get 429 answers from the model api.
12
- const LIVE_JUDGES = 4;
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-] returns from a judge to the table. A terminal in the kitty keyboard mode sends it as CSI 93;5u.
18
- const BACK = ['\x1d', '\x1b[93;5u'];
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}${SPIN[frame % SPIN.length]}${RESET}`);
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(confirm ? ' judges still run. y quits, another key stays' : `${GREY} 1-${Math.min(rows.length, 9)} open judge q quit${RESET}`);
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 footerTimer;
108
+ let tabsTimer;
109
+ let lastData = 0;
69
110
 
70
111
  const judgeDir = (rank) => join(runDir, 'judges', String(rank));
71
- // A judge gets one line less than the terminal. The last line holds the footer.
72
- const size = () => ({ cols: out.columns, rows: out.rows - 1 });
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
- footer();
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
- function drawFooter() {
114
- if (!ui.view) return;
115
- const { cursorX, cursorY } = judges[ui.view - 1].term.buffer.active;
116
- out.write(`\x1b[${out.rows};1H${RESET}${GREY} judge ${ui.view} ctrl-] scores${RESET}\x1b[K\x1b[${cursorY + 1};${cursorX + 1}H`);
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 footer does not land inside an escape sequence that a chunk split.
120
- function footer() {
121
- clearTimeout(footerTimer);
122
- footerTimer = setTimeout(drawFooter, 50);
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 footer line.
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 - 1}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
133
- drawFooter();
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
- out.write(`\x1b[H${lines.map((l) => `${l}\x1b[K`).join('\n')}\x1b[J`);
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
- for (const r of rows.filter((x) => x.state === 'queued').slice(0, LIVE_JUDGES - live)) {
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 (!ui.view && !final) draw();
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
6
  "type": "module",