geneseed 3.14.0 → 3.16.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/CHANGELOG.md +66 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +45 -11
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +37 -11
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +22 -20
- package/bin/geneseed-hook.mjs +6 -3
- package/docs/concepts/exclusions.md +21 -9
- package/docs/guides/install-scripted.md +1 -1
- package/docs/guides/project-context.md +1 -1
- package/docs/token-footprint.md +10 -7
- package/harness.config.json +1 -1
- package/js/README.md +9 -5
- package/js/build/args.mjs +38 -6
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +16 -14
- package/js/build/emit-claude.mjs +3 -2
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +26 -16
- package/js/build/render.mjs +34 -4
- package/js/build/source.mjs +43 -7
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +85 -34
- package/js/hosts/hooks-prims.mjs +2 -2
- package/js/hosts/hosts.mjs +24 -26
- package/js/hosts/installs.mjs +58 -16
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/memory-files.mjs +13 -4
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +46 -36
- package/js/hosts/shim.mjs +4 -2
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/status.mjs +20 -22
- package/js/inspect/validate.mjs +5 -3
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/maintain/memory.mjs +5 -6
- package/js/maintain/migrate.mjs +4 -3
- package/js/maintain/setup.mjs +20 -135
- package/js/maintain/uninstall.mjs +10 -9
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +29 -35
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +87 -389
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +33 -717
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +1 -1
- package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
- package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
- package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
- package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
- package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
- package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
- package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
- package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/js/ui/cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* copy in it.
|
|
11
11
|
*
|
|
12
12
|
* P10c's answer was to make the metadata DATA — generated into `cli.json` at the repo root by
|
|
13
|
-
*
|
|
13
|
+
* a since-deleted Python script and read by both sides. P2 finishes the thought: the table is
|
|
14
14
|
* no longer GENERATED from anything, it is the OWNED document, and it lives here on a product
|
|
15
15
|
* path because that is where a document npm ships belongs. `package.json`'s `files[]` already
|
|
16
16
|
* carries `js/`, so it needs no row of its own, and nothing has to import out of `tests/`.
|
|
@@ -31,13 +31,9 @@
|
|
|
31
31
|
* a file filtered for the page breaks the CLI, and a file in `cliSpec`'s shape cannot render
|
|
32
32
|
* the docs. It stays exactly what the argparse walk produced.
|
|
33
33
|
*
|
|
34
|
-
* WHAT
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* argparse-vs-table comparison, and it can only ever cover the rows argparse HAS: `catalog`,
|
|
38
|
-
* `mcp` and `memory` never had a subparser and are outside it by construction. The successor
|
|
39
|
-
* that checks the WHOLE table, and outlives the parser, is `tests/unit/cli_table.test.mjs`;
|
|
40
|
-
* after that the table is simply the source of truth and is edited directly.
|
|
34
|
+
* WHAT GUARDS IT. The Python parser it was walked from is gone, and with it every comparison
|
|
35
|
+
* against argparse; the table is now simply the source of truth and is edited directly.
|
|
36
|
+
* `tests/unit/cli_table.test.mjs` checks the WHOLE table.
|
|
41
37
|
*
|
|
42
38
|
* THE COST, NAMED: this entry cannot parse anything without this file. `js/build/source.mjs`
|
|
43
39
|
* already reads `harness.config.json` at import for every render, so a tracked data document
|
|
@@ -48,6 +44,7 @@ import { readFileSync } from 'node:fs';
|
|
|
48
44
|
import path from 'node:path';
|
|
49
45
|
|
|
50
46
|
import { ROOT } from '../build/source.mjs';
|
|
47
|
+
import { printOut } from '../lib/fs.mjs';
|
|
51
48
|
import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
|
|
52
49
|
|
|
53
50
|
const CLI_JSON = path.join(ROOT, 'js', 'cli-table.json');
|
|
@@ -156,8 +153,8 @@ function actionInvocation(a, positional) {
|
|
|
156
153
|
// hyphenated word into two chunks, so it can break a line inside `back-compat` where a
|
|
157
154
|
// space-only wrap cannot. Measured before deciding: every help string then in the table disagreed
|
|
158
155
|
// with a greedy wrap at some width, the widest at 175 — so "the branch is dead" was simply
|
|
159
|
-
// false, and `
|
|
160
|
-
// first asserted it. The rules are transcribed instead.
|
|
156
|
+
// false, and the former Python suite's every-width comparison against `textwrap` said so on
|
|
157
|
+
// the run that first asserted it. The rules are transcribed instead.
|
|
161
158
|
//
|
|
162
159
|
// Python's classes, spelled for a `u`-flagged JavaScript regex: `\w` is letters, numbers and
|
|
163
160
|
// underscore; `[^\d\W]` is that minus the DECIMAL digits, which leaves the letter-ish half
|
|
@@ -181,9 +178,8 @@ const WORDSEP = new RegExp(
|
|
|
181
178
|
* `drop_whitespace`, `break_long_words` and `break_on_hyphens` all on, and the text already
|
|
182
179
|
* whitespace-collapsed and stripped by the caller.
|
|
183
180
|
*
|
|
184
|
-
* ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer
|
|
185
|
-
*
|
|
186
|
-
* move this when that changes, not before.
|
|
181
|
+
* ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer;
|
|
182
|
+
* move it when a second one appears, not before.
|
|
187
183
|
*/
|
|
188
184
|
export function wrapText(text, width) {
|
|
189
185
|
const chunks = text.split(WORDSEP).filter(Boolean).reverse();
|
|
@@ -310,7 +306,7 @@ function formatAction(a, positional, helpPosition, width) {
|
|
|
310
306
|
* `parser.format_help()` for one subcommand, byte for byte with argparse.
|
|
311
307
|
*
|
|
312
308
|
* WHY THE PORT RENDERS THIS AT ALL. Task 10 compared the two implementations' `--help` for the
|
|
313
|
-
* first time —
|
|
309
|
+
* first time — the former Python CLI matrix had zero `--help` cells — and found the port answering
|
|
314
310
|
* `geneseed: error: unrecognized arguments: --help`, exit 2, on all 26 verbs. That is not the
|
|
315
311
|
* declared wording divergence `parse`'s docblock reserves for argparse's ERROR text: it is the
|
|
316
312
|
* absence of the surface, on the binary that survives the reference's deletion.
|
|
@@ -318,19 +314,19 @@ function formatAction(a, positional, helpPosition, width) {
|
|
|
318
314
|
* `prog` IS AN ARGUMENT AND THAT IS THE ONE DELIBERATE DIVERGENCE. The reference names itself
|
|
319
315
|
* `harness`, after the Python file this migration deletes; a user who typed `geneseed status
|
|
320
316
|
* --help` is shown `usage: geneseed status`, which is also how every error this entry prints
|
|
321
|
-
* already spells itself (`die`).
|
|
322
|
-
*
|
|
323
|
-
*
|
|
317
|
+
* already spells itself (`die`). The former Python suite replayed the reference's recorded
|
|
318
|
+
* answers through here with `prog` set to the reference's own, so the layout was gated byte for
|
|
319
|
+
* byte and the rename was the only thing that moved. That corpus is gone; the layout is now
|
|
320
|
+
* pinned by the written-out `formatHelp` rows in `tests/unit/text_layout.test.mjs`.
|
|
324
321
|
*
|
|
325
|
-
* NOT EVERY VERB
|
|
326
|
-
* no subparser for the recorder to render from, so
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* what keeps "no recorded text" from meaning "no gate".
|
|
322
|
+
* NOT EVERY VERB HAD A RECORDED ANSWER, AND THREE NEVER COULD. `catalog`, `mcp` and `memory` had
|
|
323
|
+
* no subparser for the recorder to render from, so they were held ABSOLUTELY instead — and
|
|
324
|
+
* `tests/unit/hook_cli.test.mjs`'s `NATIVE` list still names that population and fails a verb
|
|
325
|
+
* that falls into neither, which is what keeps "no recorded text" from meaning "no gate".
|
|
330
326
|
*
|
|
331
327
|
* The alias rides on the same argument: argparse gives `update` the parser object it built for
|
|
332
|
-
* `upgrade`, so `harness.py update --help`
|
|
333
|
-
* map
|
|
328
|
+
* `upgrade`, so `harness.py update --help` printed `usage: harness upgrade`. The recorded prog
|
|
329
|
+
* map reproduced that; the shipped call passes `geneseed update`, which is what the user typed.
|
|
334
330
|
*
|
|
335
331
|
* `width` is argparse's `shutil.get_terminal_size().columns - 2`, passed in rather than read,
|
|
336
332
|
* because a recorded help text whose wrap column came from the recorder's terminal is a corpus
|
|
@@ -407,12 +403,13 @@ export function formatHelp(cmd, prog, width) {
|
|
|
407
403
|
*
|
|
408
404
|
* `parseIntStrict`, NOT `Number.parseInt`, and the tree already owned it. `get_terminal_size` spells
|
|
409
405
|
* this `int(os.environ['COLUMNS'])` inside `except (KeyError, ValueError)`, and `int` is not
|
|
410
|
-
* `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values
|
|
411
|
-
* (`
|
|
406
|
+
* `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values in the former
|
|
407
|
+
* Python suite (`tests/unit/cli_table.test.mjs`'s 'the help width is the terminal width less
|
|
408
|
+
* two, and COLUMNS wins' keeps a written-out part of it): `COLUMNS=1_0` is 10 to Python and
|
|
412
409
|
* 1 to `parseInt`, `COLUMNS=٣` is 3 and NaN, `COLUMNS=' 80 '` is 80 and NaN. `COLUMNS=80abc`
|
|
413
410
|
* is a ValueError to Python and 80 to `parseInt` — the same answer down a PIPE, where the
|
|
414
411
|
* fallback is 80 either way, and a different one on any terminal that is not 80 wide, which is
|
|
415
|
-
* why
|
|
412
|
+
* why a unit test is the gate rather than a cell. A non-positive value falls through on both
|
|
416
413
|
* sides, which is Python's `if columns <= 0` and was already right here.
|
|
417
414
|
*
|
|
418
415
|
* `stripWhitespace` because THIS is the caller `parseIntStrict`'s docblock did not have: its other two
|
|
@@ -443,8 +440,7 @@ export function printVerbList(prog, verbs, extra = {}) {
|
|
|
443
440
|
`A bare \`${prog}\` runs \`home\`. \`${prog} <command> --help\` describes one command.`, '',
|
|
444
441
|
'commands:'];
|
|
445
442
|
for (const n of names) lines.push(` ${n.padEnd(pad)}${helpOf.get(n) || ''}`.trimEnd());
|
|
446
|
-
|
|
447
|
-
process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
|
|
443
|
+
printOut(`${lines.join('\n')}\n`);
|
|
448
444
|
return 0;
|
|
449
445
|
}
|
|
450
446
|
|
|
@@ -453,18 +449,16 @@ export function printVerbList(prog, verbs, extra = {}) {
|
|
|
453
449
|
* describe — the caller already owns that refusal and its message.
|
|
454
450
|
*
|
|
455
451
|
* ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
|
|
456
|
-
* `bin/geneseed-cli.mjs` the rest of the table, and
|
|
457
|
-
*
|
|
452
|
+
* `bin/geneseed-cli.mjs` the rest of the table, and `tests/unit/hook_cli.test.mjs`'s
|
|
453
|
+
* "the two entry points carry disjoint verb sets" keeps them from learning each other's
|
|
458
454
|
* tables — so the help each prints has to come from here rather than from a copy in each.
|
|
459
455
|
*
|
|
460
|
-
* CRLF ON WINDOWS
|
|
461
|
-
* translates, and this text lands in the same terminals and the same pipes as that one.
|
|
456
|
+
* CRLF ON WINDOWS through `printOut`, the one funnel every CLI print takes.
|
|
462
457
|
*/
|
|
463
458
|
export function printHelp(prog, verb) {
|
|
464
459
|
const cmd = cliCommand(verb);
|
|
465
460
|
if (cmd === null) return null;
|
|
466
|
-
|
|
467
|
-
process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
|
|
461
|
+
printOut(formatHelp(cmd, `${prog} ${verb}`, helpWidth()));
|
|
468
462
|
return 0;
|
|
469
463
|
}
|
|
470
464
|
|
package/js/ui/menu.mjs
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* still the right shape; only its explanation moved. The alternative that was considered and
|
|
23
23
|
* refused: building a numbered line-menu out of `js/maintain/setup.mjs`'s `askChoice`. That is not a
|
|
24
24
|
* port of `_main_menu` — it is a THIRD user interface, differing from the reference on every
|
|
25
|
-
* screen, unreachable from every cell (no cell has a TTY),
|
|
26
|
-
*
|
|
25
|
+
* screen, unreachable from every cell (no cell has a TTY), for a panel that was rejected
|
|
26
|
+
* outright rather than deferred. The fallback is the reference's own contract for "no
|
|
27
27
|
* full-screen menu available here"; a new UI would not be.
|
|
28
28
|
*
|
|
29
29
|
* `cmd_home` is the default for a bare `geneseed`, and it crosses WHOLE. Its two arms are
|
|
@@ -32,10 +32,9 @@
|
|
|
32
32
|
*
|
|
33
33
|
* `_web_first_ok`'s FIVE REFUSALS ARE THE PAYLOAD, and only two of them are reachable from a
|
|
34
34
|
* cell: a cell's stdin is a pipe, so `isTTY` is false and everything past it is dead. The
|
|
35
|
-
* other three (SSH, the Linux display server, the browser) are gated by
|
|
36
|
-
* `tests/
|
|
37
|
-
* one input no cell can vary.
|
|
38
|
-
* declares.
|
|
35
|
+
* other three (SSH, the Linux display server, the browser) are gated by
|
|
36
|
+
* `tests/unit/web_first.test.mjs`, which drives this function through
|
|
37
|
+
* `tests/fixtures/pure_probe.mjs` with `isTTY` FAKED — the one input no cell can vary.
|
|
39
38
|
*/
|
|
40
39
|
import { printOut, printErr } from '../lib/fs.mjs';
|
|
41
40
|
import { which } from '../lib/paths.mjs';
|
|
@@ -93,13 +92,8 @@ export function cmdMenu() {
|
|
|
93
92
|
// The reference's own `except Exception` arm, with a reason that is permanent here rather
|
|
94
93
|
// than machine-dependent. stderr, and CRLF on Windows, because `sys.stderr.write` does.
|
|
95
94
|
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// to be false where it matters: `rituals/_harness_core.py` installs `rituals/_winterm.py`
|
|
99
|
-
// as `sys.modules["curses"]` when the stdlib module is missing, so the reference opens its
|
|
100
|
-
// panel on this machine and `tests/test_tui_boundary.py` drives it doing so. What is
|
|
101
|
-
// missing is on THIS side — there is no window implementation in the port — and that is
|
|
102
|
-
// what the message now says.
|
|
95
|
+
// The reason is on THIS side: there is no window implementation, by decision, and that is
|
|
96
|
+
// what the message says (`tests/unit/no_panel.test.mjs` keeps it so).
|
|
103
97
|
// NOTHING TO POINT AT ANY MORE. The message used to end by sending the reader at the
|
|
104
98
|
// Python panel, and P2 took the pointer out rather than re-aiming it: the panel it named
|
|
105
99
|
// is the Python this migration deletes, and a refusal that sends a user to a file which
|
package/js/ui/tui.mjs
CHANGED
|
@@ -21,13 +21,11 @@
|
|
|
21
21
|
* deleted the day the real one crossed, would be a second user interface and not a port.
|
|
22
22
|
*
|
|
23
23
|
* WHAT DOES CROSS is the alignment contract — the functions below that decide what a row
|
|
24
|
-
* looks like
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* this port has shipped code before whose absence nothing would have noticed, and these are
|
|
30
|
-
* not that.
|
|
24
|
+
* looks like: display width, fitting, glyph tiers, the catalogue's entries and detail lines.
|
|
25
|
+
* Each has a caller outside this file (the catalogue, the status panel, the web console).
|
|
26
|
+
* The panel itself was REJECTED, not deferred, so the helpers that existed only for it —
|
|
27
|
+
* the spinner, the wordmark, scroll clamping, the progress bar and the theme preview/flair
|
|
28
|
+
* readers — were deleted rather than kept waiting for a screen that will not come.
|
|
31
29
|
*
|
|
32
30
|
* WHAT IS DELIBERATELY NOT HERE: `_bx` (its non-ASCII arm returned `curses.ACS_*` chtype
|
|
33
31
|
* ints, which belonged with the window) and every `(stdscr, curses, pal)` drawing helper.
|
|
@@ -37,14 +35,10 @@
|
|
|
37
35
|
* **DO NOT WRITE A SECOND LINE BREAKER.** This block used to argue that `textwrap.wrap` was
|
|
38
36
|
* "a second `difflib` — a stdlib payload with no Node twin". It has one since P1:
|
|
39
37
|
* `wrapText` in `js/ui/cli.mjs`, written because `--help` was the caller that finally
|
|
40
|
-
* required it,
|
|
38
|
+
* required it, pinned by the written-out rows in `tests/unit/text_layout.test.mjs`.
|
|
41
39
|
* Anything that needs wrapping wraps THAT. `_parse_laws`, `load_registry`, `entity_status` and
|
|
42
40
|
* `_tui_inventory` crossed in P6c and live in `js/inspect/inventory.mjs`.
|
|
43
41
|
*/
|
|
44
|
-
import path from 'node:path';
|
|
45
|
-
|
|
46
|
-
import { THEMES } from '../build/source.mjs';
|
|
47
|
-
import { readJsonMaybe } from '../hosts/installs.mjs';
|
|
48
42
|
import { splitLines } from '../lib/udiff.mjs';
|
|
49
43
|
import { printOut } from '../lib/fs.mjs';
|
|
50
44
|
|
|
@@ -58,7 +52,6 @@ import { printOut } from '../lib/fs.mjs';
|
|
|
58
52
|
const TUI_ASCII = !!process.env.GENESEED_TUI_ASCII;
|
|
59
53
|
const TUI_PLAIN = !!process.env.GENESEED_TUI_PLAIN;
|
|
60
54
|
const TUI_EMOJI = !(TUI_ASCII || TUI_PLAIN);
|
|
61
|
-
const TUI_ANIM = TUI_EMOJI;
|
|
62
55
|
|
|
63
56
|
/** `_glyphs` — the one glyph table for the whole TUI, unicode or ASCII stand-ins. */
|
|
64
57
|
export function glyphs(asciiMode) {
|
|
@@ -269,119 +262,18 @@ export function mark(kind) {
|
|
|
269
262
|
return TUI_ASCII ? asc : (TUI_EMOJI ? emoji : sym);
|
|
270
263
|
}
|
|
271
264
|
|
|
272
|
-
const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
273
|
-
|
|
274
|
-
/** `_spin` — one spinner frame for tick `i`; a static dot in the non-animated tiers. */
|
|
275
|
-
export function spin(i) {
|
|
276
|
-
if (!TUI_ANIM) return TUI_ASCII ? '-' : '·';
|
|
277
|
-
const frames = TUI_ASCII ? '|/-\\' : SPIN;
|
|
278
|
-
// `frames[i % len]` on the reference indexes CODE POINTS; the braille frames are BMP so
|
|
279
|
-
// UTF-16 indexing agrees, but spelling it through `[...frames]` keeps that an argument
|
|
280
|
-
// about the data rather than a coincidence. Python's `%` on a negative `i` returns a
|
|
281
|
-
// non-negative remainder and JavaScript's does not, so the modulo is corrected.
|
|
282
|
-
const f = [...frames];
|
|
283
|
-
return f[((i % f.length) + f.length) % f.length];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const LOGO_FONT = {
|
|
287
|
-
G: [' ### ', '# ', '# ## ', '# # ', ' ### '],
|
|
288
|
-
E: ['#### ', '# ', '### ', '# ', '#### '],
|
|
289
|
-
N: ['# #', '## #', '# # #', '# ##', '# #'],
|
|
290
|
-
S: [' ####', '# ', ' ### ', ' #', '#### '],
|
|
291
|
-
D: ['### ', '# # ', '# #', '# # ', '### '],
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
/** `_logo_lines` — the GENESEED wordmark as 5 text rows. */
|
|
295
|
-
export function logoLines() {
|
|
296
|
-
const ink = TUI_ASCII ? '#' : '█';
|
|
297
|
-
const rows = [];
|
|
298
|
-
for (let r = 0; r < 5; r += 1) {
|
|
299
|
-
rows.push([...'GENESEED'].map((c) => LOGO_FONT[c][r].replaceAll('#', ink)).join(' '));
|
|
300
|
-
}
|
|
301
|
-
return rows;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// ---- layout maths --------------------------------------------------------------------
|
|
305
|
-
|
|
306
|
-
/** `_clamp` — clamp a scroll offset so [top, top+viewH) stays inside `total`. */
|
|
307
|
-
export function clamp(top, total, viewH) {
|
|
308
|
-
return Math.max(0, Math.min(top, Math.max(0, total - viewH)));
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const BAR_EIGHTHS = ' ▏▎▍▌▋▊▉█';
|
|
312
|
-
|
|
313
|
-
/** `_progress_bar` — a determinate bar exactly `width` display columns wide. */
|
|
314
|
-
export function progressBar(frac, width = 24) {
|
|
315
|
-
const f = Math.max(0.0, Math.min(1.0, frac));
|
|
316
|
-
if (TUI_ASCII) {
|
|
317
|
-
const filled = roundHalfEven(f * width);
|
|
318
|
-
return '#'.repeat(filled) + '-'.repeat(width - filled);
|
|
319
|
-
}
|
|
320
|
-
const eighths = roundHalfEven(f * width * 8);
|
|
321
|
-
const full = Math.floor(eighths / 8);
|
|
322
|
-
const rem = eighths % 8;
|
|
323
|
-
if (full >= width) return '█'.repeat(width);
|
|
324
|
-
return '█'.repeat(full) + [...BAR_EIGHTHS][rem] + ' '.repeat(width - full - 1);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* `round()` — Python rounds HALF TO EVEN and `Math.round` rounds half UP, and this is the
|
|
329
|
-
* one place in the file where that shows: a bar at exactly 0.5 of a cell is the frontier
|
|
330
|
-
* case the eighths resolution exists to draw, and `round(0.5)` is 0 there and 1 here.
|
|
331
|
-
*/
|
|
332
|
-
function roundHalfEven(x) {
|
|
333
|
-
const f = Math.floor(x);
|
|
334
|
-
const d = x - f;
|
|
335
|
-
if (d > 0.5) return f + 1;
|
|
336
|
-
if (d < 0.5) return f;
|
|
337
|
-
return f % 2 === 0 ? f : f + 1;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ---- theme reads ---------------------------------------------------------------------
|
|
341
|
-
|
|
342
|
-
/** `_theme_preview` — right-panel preview rows for a theme, as (kind, text) pairs. */
|
|
343
|
-
export function themePreview(key) {
|
|
344
|
-
const data = readJsonMaybe(path.join(THEMES, `${key}.json`));
|
|
345
|
-
if (!data || typeof data !== 'object') return [['dim', '(no preview available)']];
|
|
346
|
-
const lines = [['title', key], ['', '']];
|
|
347
|
-
if (data.TAGLINE) lines.push(['dim', data.TAGLINE], ['', '']);
|
|
348
|
-
if (data.LOADED_SIGIL) lines.push(['ok', data.LOADED_SIGIL], ['', '']);
|
|
349
|
-
if (data.VOICE) lines.push(['', `Voice — ${data.VOICE}`], ['', '']);
|
|
350
|
-
if (data.LEX_SEALED_SECRETS) lines.push(['', `e.g. Rule I — ${data.LEX_SEALED_SECRETS}`]);
|
|
351
|
-
if (data.BENEDICTION) lines.push(['', ''], ['dim', data.BENEDICTION]);
|
|
352
|
-
return lines;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** `_theme_flair` — the voice elements the setup chrome speaks in once a theme is chosen. */
|
|
356
|
-
export function themeFlair(theme) {
|
|
357
|
-
const data = readJsonMaybe(path.join(THEMES, `${theme}.json`)) || {};
|
|
358
|
-
const banner = data.BANNER ?? '';
|
|
359
|
-
return {
|
|
360
|
-
accent: data.ACCENT ?? 'cyan',
|
|
361
|
-
tagline: data.TAGLINE ?? '',
|
|
362
|
-
sigil: data.LOADED_SIGIL ?? '',
|
|
363
|
-
// `str.splitlines()` — `js/lib/udiff.mjs`' twin, already gated. It answers `[]` for
|
|
364
|
-
// the empty string where `''.split('\n')` answers `['']`, which would put a blank row
|
|
365
|
-
// in the banner of every theme that omits one; and its boundary set is Python's rather
|
|
366
|
-
// than `\s`, which P6d measured apart.
|
|
367
|
-
banner: splitLines(banner),
|
|
368
|
-
benediction: data.BENEDICTION ?? '',
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
265
|
// ---- the inventory's two consumers ----------------------------------------------------
|
|
373
266
|
|
|
374
267
|
/**
|
|
375
268
|
* `_tui_entries` — the ordered (kind, label, data) rows for the left list. `head` is a
|
|
376
269
|
* section divider and is not selectable.
|
|
377
270
|
*
|
|
378
|
-
* ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS.
|
|
379
|
-
* recorded cases per platform in
|
|
380
|
-
*
|
|
381
|
-
* `
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* gets all three tiers.
|
|
271
|
+
* ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS. When they landed, this
|
|
272
|
+
* function had two recorded cases per platform in the former Python suite's corpus, and their
|
|
273
|
+
* argument was a synthetic inventory carrying exactly `{agents, skills, laws, theme}`. Reading
|
|
274
|
+
* `inv.doctrines.length` on such an inventory THROWS; emitting a head unconditionally changes
|
|
275
|
+
* the result array. Keyed on presence, an inventory without the tiers renders as it always did
|
|
276
|
+
* and a real inventory gets all three tiers.
|
|
385
277
|
*
|
|
386
278
|
* The order is CONSTITUTIONAL and not alphabetical: ontology, then invariants, then doctrines
|
|
387
279
|
* — the order they are read in, the order AGENT.md renders them, and the order the console's
|
|
@@ -435,12 +327,11 @@ export function tuiEntries(inv) {
|
|
|
435
327
|
* because line 554 moves `sel` onto the first SELECTABLE row — but line 555 is
|
|
436
328
|
* `if not selectable: sel = 0`, and row 0 is always the AGENTS header. So an inventory with
|
|
437
329
|
* no agents, no skills AND no laws crashes the panel on its first frame instead of drawing
|
|
438
|
-
* an empty state. `_TUI_INV_EMPTY` in
|
|
330
|
+
* an empty state. `_TUI_INV_EMPTY` in the former Python parity suite is the corpus
|
|
439
331
|
* entry that surfaced it. **P7c must not reproduce the crash when it ports `_tui_loop`** —
|
|
440
332
|
* the fix is one `or []` at the call site, and it belongs in the same change as the port.
|
|
441
333
|
*
|
|
442
|
-
* Its only consumer with direct assertions is `tests/unit/setup.test.mjs
|
|
443
|
-
* also dispatches it to exercise wizard/web_first test scenarios.
|
|
334
|
+
* Its only consumer with direct assertions is `tests/unit/setup.test.mjs`.
|
|
444
335
|
*/
|
|
445
336
|
export function detailLines(kind, label, data) {
|
|
446
337
|
// The three constitutional tiers share one treatment — the label, a blank, then the body —
|