@xsft/panels 0.7.1 → 0.8.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 CHANGED
@@ -5,12 +5,10 @@
5
5
  ```text
6
6
  panels run <panel> [target...] [flags]
7
7
 
8
- <panel> owner/name, or owner/name/vN to pin version N
8
+ <panel> owner/name for the latest version, or owner/name@N to pin version N
9
9
  [target...] free words, joined with single spaces into one target
10
10
 
11
11
  --harness <name> claude | codex default: claude if installed, else codex
12
- --brief <file> the brief default: none
13
- --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
14
12
  --yolo the judges skip every permission question (claude --dangerously-skip-permissions, codex --yolo).
15
13
  Without it, a judge asks its own questions in its tab. Without a terminal, the flag is required.
16
14
  -- end of the flags; each later word joins the target
@@ -20,22 +18,24 @@ exit 1 fail: every judge answered, and one or more criteria scored below 4
20
18
  exit 2 error: bad flags, no target, panel not found, harness missing, or a judge failed
21
19
  ```
22
20
 
23
- CAUTION: A judge gets no tool block and no sandbox from the CLI. Without `--yolo`, the harness asks its own permission questions in the judge tab. With `--yolo`, nothing asks: the judge can read, write, run commands, and reach the network as you, and the system prompt (do not write or change files, no sub-agents, no forms, no posts) is the only guard. Do not run a panel on a repository that you do not trust.
21
+ CAUTION: A judge gets no tool block and no sandbox from the CLI. Without `--yolo`, the harness asks its own permission questions in the judge tab. With `--yolo`, nothing asks: the judge can read, write, run commands, and reach the network as you, and the system prompt (do not write or change files of the target, no forms, no posts) is the only guard. Do not run a panel on a repository that you do not trust.
24
22
 
25
23
  ```sh
26
- npx @xsft/panels run xsft/marketing-site https://example.com --brief docs/brief.md --harness claude
27
- npx @xsft/panels run xsft/marketing-site/v6 . at http://localhost:4321 --sources docs --harness codex
24
+ npx @xsft/panels run xsft/marketing-site https://example.com --harness claude --yolo
25
+ npx @xsft/panels run xsft/marketing-site@8 . at http://localhost:4321 --harness codex
28
26
  ```
29
27
 
30
- Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes.
28
+ Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes. The criteria of a panel say what to assess; the system prompt of the CLI (`prompts/judge-system-prompt.txt`) carries how to judge and the four levels: 4 Met, 3 Almost met, 2 Falls short, 1 Fails. Only 4 passes.
31
29
 
32
30
  ## Terminal mode
33
31
 
34
32
  On a terminal, `panels run` shows the overview, 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 overview 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`), and stdout prints `N/4 name` and the verdict of each criterion, then the report path. Plain mode needs `--yolo`, because a background judge cannot answer a question; without it the command exits 2.
35
33
 
36
- The overview fills the terminal: a header line with the run (panel and target at the left, the version, the harness, and the elapsed time at the right edge) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red, a grey `·/4` until the score arrives), the time the judge has run, and the tokens it used, with a total row at the bottom under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, the verdict paragraph, the one-line description and the body of the criterion as plain text, and at the bottom the key line and a grey fact line with the judge number, the harness, the time, and the answer file. `enter` moves the focus into that pane (an orange bar marks it): `↑` and `↓` scroll one line, `PgUp` and `PgDn` a page, `esc` or `enter` return to the list. While a judge runs, its detail shows the word judging in big letters (figlet, ANSI Shadow) with the wave over it and the time under it. One soft wave sweeps across the running rows. On the overview, `↑` and `↓` move the cursor, `r` restarts the judge of the cursor row with its harness (its answer is dropped and the report is written again when it finishes), and `h` flips that harness between `claude` and `codex`. The page never says pass or fail; the total is the result.
34
+ The overview fills the terminal: a header line with the run (panel and target at the left, the version, the harness, and the elapsed time at the right edge) and two columns from 100 columns up (stacked below that). The left column lists the criteria: rank, name, score (4 green, 3 ink, 2 yellow, 1 red, a grey `·/4` until the score arrives), the time the judge has run, and the tokens it used, with a total row at the bottom under a hairline: the scores added as quarters over the number of criteria, the longest judge time, and the tokens added up. The right column shows the criterion under the cursor: its name, score, and level word, the verdict paragraph, and at the bottom the key line and a grey fact line with the judge number, the harness, the time, and the answer file. While a judge runs, its detail shows the Cross mark, a half disc in a square frame drawn in a halftone screen that breathes, centred in the pane, with the time under it. One soft wave sweeps across the running rows and the mark. On the overview, `↑` and `↓` move the cursor, `r` restarts the judge of the cursor row with its harness (its answer is dropped and the report is written again when it finishes), and `h` flips that harness between `claude` and `codex`. The page never says pass or fail; the total is the result.
37
35
 
38
- A tab strip on the last three lines shows a hairline, the tabs (`Overview`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. 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 by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, 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.
36
+ The second tab, `Panel`, holds the text of the panel: the criteria at the left with the same cursor as the overview, and at the right the description and the body of the cursor criterion as plain text. `enter` moves the focus into that pane (an orange bar marks it): `↑` and `↓` scroll one line, `PgUp` and `PgDn` a page, `esc` or `enter` return to the list.
37
+
38
+ A tab strip on the last three lines shows a hairline, the tabs (`Overview`, `Panel`, then the name of each criterion, split by a grey `│`, cut to the first word when they do not fit), and a status line with the progress, the elapsed time, and the keys. 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 by list order, `0` to the overview, `enter` gives the focus to the judge, and `q` quits; while a judge still runs, the first `q` asks and a second `q` quits. With the focus on a judge, each key goes to the judge, and `ctrl-q` gives the focus back to the strip. The overview takes no text, 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.
39
39
 
40
40
  ## Run folder
41
41
 
@@ -50,7 +50,7 @@ The last line on stdout is the path of `report.json`.
50
50
  ## Skipped
51
51
 
52
52
  - Run upload to cross.software, and `--no-upload`.
53
- - `--version`: `owner/name/vN` pins a version.
53
+ - `--version`: `owner/name@N` pins a version. The CLI fetches the panel from the api on every run and never caches it.
54
54
  - `--out`: the run folder is fixed.
55
55
  - `--json`: stdout ends with the path of `report.json`.
56
56
  - `--concurrency`: fixed at 4.
package/bin/panels.js CHANGED
@@ -27,12 +27,11 @@ async function pool(items, size, fn) {
27
27
 
28
28
  const EXIT = { pass: 0, fail: 1, error: 2 };
29
29
 
30
- /** Runs the judges in the score table, and exits from report.json when the engineer quits. */
30
+ /** Runs the judges in the overview, and exits from report.json when the engineer quits. Prints nothing. */
31
31
  async function runOnTerminal({ panel, runDir, harness, cwd, args }) {
32
- await runTable({ panel, runDir, harness, cwd, target: args.target, brief: args.brief, sources: args.sources, yolo: args.yolo });
32
+ await runTable({ panel, runDir, harness, cwd, target: args.target, yolo: args.yolo });
33
33
  const reportPath = join(runDir, 'report.json');
34
34
  if (!existsSync(reportPath)) return 2;
35
- console.log(reportPath);
36
35
  return EXIT[JSON.parse(readFileSync(reportPath, 'utf8')).status];
37
36
  }
38
37
 
@@ -78,8 +77,6 @@ async function main() {
78
77
  harness,
79
78
  cwd,
80
79
  target: args.target,
81
- brief: args.brief,
82
- sources: args.sources,
83
80
  yolo: true,
84
81
  }));
85
82
  const report = buildReport({ panel, target: args.target, outcomes });
package/lib/args.js CHANGED
@@ -2,12 +2,10 @@ import { parseArgs } from 'node:util';
2
2
 
3
3
  export const SYNOPSIS = `panels run <panel> [target...] [flags]
