@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/lib/table.js +61 -18
  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-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(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');
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 footerTimer;
108
+ let tabsTimer;
109
+ let lastData = 0;
74
110
 
75
111
  const judgeDir = (rank) => join(runDir, 'judges', String(rank));
76
- // A judge gets one line less than the terminal. The last line holds the footer.
77
- 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 });
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
- footer();
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
- function drawFooter() {
126
- if (!ui.view) return;
127
- const { cursorX, cursorY } = judges[ui.view - 1].term.buffer.active;
128
- out.write(`\x1b[${out.rows};1H${RESET}${GREY} judge ${ui.view} ctrl-q 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}`);
129
168
  }
130
169
 
131
- // Waits for a pause in the output, so the footer does not land inside an escape sequence that a chunk split.
132
- function footer() {
133
- clearTimeout(footerTimer);
134
- 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);
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 footer line.
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 - 1}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
145
- 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();
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
- 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();
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 (!ui.view && !final) draw();
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.3.3",
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",