@xsft/panels 0.5.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 +35 -29
  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 strip on the last two lines shows a tab 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). 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.
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
@@ -31,9 +31,21 @@ const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b
31
31
  * trust is true while the harness of a running judge asks whether to trust the folder.
32
32
  * final is null until the report exists, then { status, path }. confirm is true while q waits for y.
33
33
  */
34
- const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
35
- // The tab strip takes the last two lines of the terminal: a hairline that opens above the active tab, then the tabs.
36
- const STRIP_ROWS = 2;
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,16 +69,16 @@ export function render({ title, rows, final, confirm, width, frame = 0 }) {
57
69
  }
58
70
 
59
71
  /**
60
- * The two lines of the tab strip: a grey hairline that is open above the active tab, so the active tab joins
61
- * the content above it, then the tabs. The first tab is the scores; each judge tab reads "N name mark".
62
- * The mark is the spinner while the judge runs, the score (bold at 4), ✗ for an error, · while queued, or !
63
- * while the harness asks whether to trust the folder. The names step down to fit the width: the whole name,
64
- * the first word, or no name. The right end shows the progress, the elapsed time, and the keys of the focus.
65
- * focus is 'strip' (the active tab is orange, the arrows move it) or 'judge' (the keys go to the judge).
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).
66
78
  */
67
79
  export function renderStrip({ rows, view, final, frame = 0, width, focus = 'strip', elapsed = 0 }) {
68
80
  const mark = (r) => {
69
- if (r.state === 'running') return r.trust ? '!' : SPIN[frame % SPIN.length];
81
+ if (r.state === 'running') return r.trust ? '!' : '';
70
82
  if (r.state === 'queued') return '·';
71
83
  if (r.state === 'error') return '✗';
72
84
  return String(r.result.score);
@@ -79,34 +91,28 @@ export function renderStrip({ rows, view, final, frame = 0, width, focus = 'stri
79
91
  ...rows.map((r) => ({ rank: String(r.rank), name: level === 0 ? r.name : level === 1 ? r.name.split(' ')[0] : '', mark: mark(r) })),
80
92
  ];
81
93
  const plain = (t) => [t.rank, t.name, t.mark].filter(Boolean).join(' ');
82
- const used = (list, right) => 1 + list.reduce((n, t) => n + plain(t).length, 0) + 2 * (list.length - 1) + 2 + right.length + 1;
83
- // The names come first: a narrow terminal drops the progress before it shortens the names.
84
- const choices = [0, 1, 2].flatMap((level) => [`${finished} of ${rows.length} done · ${clock} · ${keys}`, keys].map((right) => ({ list: tabs(level), right })));
85
- const { list, right } = choices.find((c) => used(c.list, c.right) <= width) ?? choices.at(-1);
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);
86
96
  let line = ' ';
87
- let col = 1;
88
- let open = [1, 1];
89
97
  list.forEach((t, i) => {
90
- if (i) { line += ' '; col += 2; }
98
+ if (i) line += ' ';
91
99
  const active = i === view;
92
100
  const base = active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY;
93
101
  const text = plain(t);
94
- if (active) open = [col, col + text.length];
95
- col += text.length;
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
+ }
96
107
  const pieces = [];
97
108
  if (t.rank) pieces.push(`${base}${t.rank}${RESET}`);
98
109
  if (t.name) pieces.push(`${base}${t.name}${RESET}`);
99
- if (t.mark) {
100
- const row = rows[i - 1];
101
- const style = row.state === 'running' && !row.trust ? ORANGE : row.state === 'done' && row.result.score === 4 ? `${base}${BOLD}` : base;
102
- pieces.push(`${style}${t.mark}${RESET}`);
103
- }
110
+ if (t.mark) pieces.push(`${row.state === 'done' && row.result.score === 4 ? `${base}${BOLD}` : base}${t.mark}${RESET}`);
104
111
  line += pieces.join(' ');
105
112
  });
106
- const gap = Math.max(2, width - col - right.length - 1);
107
- line += `${' '.repeat(gap)}${GREY}${right}${RESET}`;
108
- const rule = [...'─'.repeat(width)].map((c, x) => (x >= open[0] && x < open[1] ? ' ' : c)).join('');
109
- return [`${GREY}${rule}${RESET}`, line];
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}`];
110
116
  }
111
117
 
112
118
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.5.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",