4
4
 
5
- <panel> owner/name, or owner/name/vN to pin version N
5
+ <panel> owner/name for the latest version, or owner/name@N to pin version N
6
6
  [target...] free words, joined with single spaces into one target
7
7
 
8
8
  --harness <name> claude | codex default: claude if installed, else codex
9
- --brief <file> the brief default: none
10
- --sources <path> a doc folder, a repository, or an app URL; repeat the flag for each
11
9
  --yolo the judges skip every permission question (claude --dangerously-skip-permissions, codex --yolo).
12
10
  Without it, a judge asks its own questions in its tab. Without a terminal, the flag is required.
13
11
  -- end of the flags; each later word joins the target
@@ -18,13 +16,11 @@ exit 2 error: bad flags, no target, panel not found, harness missing, or a judg
18
16
 
19
17
  const options = {
20
18
  harness: { type: 'string' },
21
- brief: { type: 'string' },
22
- sources: { type: 'string', multiple: true },
23
19
  yolo: { type: 'boolean' },
24
20
  help: { type: 'boolean' },
25
21
  };
26
22
 
27
- const PANEL = /^([a-z0-9-]+)\/([a-z0-9-]+)(?:\/v([1-9]\d*))?$/;
23
+ const PANEL = /^([a-z0-9-]+)\/([a-z0-9-]+)(?:@([1-9]\d*))?$/;
28
24
 
29
25
  /** Returns the run options, { help: true }, { usage: true }, or { error } with a one-line message. */
30
26
  export function parse(argv) {
@@ -38,15 +34,13 @@ export function parse(argv) {
38
34
  const [command, panel, ...words] = positionals;
39
35
  if (command !== 'run' || panel === undefined) return { usage: true };
40
36
  const match = PANEL.exec(panel);
41
- if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name/vN` };
37
+ if (!match) return { error: `bad panel ${panel}: use owner/name or owner/name@N` };
42
38
  if (words.length === 0) return { error: 'no target: type what the panel reviews after the panel name, such as a URL, a path, or a branch' };
43
39
  if (values.harness !== undefined && !['claude', 'codex'].includes(values.harness)) return { error: `--harness needs claude or codex, not ${values.harness}` };
44
40
  return {
45
41
  panel: { owner: match[1], name: match[2], version: match[3] && Number(match[3]) },
46
42
  target: words.join(' '),
47
43
  harness: values.harness,
48
- brief: values.brief,
49
- sources: values.sources ?? [],
50
44
  yolo: Boolean(values.yolo),
51
45
  };
52
46
  }
package/lib/judge.js CHANGED
@@ -1,26 +1,24 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { join } from 'node:path';
4
4
 
5
5
  const { criterion: CRITERION } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
6
6
  const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
7
7
  export const MINUTES_PER_CRITERION = 20;
8
8
  // A judge gets no tool list, no permission mode, and no sandbox flag: the harness defaults, and its own questions
9
9
  // in its tab. With --yolo, the judge skips every question: no sandbox, nothing is denied, nothing asks. The system
10
- // prompt holds the rules on writing files, sub-agents, forms, and messages. exec resume takes no sandbox flag,
10
+ // prompt holds the rules on writing files, forms, and messages. exec resume takes no sandbox flag,
11
11
  // so it gets the same setting as config values.
12
12
  // codex --yolo is the alias of --dangerously-bypass-approvals-and-sandbox.
13
13
  const yoloFlags = (yolo, harness) => (!yolo ? [] : harness === 'claude' ? ['--dangerously-skip-permissions'] : ['--yolo']);
14
14
  const CODEX_RESUME_YOLO = ['-c', 'sandbox_mode="danger-full-access"', '-c', 'approval_policy="never"'];
15
15
 
16
16
  /**
17
- * The user message of a judge. criterion is { rank, name, description, body }. Each part sits in a tag,
18
- * because a body is markdown with headings of its own.
17
+ * The user message of a judge: the criterion and the target. criterion is { rank, name, description, body }.
18
+ * Each part sits in a tag, because a body is markdown with headings of its own.
19
19
  */
20
- export function userMessage(criterion, { target, brief, sources }) {
21
- const parts = [`<criterion rank="${criterion.rank}" name="${criterion.name}">\nDescription: ${criterion.description}\n\n${criterion.body}\n</criterion>`];
22
- parts.push(`<target>\n${target}\n</target>`, `<brief>\n${brief ?? 'none supplied'}\n</brief>`, `<sources>\n${sources.length ? sources.join('\n') : 'none'}\n</sources>`);
23
- return `${parts.join('\n\n')}\n`;
20
+ export function userMessage(criterion, { target }) {
21
+ return `<criterion rank="${criterion.rank}" name="${criterion.name}">\nDescription: ${criterion.description}\n\n${criterion.body}\n</criterion>\n\n<target>\n${target}\n</target>\n`;
24
22
  }
25
23
 
26
24
  /** The output schema of a judge: one criterion object { name, score, verdict }, with the name fixed. */
@@ -41,12 +39,6 @@ export function check(answer, criterion) {
41
39
  return errors;
42
40
  }
43
41
 
44
- /** The folders of the brief and the path sources that are outside cwd. A Claude judge cannot read them without --add-dir. */
45
- export function addDirs(cwd, brief, sources) {
46
- const paths = [brief && dirname(resolve(cwd, brief)), ...sources.filter((s) => !/^https?:\/\//.test(s)).map((s) => resolve(cwd, s))];
47
- return [...new Set(paths.filter(Boolean))].filter((p) => relative(cwd, p).split(sep)[0] === '..');
48
- }
49
-
50
42
  function exec(command, args, { cwd, stdin, timeout }) {
51
43
  return new Promise((done) => {
52
44
  const child = spawn(command, args, { cwd, timeout, stdio: [stdin, 'pipe', 'pipe'] });
@@ -65,7 +57,6 @@ function exec(command, args, { cwd, stdin, timeout }) {
65
57
  async function askClaude(j, prompt, session) {
66
58
  const args = [
67
59
  '-p', ...yoloFlags(j.yolo, 'claude'),
68
- ...j.addDirs.flatMap((d) => ['--add-dir', d]),
69
60
  '--system-prompt', SYSTEM_PROMPT,
70
61
  '--json-schema', JSON.stringify(j.schema),
71
62
  '--output-format', 'json',
@@ -109,13 +100,13 @@ export function parseAnswer(text) {
109
100
  * Runs one judge to the end: the first answer, and one re-ask when check() rejects it.
110
101
  * Returns { answer } or { error }. command is the harness binary; the tests pass a stub.
111
102
  */
112
- export async function runJudge({ dir, criterion, harness, cwd, target, brief, sources, yolo = false }, command = harness) {
103
+ export async function runJudge({ dir, criterion, harness, cwd, target, yolo = false }, command = harness) {
113
104
  mkdirSync(dir, { recursive: true });
114
- const prompt = userMessage(criterion, { target, brief, sources });
105
+ const prompt = userMessage(criterion, { target });
115
106
  const schema = judgeSchema(criterion);
116
107
  writeFileSync(join(dir, 'prompt.md'), prompt);
117
108
  writeFileSync(join(dir, 'schema.json'), JSON.stringify(schema, null, 2));
118
- const j = { dir, cwd, schema, command, yolo, addDirs: addDirs(cwd, brief, sources), timeout: MINUTES_PER_CRITERION * 60_000 };
109
+ const j = { dir, cwd, schema, command, yolo, timeout: MINUTES_PER_CRITERION * 60_000 };
119
110
  const ask = harness === 'claude' ? askClaude : askCodex;
120
111
  let reply = await ask(j, prompt);
121
112
  if (reply.error) return reply;
@@ -137,17 +128,19 @@ export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOM
137
128
  * Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
138
129
  * arguments that start the judge in its interactive harness.
139
130
  */
140
- export function writeJudge({ dir, criterion, harness, cwd, target, brief, sources, yolo = false }) {
131
+ export function writeJudge({ dir, criterion, harness, cwd, target, yolo = false }) {
141
132
  mkdirSync(dir, { recursive: true });
142
- const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer, one JSON object { name, score, verdict } that matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Write that file yourself with a tool, and do not start sub-agents: the JSON in your reply does not count. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
143
- const prompt = `${userMessage(criterion, { target, brief, sources })}\n${closing}`;
133
+ // The answer file rule sits at the end of the user prompt and in the system prompt, so a judge that hands the
134
+ // work to a sub-agent still leaves the file at the exact path.
135
+ const closing = `The engineer ran the command in ${cwd}. Your answer counts only as the file ${join(dir, 'answer.json')}: write it with that exact absolute path, as one JSON object { name, score, verdict } that matches ${join(dir, 'schema.json')}. JSON in your reply does not count. Whoever does the work, that file must exist at that path when you stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
136
+ const prompt = `${userMessage(criterion, { target })}\n${closing}`;
137
+ const system = `${SYSTEM_PROMPT}\n${closing}`;
144
138
  writeFileSync(join(dir, 'prompt.md'), prompt);
145
139
  writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criterion), null, 2));
