@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/lib/table.js +114 -64
  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
- 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.
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 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'];
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
- 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';
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(`${ORANGE}${head}${SPIN[frame % SPIN.length]}${RESET}`);
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 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.
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 renderTabs({ rows, view, final, frame = 0, width }) {
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 ? '!' : SPIN[frame % SPIN.length];
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 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
+ 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 table, or N for judge N. open holds the ranks of the judges that have started.
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
- if (view) return BACK.includes(key) ? { view: 0, confirm: false } : { view, confirm, send: true };
94
- if (confirm) return { view: 0, confirm: false, quit: key === 'y' };
95
- if (key === 'q') return running ? { view: 0, confirm: true } : { view: 0, confirm: false, quit: true };
96
- if (/^[1-9]$/.test(key) && open.includes(Number(key))) return { view: Number(key), confirm: false };
97
- return { view: 0, confirm: false };
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 tabsTimer;
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 bar.
113
- const size = () => ({ cols: out.columns, rows: out.rows - TAB_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
- tabsSoon();
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 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}`);
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 tab bar does not land inside an escape sequence that a chunk split.
171
- function tabsSoon() {
172
- clearTimeout(tabsTimer);
173
- tabsTimer = setTimeout(drawTabs, 50);
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 off the tab bar.
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 - TAB_ROWS}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
184
- drawTabs();
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 - 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();
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) drawTabs();
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
- showJudge(ui.view);
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
- draw();
301
+ if (ui.view) showJudge(ui.view);
302
+ else draw();
253
303
  }
254
304
  });
255
305
  out.on('resize', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
6
  "type": "module",