146
140
  if (harness === 'codex') {
147
141
  // Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
148
142
  // write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
149
- return ['codex', '-C', dir, ...yoloFlags(yolo, 'codex'), '-c', `developer_instructions=${tomlString(`${SYSTEM_PROMPT}\n${closing}`)}`, prompt];
143
+ return ['codex', '-C', dir, ...yoloFlags(yolo, 'codex'), '-c', `developer_instructions=${tomlString(system)}`, prompt];
150
144
  }
151
- const add = addDirs(cwd, brief, sources).flatMap((d) => ['--add-dir', d]);
152
- return ['claude', ...yoloFlags(yolo, 'claude'), '--system-prompt', SYSTEM_PROMPT, ...add, prompt];
145
+ return ['claude', ...yoloFlags(yolo, 'claude'), '--system-prompt', system, prompt];
153
146
  }
package/lib/panel.js CHANGED
@@ -1,8 +1,12 @@
1
1
  const API = 'https://cross.software/panels/api/panels';
2
2
 
3
- /** Reads a panel from the public api. An unpublished panel also answers 404. */
3
+ /**
4
+ * Reads a panel from the public api on every run, the latest version unless one is pinned, and never from a
5
+ * cache: the api answers no-store, and the fetch asks for no store too, so a proxy cannot keep a copy.
6
+ * An unpublished panel also answers 404.
7
+ */
4
8
  export async function fetchPanel({ owner, name, version }) {
5
- const res = await fetch(`${API}/${owner}/${name}${version ? `/v/${version}` : ''}`);
9
+ const res = await fetch(`${API}/${owner}/${name}${version ? `/v/${version}` : ''}`, { cache: 'no-store' });
6
10
  if (res.status === 404) return { error: 'panel not found' };
7
11
  if (!res.ok) return { error: `panel fetch failed: ${res.status}` };
8
12
  return { panel: await res.json() };
package/lib/report.js CHANGED
@@ -18,7 +18,11 @@ export function buildReport({ panel, target, outcomes }) {
18
18
  return { panel: `${panel.owner}/${panel.name}@${panel.version}`, target, status, criteria };
19
19
  }
20
20
 
21
- /** The lines that the plain mode prints: the run and the version, then "N/4 name" and the verdict under it, for each criterion. */
21
+ /**
22
+ * The lines that the plain mode prints: the run (panel and its version, the target, the CLI version), then
23
+ * "N/4 name" and the verdict under it, for each criterion.
24
+ */
22
25
  export function renderPlain(report) {
23
- return [`panels ${VERSION} ${report.panel} ${report.target}`, '', ...report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, ''])];
26
+ const [name, version] = report.panel.split('@');
27
+ return [`panels run ${name} v${version} ${report.target} ${VERSION}`, '', ...report.criteria.flatMap((c) => [`${c.score === null ? 'err' : `${c.score}/4`} ${c.name}`, ` ${c.verdict}`, ''])];
24
28
  }
package/lib/table.js CHANGED
@@ -5,7 +5,6 @@ import { join } from 'node:path';
5
5
  import pty from '@homebridge/node-pty-prebuilt-multiarch';
6
6
  import { SerializeAddon } from '@xterm/addon-serialize';
7
7
  import xterm from '@xterm/headless';
8
- import figlet from 'figlet';
9
8
  import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
10
9
  import { buildReport, VERSION } from './report.js';
11
10
 
@@ -76,18 +75,9 @@ export function tokensOnScreen(text) {
76
75
  return best;
77
76
  }
78
77
 
79
- let artLines;
80
- /** The word "judging" as big letters, in the ANSI Shadow font (Standard when that font is missing). Rendered once. */
81
- export function art() {
82
- if (!artLines) {
83
- const font = figlet.fontsSync().includes('ANSI Shadow') ? 'ANSI Shadow' : 'Standard';
84
- artLines = figlet.textSync('judging', { font }).split('\n').filter((l) => l.trim());
85
- }
86
- return artLines;
87
- }
88
-
89
78
  const plainLength = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
90
79
  const padTo = (s, width) => s + ' '.repeat(Math.max(0, width - plainLength(s)));
80
+ const centre = (s, width) => ' '.repeat(Math.max(0, Math.floor((width - plainLength(s)) / 2))) + s;
91
81
 
92
82
  /** The first width visible characters of a styled line, with every escape sequence kept, so the line never wraps. */
93
83
  export function clip(line, width) {
@@ -117,8 +107,41 @@ export function wrap(text, width) {
117
107
  /** The scores added as quarters: 1/4 + 1/4 + 2/4 is "1". No trailing zeros. */
118
108
  const quarters = (sum) => String(Math.round((sum / 4) * 100) / 100);
119
109
 
110
+ // The Cross mark: a half disc inside a square frame, drawn in a halftone screen. Two sizes, rows by columns.
111
+ const MARK_SIZES = [[11, 22], [7, 14]];
112
+ const HALFTONE = [' ', '░', '▒', '▓'];
113
+ const BREATH = 20;
114
+
115
+ /**
116
+ * The lines of the Cross mark at frame: a square frame of box-drawing lines, grey, and inside it a half disc on
117
+ * the left, its flat side on the frame, made of halftone cells two columns per row. The screen breathes: its
118
+ * density pulses over BREATH frames, and the glimmer wave runs across the cells by screen column, where start is
119
+ * the screen column of the frame. size is [rows, columns].
120
+ */
121
+ export function renderMark({ frame = 0, start = 0, width = 80, size = MARK_SIZES[0] }) {
122
+ const [rows, cols] = size;
123
+ const inner = { rows: rows - 2, cols: cols - 2 };
124
+ const cy = (inner.rows - 1) / 2;
125
+ const radius = inner.rows / 2;
126
+ const pulse = Math.sin((2 * Math.PI * frame) / BREATH);
127
+ const lines = [`${GREY}┌${'─'.repeat(inner.cols)}┐${RESET}`];
128
+ for (let y = 0; y < inner.rows; y++) {
129
+ let cells = '';
130
+ for (let x = 0; x < inner.cols; x++) {
131
+ const d = Math.hypot((x + 0.5) / 2, y - cy) / radius;
132
+ cells += d > 1 ? ' ' : HALFTONE[Math.max(1, Math.min(3, Math.round(1.5 + (1 - d) * 1.5 + pulse)))];
133
+ }
134
+ lines.push(`${GREY}│${RESET}${glimmer(cells, frame, start + 1, width)}${GREY}│${RESET}`);
135
+ }
136
+ lines.push(`${GREY}└${'─'.repeat(inner.cols)}┘${RESET}`);
137
+ return lines;
138
+ }
139
+
140
+ /** The size of the mark that fits a pane of columnWidth by lines, or null when only the one-line form fits. */
141
+ export const markSize = (columnWidth, lines) => MARK_SIZES.find(([r, c]) => r + 2 <= lines && c <= columnWidth) ?? null;
142
+
120
143
  /**
121
- * The body of a criterion as plain lines for the detail pane: a heading becomes a grey upper-case label, a list
144
+ * The body of a criterion as plain lines for the text pane: a heading becomes a grey upper-case label, a list
122
145
  * item keeps its dash, a fenced block is indented four spaces as it is, a table line stays as it is, and the
123
146
  * other text is wrapped to width with the emphasis, code, and link marks removed.
124
147
  */
@@ -149,23 +172,10 @@ export function plainMarkdown(markdown, width) {
149
172
  return lines;
150
173
  }
151
174
 
152
- /** The lines of the detail pane under its header: the verdict or the judging art, then the criterion text. */
153
- function detailContent(row, columnWidth, start, width, frame) {
154
- const lines = [''];
155
- if (row.state === 'running' && !row.trust) {
156
- // The judge works: the word judging in big letters with the wave over it, or in one line in a narrow pane.
157
- const letters = art();
158
- lines.push(...(letters[0].length <= columnWidth ? letters : ['judging']).map((l) => glimmer(l, frame, start, width)));
159
- const since = row.restarted !== null ? `restarted with ${row.harness} · ` : '';
160
- lines.push(`${GREY}${since}${clock(row.time ?? 0)} · ${row.rank} to watch${RESET}`);
161
- } else {
162
- const text = row.state === 'done' ? row.result.verdict
163
- : row.state === 'error' ? row.error
164
- : row.state === 'queued' ? 'The judge has not started.'
165
- : `Press ${row.rank} and answer the trust prompt.`;
166
- lines.push(...wrap(text, columnWidth));
167
- }
168
- if (row.description) lines.push('', ...wrap(row.description, columnWidth));
175
+ /** The lines of the body of a criterion in the Panel tab. */
176
+ function criterionText(row, columnWidth) {
177
+ const lines = [];
178
+ if (row.description) lines.push(...wrap(row.description, columnWidth));
169
179
  if (row.body) lines.push('', ...plainMarkdown(row.body, columnWidth));
170
180
  return lines;
171
181
  }
@@ -176,35 +186,58 @@ const layout = (rows, width) => {
176
186
  return width >= TWO_COLUMNS ? { leftWidth, columnWidth: width - leftWidth - 3, start: leftWidth + 3 } : { leftWidth, columnWidth: width - 2, start: 2 };
177
187
  };
178
188
 
179
- /** The number of lines of the detail pane that the scroll can hide: the content past the window of the pane. */
180
- export function maxScroll({ rows, cursor, width, height }) {
181
- const { columnWidth, start } = layout(rows, width);
182
- const content = detailContent(rows[cursor], columnWidth, start, width, 0);
183
- const body = height - 2;
184
- const paneHeight = width >= TWO_COLUMNS ? body : body - Math.floor(body / 2) - 1;
185
- // The pane holds the header, the content window, a blank line, and the three bottom lines.
186
- const window = paneHeight - 5;
187
- return window < 1 ? 0 : Math.max(0, content.length - window);
189
+ const headerLine = (title, target, harness, elapsed, width, version) => {
190
+ const run = ` panels run ${title} ${target}`;
191
+ const meta = `${version} · ${harness} · ${clock(elapsed)} `;
192
+ return ` ${BOLD}panels run${RESET} ${title}${GREY} ${target}${' '.repeat(Math.max(2, width - run.length - meta.length))}${meta}${RESET}`;
193
+ };
194
+
195
+ const paneHeight = (width, body) => (width >= TWO_COLUMNS ? body : body - Math.floor(body / 2) - 1);
196
+
197
+ /** The number of lines of the text pane of the Panel tab that the scroll can hide. */
198
+ export function panelScrollMax({ rows, cursor, width, height }) {
199
+ const { columnWidth } = layout(rows, width);
200
+ const window = paneHeight(width, height - 2) - 2;
201
+ return window < 1 ? 0 : Math.max(0, criterionText(rows[cursor], columnWidth).length - window);
188
202
  }
189
203
 
190
204
  /**
191
- * The lines of the overview. rows hold { rank, name, description, body, state, result, error, trust, time, tokens,
192
- * harness, restarted }, and state is queued, running, done, or error. time is the seconds the judge has run,
193
- * tokens the count it used (null for none), harness the one its next restart uses, restarted the seconds since
194
- * a restart (null for none). cursor is the index of the row whose detail shows at the right; focus is 'list'
195
- * or 'detail' (the detail pane shows an orange bar and its header turns orange); scroll hides that many lines
196
- * of the detail content under its header. The header line holds the run: panel and target at the left, the
197
- * version, the harness, and the elapsed time at the right edge. Two columns from TWO_COLUMNS wide, the list
198
- * above the detail below that. height is the number of lines the overview may fill: the column divider runs
199
- * the whole height, the rows and the detail header stay at the top, and the total row and the key and fact
200
- * lines sit at the bottom. Without a height, or when the content is taller than the height, the lines follow
201
- * each other and nothing scrolls.
205
+ * Lays a page out: the header and the hairline, then the list at the left and a pane at the right from
206
+ * TWO_COLUMNS wide, or the list above the pane below that. pane(lines) gives the lines of the pane for a height,
207
+ * or for undefined when there is no height. edge is the divider character between the columns.
202
208
  */
203
- export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, height, frame = 0, version = VERSION, focus = 'list', scroll = 0 }) {
204
- const run = ` panels run ${title} ${target}`;
205
- const meta = `${version} · ${harness} · ${clock(elapsed)} `;
206
- const header = ` ${BOLD}panels run${RESET} ${title}${GREY} ${target}${' '.repeat(Math.max(2, width - run.length - meta.length))}${meta}${RESET}`;
207
- const rule = `${GREY}${'─'.repeat(width)}${RESET}`;
209
+ function page({ header, width, height, left, leftBottom, pane, edge }) {
210
+ const { leftWidth } = layout(left.rows, width);
211
+ const fill = (top, bottom, lines) => {
212
+ const n = lines === undefined || lines < top.length + bottom.length ? 0 : lines - top.length - bottom.length;
213
+ return [...top, ...Array(n).fill(''), ...bottom];
214
+ };
215
+ const lines = [header, `${GREY}${'─'.repeat(width)}${RESET}`];
216
+ const body = height === undefined ? undefined : height - 2;
217
+ if (width >= TWO_COLUMNS) {
218
+ const leftLines = fill(left.lines, leftBottom, body);
219
+ const rightLines = pane(body);
220
+ for (let i = 0; i < Math.max(leftLines.length, rightLines.length); i++) lines.push(`${padTo(leftLines[i] ?? '', leftWidth)} ${edge} ${rightLines[i] ?? ''}`);
221
+ } else {
222
+ const half = body === undefined ? undefined : Math.floor(body / 2);
223
+ const list = fill(left.lines, leftBottom, half);
224
+ lines.push(...list, '', ...pane(body === undefined ? undefined : body - list.length - 1).map((l) => `${edge === `${GREY}│${RESET}` ? ' ' : edge} ${l}`));
225
+ }
226
+ return lines.map((l) => clip(l, width));
227
+ }
228
+
229
+ /**
230
+ * The lines of the overview. rows hold { rank, name, state, result, error, trust, time, tokens, harness, restarted },
231
+ * and state is queued, running, done, or error. time is the seconds the judge has run, tokens the count it used
232
+ * (null for none), harness the one its next restart uses, restarted the seconds since a restart (null for none).
233
+ * cursor is the index of the row whose detail shows at the right. The header line holds the run: panel and target
234
+ * at the left, the version, the harness, and the elapsed time at the right edge. Two columns from TWO_COLUMNS wide,
235
+ * the list above the detail below that. height is the number of lines the overview may fill: the column divider
236
+ * runs the whole height, the rows and the detail header stay at the top, and the total row and the key and fact
237
+ * lines sit at the bottom. The detail of a running judge is the Cross mark, centred in the pane, with the time
238
+ * under it. Without a height, or when the content is taller than the height, the lines follow each other.
239
+ */
240
+ export function renderOverview({ title, target, harness, elapsed, rows, cursor, width, height, frame = 0, version = VERSION }) {
208
241
  const { leftWidth, columnWidth, start } = layout(rows, width);
209
242
  const pad = leftWidth - 27;
210
243
  const cells = (score, time, tokens) => ` ${score.padStart(3)} ${time.padStart(5)} ${tokens.padStart(6)}`;
@@ -229,73 +262,109 @@ export function renderOverview({ title, target, harness, elapsed, rows, cursor,
229
262
  const sum = done.length ? `${quarters(done.reduce((n, r) => n + r.result.score, 0))}/${rows.length}` : `·/${rows.length}`;
230
263
  const total = cells(sum, clock(Math.max(0, ...rows.map((r) => r.time ?? 0))), tokensText(rows.some((r) => r.tokens !== null) ? rows.reduce((n, r) => n + (r.tokens ?? 0), 0) : null));
231
264
  const totalLine = `${padTo(' total', leftWidth - total.length)}${done.length ? total : total.replace(sum, `${GREY}${sum}${RESET}`)}`;
232
- const leftBottom = [`${GREY}${'─'.repeat(leftWidth)}${RESET}`, totalLine];
233
265
 
234
266
  const row = rows[cursor];
235
267
  const level = row.state === 'done' ? `${row.result.score}/4 ${LEVELS[row.result.score]}` : row.state === 'error' ? 'error' : row.state;
236
268
  const name = `${row.rank} ${row.name.toUpperCase()}`;
237
- const detailHeader = `${focus === 'detail' ? `${ORANGE}${BOLD}` : BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`;
238
- const content = detailContent(row, columnWidth, start, width, frame);
269
+ const detailHeader = `${BOLD}${name}${RESET}${' '.repeat(Math.max(1, columnWidth - name.length - level.length))}${GREY}${level}${RESET}`;
239
270
  const facts = [`judge ${row.rank}`, row.harness, ...(row.time === null ? [] : [clock(row.time)]), ...(row.state === 'done' || row.state === 'error' ? [`judges/${row.rank}/answer.json`] : [])];
240
271
  const bottom = [`${ORANGE}r${RESET} restart ${ORANGE}h${RESET} harness ${row.harness} ${ORANGE}${row.rank}${RESET} watch`, '', `${GREY}${facts.join(' · ')}${RESET}`];
241
-
242
- // top, gap blank lines, then bottom; with a height that holds them, the blank lines grow so bottom ends at the height.
243
- const pane = (top, bottomLines, lines, gap) => {
244
- const fill = lines === undefined || lines < top.length + gap + bottomLines.length ? gap : lines - top.length - bottomLines.length;
245
- return [...top, ...Array(fill).fill(''), ...bottomLines];
246
- };
247
- // The detail pane: the header, a window of the content that the scroll moves, a blank line, then the bottom lines.
248
- const detailPane = (lines) => {
249
- const window = lines === undefined ? 0 : lines - 5;
250
- if (window < 1) return pane([detailHeader, ...content], bottom, lines, 1);
251
- const from = Math.min(scroll, Math.max(0, content.length - window));
252
- return [detailHeader, ...content.slice(from, from + window), ...Array(Math.max(0, window - content.length)).fill(''), '', ...bottom];
272
+ // The pane: the header, the content, then the bottom lines at the bottom. A running judge shows the mark centred.
273
+ const pane = (lines) => {
274
+ // The pane holds the header, a blank line, the content, a blank line, then the bottom lines.
275
+ const window = lines === undefined ? undefined : lines - 2 - 1 - bottom.length;
276
+ let content;
277
+ if (row.state === 'running' && !row.trust) {
278
+ const since = row.restarted !== null ? `restarted with ${row.harness} · ` : '';
279
+ const line = `${GREY}${since}${clock(row.time ?? 0)} · ${row.rank} to watch${RESET}`;
280
+ const size = markSize(columnWidth, window ?? MARK_SIZES[0][0] + 2);
281
+ const art = size ? renderMark({ frame, start: start + Math.floor((columnWidth - size[1]) / 2), width, size }) : [glimmer('judging', frame, start + Math.floor((columnWidth - 7) / 2), width)];
282
+ const block = [...art, '', line].map((l) => centre(l, columnWidth));
283
+ const above = window === undefined ? 0 : Math.max(0, Math.floor((window - block.length) / 2));
284
+ content = [...Array(above).fill(''), ...block];
285
+ } else {
286
+ const text = row.state === 'done' ? row.result.verdict : row.state === 'error' ? row.error : row.state === 'queued' ? 'The judge has not started.' : `Press ${row.rank} and answer the trust prompt.`;
287
+ content = wrap(text, columnWidth);
288
+ }
289
+ const top = [detailHeader, '', ...content];
290
+ if (window === undefined || content.length > window) return [...top, '', ...bottom];
291
+ return [...top, ...Array(window - content.length).fill(''), '', ...bottom];
253
292
  };
254
- const edge = focus === 'detail' ? `${ORANGE}▌${RESET}` : `${GREY}│${RESET}`;
293
+ return page({
294
+ header: headerLine(title, target, harness, elapsed, width, version),
295
+ width,
296
+ height,
297
+ left: { rows, lines: left },
298
+ leftBottom: [`${GREY}${'─'.repeat(leftWidth)}${RESET}`, totalLine],
299
+ pane,
300
+ edge: `${GREY}│${RESET}`,
301
+ });
302
+ }
255
303
 
256
- const lines = [header, rule];
257
- const body = height === undefined ? undefined : height - 2;
258
- if (width >= TWO_COLUMNS) {
259
- const leftLines = pane(left, leftBottom, body, 0);
260
- const rightLines = detailPane(body);
261
- for (let i = 0; i < Math.max(leftLines.length, rightLines.length); i++) lines.push(`${padTo(leftLines[i] ?? '', leftWidth)} ${edge} ${rightLines[i] ?? ''}`);
262
- } else {
263
- const half = body === undefined ? undefined : Math.floor(body / 2);
264
- const list = pane(left, leftBottom, half, 0);
265
- lines.push(...list, '', ...detailPane(body === undefined ? undefined : body - list.length - 1).map((l) => `${focus === 'detail' ? `${ORANGE}▌${RESET}` : ' '} ${l}`));
266
- }
267
- return lines.map((l) => clip(l, width));
304
+ /**
305
+ * The lines of the Panel tab: the same header, the criteria at the left (rank and name, the cursor row orange),
306
+ * and at the right the description and the body of the cursor criterion as plain text. focus 'text' marks the
307
+ * pane with an orange edge and an orange header, and scroll hides that many lines of the text.
308
+ */
309
+ export function renderPanel({ title, target, harness, elapsed, rows, cursor, width, height, version = VERSION, focus = 'list', scroll = 0 }) {
310
+ const { leftWidth, columnWidth } = layout(rows, width);
311
+ const pad = leftWidth - 27;
312
+ const left = rows.map((r, i) => (i === cursor ? `${ORANGE}▸ ${String(r.rank).padStart(2)} ${r.name.padEnd(pad)}${RESET}` : ` ${String(r.rank).padStart(2)} ${r.name.padEnd(pad)}`));
313
+ const row = rows[cursor];
314
+ const name = `${row.rank} ${row.name.toUpperCase()}`;
315
+ const header = `${focus === 'text' ? `${ORANGE}${BOLD}` : BOLD}${name}${RESET}`;
316
+ const text = criterionText(row, columnWidth);
317
+ const pane = (lines) => {
318
+ const window = lines === undefined ? text.length : lines - 2;
319
+ const from = Math.min(scroll, Math.max(0, text.length - window));
320
+ return [header, '', ...text.slice(from, from + Math.max(0, window))];
321
+ };
322
+ return page({
323
+ header: headerLine(title, target, harness, elapsed, width, version),
324
+ width,
325
+ height,
326
+ left: { rows, lines: left },
327
+ leftBottom: [],
328
+ pane,
329
+ edge: focus === 'text' ? `${ORANGE}▌${RESET}` : `${GREY}│${RESET}`,
330
+ });
268
331
  }
269
332
 
333
+ // The pages: the overview (view 0) and the Panel tab (view 'panel'). The other views are judges.
334
+ export const PANEL = 'panel';
335
+ export const isPage = (view) => view === 0 || view === PANEL;
336
+
270
337
  /**
271
338
  * The three lines of the tab strip: a grey hairline, the tabs, then a grey status line with the progress and
272
- * the elapsed time at the left and the keys of the focus at the right. The first tab is Overview, then the
339
+ * the elapsed time at the left and the keys of the focus at the right. The tabs are Overview, Panel, then the
273
340
  * name of each criterion, with a grey │ between the tabs. A running tab glimmers. The names step down to the
274
341
  * first word when they do not fit the width. focus is 'strip' (the active tab is bold orange, the arrows move
275
- * it), 'detail' (the detail pane of the overview scrolls), or 'judge' (the active tab is bold ink, the keys go to
342
+ * it), 'text' (the text pane of the Panel tab scrolls), or 'judge' (the active tab is bold ink, the keys go to
276
343
  * the judge). confirm replaces the progress with the quit question.
277
344
  */
278
345
  export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', elapsed = 0, confirm = false }) {
279
346
  const finished = rows.filter((r) => r.state === 'done' || r.state === 'error').length;
280
347
  const keys = focus === 'judge' ? 'ctrl-q tabs'
281
- : focus === 'detail' ? '↑ ↓ scroll · enter list · ← → tabs · q q quit'
282
- : view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
283
- const names = (level) => ['Overview', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
348
+ : focus === 'text' ? '↑ ↓ scroll · enter list · ← → tabs · q q quit'
349
+ : view === 0 ? '↑ ↓ criterion · r restart · h harness · ← → tabs · q q quit'
350
+ : view === PANEL ? '↑ ↓ criterion · enter text · ← → tabs · q q quit' : '← → tabs · enter back · q quit';
351
+ const names = (level) => ['Overview', 'Panel', ...rows.map((r) => (level === 0 ? r.name : r.name.split(' ')[0]))];
284
352
  const used = (list) => 1 + list.reduce((n, t) => n + t.length, 0) + 3 * (list.length - 1);
285
353
  const list = [0, 1].map(names).find((l) => used(l) <= width) ?? names(1);
354
+ const activeIndex = view === 0 ? 0 : view === PANEL ? 1 : view + 1;
286
355
  let line = ' ';
287
356
  let col = 1;
288
357
  list.forEach((text, i) => {
289
358
  if (i) { line += `${GREY} │ ${RESET}`; col += 3; }
290
- const active = i === view;
291
- const row = rows[i - 1];
359
+ const active = i === activeIndex;
360
+ const row = rows[i - 2];
292
361
  const start = col;
293
362
  col += text.length;
294
363
  if (row?.state === 'running' && !row.trust) {
295
364
  line += glimmer(text, frame, start, width, active && focus === 'strip' ? GLIMMER_ORANGE : GLIMMER, active ? BOLD : '');
296
365
  return;
297
366
  }
298
- line += `${active ? (focus === 'strip' ? `${ORANGE}${BOLD}` : BOLD) : GREY}${text}${RESET}`;
367
+ line += `${active ? (focus === 'judge' ? BOLD : `${ORANGE}${BOLD}`) : GREY}${text}${RESET}`;
299
368
  });
300
369
  const progress = confirm ? 'judges still run. q again quits, another key stays' : `${finished} of ${rows.length} done · ${clock(elapsed)}`;
301
370
  // A narrow terminal drops the keys before the progress, and then the end of the progress.
@@ -307,50 +376,51 @@ export function renderStrip({ rows, view, frame = 0, width, focus = 'strip', ela
307
376
  /**
308
377
  * The next { view, confirm, focus, cursor, scroll } after one key, plus send: true when the key goes to the judge,
309
378
  * quit: true, restart: N to restart judge N, or flip: N to flip the harness of judge N. view is 0 for the
310
- * overview, or N for judge N. open holds the ranks of the judges that have started, count the number of
311
- * criteria, maxScroll the lines the detail pane can hide, page the lines one PgUp or PgDn moves. With the
312
- * focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the strip. With the
313
- * focus on the strip, the arrows left and right move the active tab and switch the view, 1-9 jump to a started
314
- * judge, 0 to the overview, Enter, Esc, or ctrl-q give the focus back to the judge, and q quits (a second q
315
- * confirms while a judge runs). On the overview, which takes no text, up and down move the cursor, r restarts
316
- * the judge of the cursor row, h flips its harness, and Enter moves the focus into the detail pane: there up
317
- * and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus to the list.
379
+ * overview, PANEL for the Panel tab, or N for judge N. open holds the ranks of the judges that have started,
380
+ * count the number of criteria, maxScroll the lines the text pane can hide, page the lines one PgUp or PgDn
381
+ * moves. With the focus on a judge, every key but ctrl-q goes to the judge, and ctrl-q moves the focus to the
382
+ * strip. With the focus on the strip, the arrows left and right move the active tab and switch the view, 1-9
383
+ * jump to a started judge, 0 to the overview, and q quits (a second q confirms while a judge runs). On the
384
+ * overview and the Panel tab, which take no text, up and down move the shared cursor; on the overview r
385
+ * restarts the judge of the cursor row and h flips its harness; on the Panel tab Enter moves the focus into
386
+ * the text pane, where up and down scroll one line, PgUp and PgDn a page, and Esc or Enter return the focus.
387
+ * On a judge tab, Enter, Esc, or ctrl-q give the focus to the judge.
318
388
  */
319
- export function press({ view, confirm, focus, cursor, scroll = 0 }, key, { open, running, count, maxScroll: limit = 0, page = 10 }) {
389
+ export function press({ view, confirm, focus, cursor, scroll = 0 }, key, { open, running, count, maxScroll: limit = 0, page: step = 10 }) {
320
390
  const strip = (v) => ({ view: v, confirm: false, focus: 'strip', cursor, scroll });
321
391
  if (focus === 'judge') return TOGGLE.includes(key) ? strip(view) : { view, confirm, focus, cursor, scroll, send: true };
322
392
  if (confirm) return { ...strip(0), quit: key === 'q' };
323
393
  if (key === 'q') return running ? { view: 0, confirm: true, focus: 'strip', cursor, scroll } : { ...strip(0), quit: true };
324
394
  if (RIGHT.includes(key) || LEFT.includes(key)) {
325
- const cycle = [0, ...open];
326
- const step = RIGHT.includes(key) ? 1 : -1;
327
- return strip(cycle[(cycle.indexOf(view) + step + cycle.length) % cycle.length]);
395
+ const cycle = [0, PANEL, ...open];
396
+ const dir = RIGHT.includes(key) ? 1 : -1;
397
+ return strip(cycle[(cycle.indexOf(view) + dir + cycle.length) % cycle.length]);
328
398
  }
329
399
  if (/^[1-9]$/.test(key)) return open.includes(Number(key)) ? strip(Number(key)) : { ...strip(view), focus };
330
400
  if (key === '0') return strip(0);
331
- if (view === 0) {
332
- const detail = (s) => ({ view: 0, confirm: false, focus: 'detail', cursor, scroll: Math.max(0, Math.min(limit, s)) });
333
- if (key === 'r') return open.includes(cursor + 1) ? { ...strip(0), focus, restart: cursor + 1 } : { ...strip(0), focus };
334
- if (key === 'h') return { ...strip(0), focus, flip: cursor + 1 };
335
- if (focus === 'detail') {
336
- if (UP.includes(key)) return detail(scroll - 1);
337
- if (DOWN.includes(key)) return detail(scroll + 1);
338
- if (key === '\x1b[5~') return detail(scroll - page);
339
- if (key === '\x1b[6~') return detail(scroll + page);
340
- if (key === '\r' || key === '\x1b') return strip(0);
341
- return detail(scroll);
401
+ if (isPage(view)) {
402
+ if (view === 0 && key === 'r') return open.includes(cursor + 1) ? { ...strip(0), restart: cursor + 1 } : strip(0);
403
+ if (view === 0 && key === 'h') return { ...strip(0), flip: cursor + 1 };
404
+ if (focus === 'text') {
405
+ const text = (s) => ({ view, confirm: false, focus: 'text', cursor, scroll: Math.max(0, Math.min(limit, s)) });
406
+ if (UP.includes(key)) return text(scroll - 1);
407
+ if (DOWN.includes(key)) return text(scroll + 1);
408
+ if (key === '\x1b[5~') return text(scroll - step);
409
+ if (key === '\x1b[6~') return text(scroll + step);
410
+ if (key === '\r' || key === '\x1b') return strip(view);
411
+ return text(scroll);
342
412
  }
343
- if (UP.includes(key)) return { ...strip(0), cursor: Math.max(0, cursor - 1), scroll: 0 };
344
- if (DOWN.includes(key)) return { ...strip(0), cursor: Math.min(count - 1, cursor + 1), scroll: 0 };
345
- if (key === '\r') return detail(0);
346
- return strip(0);
413
+ if (UP.includes(key)) return { ...strip(view), cursor: Math.max(0, cursor - 1), scroll: 0 };
414
+ if (DOWN.includes(key)) return { ...strip(view), cursor: Math.min(count - 1, cursor + 1), scroll: 0 };
415
+ if (view === PANEL && key === '\r') return { view, confirm: false, focus: 'text', cursor, scroll: 0 };
416
+ return strip(view);
347
417
  }
348
418
  if (BACK.includes(key)) return { view, confirm: false, focus: 'judge', cursor, scroll };
349
419
  return strip(view);
350
420
  }
351
421
 
352
422
  /** Runs the judges, shows the overview, and writes the report. Resolves when the engineer quits. */
353
- export function runTable({ panel, runDir, harness, cwd, target, brief, sources, yolo = false }) {
423
+ export function runTable({ panel, runDir, harness, cwd, target, yolo = false }) {
354
424
  const out = process.stdout;
355
425
  const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
356
426
  const judges = [];
@@ -395,7 +465,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
395
465
  }
396
466
 
397
467
  function start(rank, restartedAt = null) {
398
- const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target, brief, sources, yolo });
468
+ const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criterion: criteria[rank - 1], harness: harnesses[rank - 1], cwd, target, yolo });
399
469
  const { cols, rows: lines } = size();
400
470
  const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
401
471
  const serializer = new SerializeAddon();
@@ -441,7 +511,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
441
511
  const lines = renderStrip({ rows, view: ui.view, frame, width: out.columns, focus: ui.focus, elapsed: seconds(started), confirm: ui.confirm });
442
512
  const top = out.rows - STRIP_ROWS + 1;
443
513
  const strip = lines.map((l, i) => `\x1b[${top + i};1H${l}\x1b[K`).join('');
444
- if (!ui.view) return void out.write(`${RESET}${strip}`);
514
+ if (isPage(ui.view)) return void out.write(`${RESET}${strip}`);
445
515
  const j = judges[ui.view - 1];
446
516
  const { cursorX, cursorY } = j.term.buffer.active;
447
517
  if (cursorX >= out.columns) return;
@@ -475,7 +545,8 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
475
545
 
476
546
  let frame = 0;
477
547
  function draw() {
478
- const lines = renderOverview({ title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, height: out.rows - STRIP_ROWS, frame, focus: ui.focus === 'detail' ? 'detail' : 'list', scroll: ui.scroll });
548
+ const common = { title: `${panel.owner}/${panel.name} v${panel.version}`, target, harness, elapsed: seconds(started), rows, cursor: ui.cursor, width: out.columns, height: out.rows - STRIP_ROWS };
549
+ const lines = ui.view === PANEL ? renderPanel({ ...common, focus: ui.focus === 'text' ? 'text' : 'list', scroll: ui.scroll }) : renderOverview({ ...common, frame });
479
550
  while (lines.length < out.rows - STRIP_ROWS) lines.push('');
480
551
  out.write(lines.slice(0, out.rows - STRIP_ROWS).map((l, i) => `\x1b[${i + 1};1H${l}\x1b[K`).join(''));
481
552
  drawStrip();
@@ -494,14 +565,14 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
494
565
  }
495
566
  if (rows.every((r) => r.state === 'done' || r.state === 'error')) finish();
496
567
  }
497
- if (!ui.view) draw();
568
+ if (isPage(ui.view)) draw();
498
569
  }
499
570
 
500
571
  return new Promise((done) => {
501
572
  const timer = setInterval(tick, 1000);
502
573
  const spinner = setInterval(() => {
503
574
  frame += 1;
504
- if (!ui.view) draw();
575
+ if (isPage(ui.view)) draw();
505
576
  else if (Date.now() - lastData > 60) drawStrip();
506
577
  }, 100);
507
578
  out.write('\x1b[?1049h\x1b[?25l');
@@ -510,7 +581,7 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
510
581
  const key = data.toString();
511
582
  const from = ui.view;
512
583
  const height = out.rows - STRIP_ROWS;
513
- const limit = rows.length ? maxScroll({ rows, cursor: ui.cursor, width: out.columns, height }) : 0;
584
+ const limit = rows.length ? panelScrollMax({ rows, cursor: ui.cursor, width: out.columns, height }) : 0;
514
585
  const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running'), count: criteria.length, maxScroll: limit, page: Math.max(1, height - 7) });
515
586
  ui = { view: next.view, confirm: next.confirm, focus: next.focus, cursor: next.cursor, scroll: next.scroll };
516
587
  if (next.send) {
@@ -529,13 +600,13 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
529
600
  } else if (next.flip) {
530
601
  harnesses[next.flip - 1] = harnesses[next.flip - 1] === 'claude' ? 'codex' : 'claude';
531
602
  tick();
532
- } else if (ui.view && ui.view === from) {
603
+ } else if (!isPage(ui.view) && ui.view === from) {
533
604
  drawStrip();
534
605
  } else {
535
606
  // A judge can leave its modes on (mouse, bracketed paste, the kitty keyboard). The next view starts clean.
536
- if (from) out.write(TABLE_MODES);
537
- if (ui.view) showJudge(ui.view);
538
- else draw();
607
+ if (!isPage(from)) out.write(TABLE_MODES);
608
+ if (isPage(ui.view)) draw();
609
+ else showJudge(ui.view);
539
610
  }
540
611
  });
541
612
  out.on('resize', () => {
@@ -544,8 +615,8 @@ export function runTable({ panel, runDir, harness, cwd, target, brief, sources,
544
615
  if (j && !j.exited) j.proc.resize(cols, lines);
545
616
  j?.term.resize(cols, lines);
546
617
  }
547
- if (ui.view) showJudge(ui.view);
548
- else draw();
618
+ if (isPage(ui.view)) draw();
619
+ else showJudge(ui.view);
549
620
  });
550
621
  tick();
551
622
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
@@ -24,7 +24,6 @@
24
24
  "dependencies": {
25
25
  "@homebridge/node-pty-prebuilt-multiarch": "0.14.1",
26
26
  "@xterm/addon-serialize": "0.14.0",
27
- "@xterm/headless": "6.0.0",
28
- "figlet": "1.12.0"
27
+ "@xterm/headless": "6.0.0"
29
28
  }
30
29
  }
@@ -3,44 +3,42 @@ You are one judge in a run of a panel. A panel is a named list of criteria. You
3
3
  YOUR INPUTS
4
4
 
5
5
  The user message gives you:
6
- - Your criterion: its rank, its name, its one-line description, and its body. Follow the body as written.
6
+ - Your criterion: its rank, its name, its one-line description, and its body. They define what to assess, not how. This prompt holds the method.
7
7
  - The target: the text that the engineer typed after the panel name, unchanged. It can be a URL, a path, a branch, a pull request, or a sentence.
8
- - The brief: a file path, or "none supplied".
9
- - The sources: paths or URLs, or "none".
10
8
 
11
9
  Your working directory is the folder where the engineer ran the command.
12
10
 
13
- THE TARGET
11
+ HOW TO JUDGE
14
12
 
15
- Find out what the target names, and reach it with your tools: read files, run git read commands, open URLs. When you cannot reach the target, or cannot do a test of the body with your tools, name the test at the end of your verdict after "Not checked:" with the reason. Then score as the body says for that case. Never score what you did not see.
13
+ - Look at the target itself: open it, read it, and run it when it is code. Reach it with your tools: read files, run commands, open URLs.
14
+ - Collect evidence for every claim you make: a URL, a file and line, a quote, or a command and its output. The engineer must be able to check each one.
15
+ - Give no credit for intent. A comment or a doc is not done work.
16
+ - When you cannot reach the target, or cannot check a part of the criterion with your tools, say so at the end of your verdict after "Not checked:" with the reason. Never score what you did not see.
17
+ - Keep any notes or tables for yourself; they do not go in the answer.
16
18
 
17
- ORDER
19
+ SCORE
18
20
 
19
- Before you read the brief or the sources, do every step that the body puts before them. Keep the records and tables that the body asks for in your own notes; they do not go in the answer.
21
+ The score is a whole number from 1 to 4. Only 4 passes.
20
22
 
21
- HOW TO SCORE
23
+ 4 Met. The criterion holds across the whole target, every claim has evidence, and a real look found no material flaw.
24
+ 3 Almost met. The criterion holds in the main. Material flaws remain, and each one is named with its evidence.
25
+ 2 Falls short. Parts hold, but the flaws outweigh them, or a core part is missing.
26
+ 1 Fails. The criterion does not hold, or the target cannot be assessed for it, or a claim is invented.
22
27
 
23
- 1. Follow the body to a level from 1 to 4: 4 Met, 3 Almost met, 2 Falls short, 1 Fails. The level is the score.
24
- 2. Apply each cap of the body, such as "cannot support a score above 3". When two caps apply, use the lower cap.
25
- 3. When the evidence fits two levels, use the lower level.
26
- 4. A criterion passes only at score 4.
27
-
28
- EVIDENCE
29
-
30
- - Each claim needs evidence that the engineer can check: a URL, a file and line, a quote, or a command and its output. Put the place in brackets inside the verdict, such as (index.html:90).
31
- - Give no credit for intent. A promise in a comment, a doc, or the brief is not done work.
28
+ Rules:
29
+ - When the evidence fits two levels, use the lower level.
32
30
  - No evidence for a level means the lower level.
31
+ - Anything you did not check cannot support a 4. List it at the end of the verdict after "Not checked:".
33
32
 
34
33
  THE VERDICT
35
34
 
36
- One paragraph: what holds, then what is wrong and where, each place in brackets, then "Not checked:" and the tests you could not do, when there are any. Judge; do not propose fixes. The fixes are the job of the engineer.
35
+ One paragraph: what holds, then what is wrong and where, each place in brackets such as (index.html:90), then "Not checked:" and the parts you could not check, when there are any. Judge; do not propose fixes. The fixes are the job of the engineer.
37
36
 
38
37
  PROHIBITIONS
39
38
 
40
- - Do not change the body, the levels, or the caps.
41
- - Do not score a fault that the body gives to another criterion.
42
- - Do not write or change files, except the answer file when the user message names one.
43
- - Do not start sub-agents.
39
+ - Do not change the criterion or the levels.
40
+ - Do not score a fault that another criterion of the panel covers.
41
+ - Do not write or change files of the target. The answer file, when the user message names one, is the only file you write.
44
42
  - Do not submit forms, send messages, or post data.
45
43
  - Do not read the .panels folder. It holds earlier reports.
46
44
 
@@ -59,5 +57,5 @@ Example:
59
57
  {
60
58
  "name": "Original design assets",
61
59
  "score": 2,
62
- "verdict": "The hero shows the real app, and the cutaway hotel and the team scenes are original and share one idea (public/assets/hotel-world). Every content icon is a generic Lucide symbol (src/components/Features.tsx:3), which caps the band at 6. Not checked: a reverse image search of hero.webp, the target is on localhost."
60
+ "verdict": "The hero shows the real app, and the cutaway hotel and the team scenes are original and share one idea (public/assets/hotel-world). Every content icon is a generic Lucide symbol (src/components/Features.tsx:3), so the icon set is not original. Not checked: a reverse image search of hero.webp, the target is on localhost."
63
61
  }