@zenera/cli 1.1.11 → 1.1.12
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 +20 -0
- package/dist/banner.js +69 -47
- package/dist/commands/index.js +3 -0
- package/dist/commands/init.js +8 -4
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.js +375 -0
- package/dist/engine.js +14 -9
- package/dist/main.js +9 -0
- package/dist/podman.js +43 -9
- package/dist/projects.js +10 -2
- package/dist/scaffold.js +22 -1
- package/dist/tui/app.js +19 -11
- package/dist/tui/theme.d.ts +7 -0
- package/dist/tui/theme.js +18 -11
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +151 -43
- package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +7 -4
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
- package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
- package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +16 -16
- package/templates/editor/.github/skills/zen-rag-schema/SKILL.md +2 -2
- package/templates/editor/.vscode/settings.json +4 -2
- package/templates/parts/exa.prompt.md.tmpl +4 -0
- package/templates/parts/exa.spec.md.tmpl +2 -0
- package/templates/project/INSTRUCTIONS.md +8 -0
- package/templates/project/SPECIFICATION.md.tmpl +47 -0
- package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
- package/templates/project/agents.yaml.tmpl +4 -0
- package/templates/project/gitignore +7 -1
- package/templates/project/scripts/_setup.sh +101 -0
- package/templates/project/SPECIFICATION.md +0 -6
package/dist/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentRunner,
|
|
1
|
+
import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
@@ -27,7 +27,6 @@ export async function open(opts) {
|
|
|
27
27
|
const meta = await readSessionMeta(opts.session);
|
|
28
28
|
const workspace = resolve(meta.workspace);
|
|
29
29
|
const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
|
|
30
|
-
const memory = [new FileMemoryStore({ dir: opts.session.memory, id: 'file' })];
|
|
31
30
|
// The config is read before the project is loaded because the sandbox is
|
|
32
31
|
// configured by it and the tools have to exist before selectors can be
|
|
33
32
|
// resolved against them. They are always registered, whether or not anyone
|
|
@@ -35,6 +34,7 @@ export async function open(opts) {
|
|
|
35
34
|
// fails to load with "unknown tool", which would be a lie.
|
|
36
35
|
let sandbox;
|
|
37
36
|
let project;
|
|
37
|
+
let files;
|
|
38
38
|
try {
|
|
39
39
|
const { root, config } = readProjectConfig(opts.project.dir);
|
|
40
40
|
// Assets and the skill catalog are mounted for both, under one name.
|
|
@@ -49,17 +49,21 @@ export async function open(opts) {
|
|
|
49
49
|
keys: opts.keys,
|
|
50
50
|
mounts,
|
|
51
51
|
});
|
|
52
|
+
const workspaceOptions = {
|
|
53
|
+
root: workspace,
|
|
54
|
+
readOnly: opts.readOnly,
|
|
55
|
+
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
56
|
+
mounts,
|
|
57
|
+
};
|
|
58
|
+
// The same view the file tools have, so a path the agent just wrote
|
|
59
|
+
// resolves the same way when it asks memory to keep a copy.
|
|
60
|
+
files = new Workspace(workspaceOptions);
|
|
52
61
|
project = await loadProject(opts.project.dir, {
|
|
53
62
|
tools: [
|
|
54
63
|
// Both groups are pointed at one directory, so both are told the
|
|
55
64
|
// one name for it: whatever `run_command` prints a path as,
|
|
56
65
|
// `read_file` accepts.
|
|
57
|
-
...workspaceTools(
|
|
58
|
-
root: workspace,
|
|
59
|
-
readOnly: opts.readOnly,
|
|
60
|
-
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
61
|
-
mounts,
|
|
62
|
-
}),
|
|
66
|
+
...workspaceTools(workspaceOptions),
|
|
63
67
|
...sandboxTools(sandbox.pool),
|
|
64
68
|
// Registered whether or not a key exists: the credential is
|
|
65
69
|
// read when a tool is called, so a project that names `exa:*`
|
|
@@ -69,7 +73,7 @@ export async function open(opts) {
|
|
|
69
73
|
],
|
|
70
74
|
skillsAt: SKILLS_MOUNT,
|
|
71
75
|
payloads,
|
|
72
|
-
|
|
76
|
+
resolveFile: (path) => files.within(path),
|
|
73
77
|
});
|
|
74
78
|
}
|
|
75
79
|
catch (err) {
|
|
@@ -105,6 +109,7 @@ export async function open(opts) {
|
|
|
105
109
|
lock,
|
|
106
110
|
close: async () => {
|
|
107
111
|
await teardown(sandbox.pool);
|
|
112
|
+
project.close();
|
|
108
113
|
lock.release();
|
|
109
114
|
},
|
|
110
115
|
};
|
package/dist/main.js
CHANGED
|
@@ -151,5 +151,14 @@ async function usage(name) {
|
|
|
151
151
|
write(` -C, --directory <d> ${dim('Act as if run in <d>.')}`);
|
|
152
152
|
write(`\n${dim(`Start with ${cyan(`${NAME} init`)}, then ${cyan(`${NAME} run`)}.`)}`);
|
|
153
153
|
}
|
|
154
|
+
// `zen memory ls | head` closes the pipe while we are still writing to it, and
|
|
155
|
+
// an unhandled EPIPE turns that ordinary shell idiom into a stack trace. The
|
|
156
|
+
// reader having left is not our error, so we stop quietly.
|
|
157
|
+
process.stdout.on('error', (err) => {
|
|
158
|
+
if (err.code !== 'EPIPE') {
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
process.exit(0);
|
|
162
|
+
});
|
|
154
163
|
process.exitCode = await main(process.argv.slice(2));
|
|
155
164
|
//# sourceMappingURL=main.js.map
|
package/dist/podman.js
CHANGED
|
@@ -2,7 +2,7 @@ import { runProcess, SandboxError } from '@zenera/neo';
|
|
|
2
2
|
import { readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir, platform } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
|
|
5
|
+
import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
|
|
6
6
|
const DEFAULT_MACHINE_CPUS = 2;
|
|
7
7
|
const DEFAULT_MACHINE_MEMORY = 2048;
|
|
8
8
|
/** Starting a virtual machine and pulling an image are both slow on purpose. */
|
|
@@ -73,7 +73,7 @@ async function preflight(opts) {
|
|
|
73
73
|
const present = await call(['image', 'exists', opts.image], 30_000);
|
|
74
74
|
if (present.code !== 0) {
|
|
75
75
|
note(dim(`pulling ${opts.image} — this happens once`));
|
|
76
|
-
const pulled = await stream(engine, ['pull', opts.image], run);
|
|
76
|
+
const pulled = await stream(engine, ['pull', opts.image], run, 'pulling');
|
|
77
77
|
if (pulled.code !== 0) {
|
|
78
78
|
throw sandboxError(`could not pull ${opts.image}`, first(pulled) || 'check the image name and your network');
|
|
79
79
|
}
|
|
@@ -99,7 +99,7 @@ async function install(engine, opts, run) {
|
|
|
99
99
|
throw sandboxError('podman is not installed', how);
|
|
100
100
|
}
|
|
101
101
|
note(dim('installing podman — this takes a few minutes'));
|
|
102
|
-
const done = await stream('brew', ['install', 'podman'], run);
|
|
102
|
+
const done = await stream('brew', ['install', 'podman'], run, 'installing podman');
|
|
103
103
|
if (done.code !== 0) {
|
|
104
104
|
throw sandboxError('could not install podman', first(done) || how);
|
|
105
105
|
}
|
|
@@ -127,7 +127,7 @@ async function machine(engine, call, opts, run) {
|
|
|
127
127
|
const cpus = String(opts.cpus ?? DEFAULT_MACHINE_CPUS);
|
|
128
128
|
const memory = String(opts.memory ?? DEFAULT_MACHINE_MEMORY);
|
|
129
129
|
note(dim(`initialising the podman machine (${cpus} cpus, ${memory} MiB) — once per host`));
|
|
130
|
-
const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run);
|
|
130
|
+
const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run, 'initialising the machine');
|
|
131
131
|
if (created.code !== 0) {
|
|
132
132
|
throw sandboxError('could not create the podman machine', first(created));
|
|
133
133
|
}
|
|
@@ -143,7 +143,7 @@ async function machine(engine, call, opts, run) {
|
|
|
143
143
|
args.push(chosen.Name);
|
|
144
144
|
}
|
|
145
145
|
note(dim('starting the podman machine'));
|
|
146
|
-
const started = await stream(engine, args, run, SLOW_MS);
|
|
146
|
+
const started = await stream(engine, args, run, 'starting the machine', SLOW_MS);
|
|
147
147
|
if (started.code !== 0 && !/already running/i.test(started.stderr)) {
|
|
148
148
|
throw sandboxError('could not start the podman machine', first(started));
|
|
149
149
|
}
|
|
@@ -197,8 +197,8 @@ async function build(engine, spec, run, force = false) {
|
|
|
197
197
|
}
|
|
198
198
|
note(dim(`${force ? 'rebuilding' : 'building'} the sandbox image from ${spec.dockerfile}`));
|
|
199
199
|
note(dim(' the agent runs its commands inside a container, so the image is needed first'));
|
|
200
|
-
note(dim(' this takes a few minutes; later runs reuse it
|
|
201
|
-
const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
|
|
200
|
+
note(dim(' this takes a few minutes; later runs reuse it'));
|
|
201
|
+
const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, 'building the sandbox image', BUILD_MS);
|
|
202
202
|
if (built.code !== 0) {
|
|
203
203
|
throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
|
|
204
204
|
}
|
|
@@ -384,13 +384,20 @@ function children(dir) {
|
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
// ---------------------------------------------------------------------------
|
|
387
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
388
|
+
const FRAME_MS = 100;
|
|
387
389
|
/**
|
|
388
390
|
* Long steps print as they go. A five-minute pull with no output is
|
|
389
391
|
* indistinguishable from a hang, and the one thing worse than waiting is not
|
|
390
392
|
* knowing whether you are waiting.
|
|
393
|
+
*
|
|
394
|
+
* The engine's own output is buffered until it exits, so while the step runs
|
|
395
|
+
* the only honest thing to show is that it is still running: a spinner and a
|
|
396
|
+
* clock, both of which have to move without any event to move them. Off
|
|
397
|
+
* without a terminal — a CI log wants one line per fact, not ten a second.
|
|
391
398
|
*/
|
|
392
|
-
async function stream(bin, args, run, timeoutMs = SLOW_MS) {
|
|
393
|
-
const res = await run(bin, args, { timeoutMs });
|
|
399
|
+
async function stream(bin, args, run, label, timeoutMs = SLOW_MS) {
|
|
400
|
+
const res = await ticking(label, run(bin, args, { timeoutMs }));
|
|
394
401
|
for (const line of res.stderr.split('\n').slice(-3)) {
|
|
395
402
|
if (line.trim()) {
|
|
396
403
|
note(dim(` ${line.trim()}`));
|
|
@@ -398,6 +405,33 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
|
|
|
398
405
|
}
|
|
399
406
|
return res;
|
|
400
407
|
}
|
|
408
|
+
async function ticking(label, work) {
|
|
409
|
+
if (!process.stderr.isTTY) {
|
|
410
|
+
return work;
|
|
411
|
+
}
|
|
412
|
+
const bar = progress();
|
|
413
|
+
const startedAt = Date.now();
|
|
414
|
+
let frame = 0;
|
|
415
|
+
const timer = setInterval(() => {
|
|
416
|
+
const spin = SPINNER[frame++ % SPINNER.length];
|
|
417
|
+
bar.update(dim(` ${spin} ${label} ${elapsed(Date.now() - startedAt)}`));
|
|
418
|
+
}, FRAME_MS);
|
|
419
|
+
timer.unref();
|
|
420
|
+
try {
|
|
421
|
+
return await work;
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
clearInterval(timer);
|
|
425
|
+
bar.done();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function elapsed(ms) {
|
|
429
|
+
const total = Math.floor(ms / 1000);
|
|
430
|
+
const seconds = total % 60;
|
|
431
|
+
return total < 60
|
|
432
|
+
? `${seconds}s`
|
|
433
|
+
: `${Math.floor(total / 60)}m ${String(seconds).padStart(2, '0')}s`;
|
|
434
|
+
}
|
|
401
435
|
function first(res) {
|
|
402
436
|
return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
|
|
403
437
|
}
|
package/dist/projects.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ASSETS_MOUNT, assetsDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
|
|
2
|
-
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { ASSETS_MOUNT, assetsDir, FILES_DIR, MEMORY_MOUNT, memoryDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { paths, readJson, writeJson } from "./home.js";
|
|
5
5
|
import { isStamp } from "./ids.js";
|
|
@@ -47,6 +47,14 @@ export function projectMounts(root, config) {
|
|
|
47
47
|
for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
|
|
48
48
|
mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
|
|
49
49
|
}
|
|
50
|
+
const memory = memoryDir(root, config);
|
|
51
|
+
if (memory) {
|
|
52
|
+
// Created here because the mount is decided before the project is
|
|
53
|
+
// loaded, and podman would refuse a source that does not exist yet.
|
|
54
|
+
const files = join(memory, FILES_DIR);
|
|
55
|
+
mkdirSync(files, { recursive: true });
|
|
56
|
+
mounts.push({ host: realpathSync(files), at: MEMORY_MOUNT, readOnly: true });
|
|
57
|
+
}
|
|
50
58
|
return mounts;
|
|
51
59
|
}
|
|
52
60
|
// ---------------------------------------------------------------------------
|
package/dist/scaffold.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -22,6 +22,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
22
22
|
// What is there is deliberately close to empty: a template full of
|
|
23
23
|
// commented-out options is a template nobody reads and everybody deletes. The
|
|
24
24
|
// one agent works as written, and every other knob is in `docs/`.
|
|
25
|
+
//
|
|
26
|
+
// `SPECIFICATION.md` is the exception, and is written out in full. It states
|
|
27
|
+
// what the project that was just scaffolded is for and what it may do, and it
|
|
28
|
+
// is *true as written* — so the first thing anyone sees is a specification
|
|
29
|
+
// standing next to the files that implement it, which is the shape every
|
|
30
|
+
// change to the project is then made in.
|
|
25
31
|
// ---------------------------------------------------------------------------
|
|
26
32
|
const TEMPLATES = fileURLToPath(new URL('../templates', import.meta.url));
|
|
27
33
|
/** The suffix on a file with `{{...}}` in it, dropped when the file lands. */
|
|
@@ -42,6 +48,11 @@ function ownVersion() {
|
|
|
42
48
|
* it — which is how an optional block leaves nothing behind. Anywhere else it
|
|
43
49
|
* takes a word. A name nothing supplies throws, so a typo in a template is a
|
|
44
50
|
* failing test rather than a `{{provider}}` sitting in somebody's agents.yaml.
|
|
51
|
+
*
|
|
52
|
+
* Only the value is trimmed at the end, never the start, so a fragment that has
|
|
53
|
+
* to be a paragraph of its own carries its own blank line as its first line.
|
|
54
|
+
* That way the template needs no blank line around the placeholder, and dropping
|
|
55
|
+
* the fragment leaves the surrounding paragraphs exactly as they were.
|
|
45
56
|
*/
|
|
46
57
|
function render(text, vars) {
|
|
47
58
|
const value = (name) => {
|
|
@@ -123,6 +134,10 @@ function copyTree(from, dir, rel, opts) {
|
|
|
123
134
|
? render(readFileSync(source, 'utf8'), opts.vars ?? {})
|
|
124
135
|
: readFileSync(source);
|
|
125
136
|
writeFileSync(join(dir, child), body);
|
|
137
|
+
// The bytes are copied, not the file, so a script arrives unrunnable.
|
|
138
|
+
if (child.endsWith('.sh')) {
|
|
139
|
+
chmodSync(join(dir, child), 0o755);
|
|
140
|
+
}
|
|
126
141
|
written.push(child);
|
|
127
142
|
}
|
|
128
143
|
return written;
|
|
@@ -176,7 +191,13 @@ export function scaffold(opts) {
|
|
|
176
191
|
keep: true,
|
|
177
192
|
vars: {
|
|
178
193
|
model: modelSection(opts.model, opts.modelOptions),
|
|
194
|
+
modelRef: opts.model,
|
|
179
195
|
exa: opts.web ? part('exa.yaml') : '',
|
|
196
|
+
// The same capability stated three times, because the grant, the
|
|
197
|
+
// specification of it and the instruction to use it are read by
|
|
198
|
+
// different readers — and a tool nothing says to use is not one.
|
|
199
|
+
web: opts.web ? part('exa.spec.md') : '',
|
|
200
|
+
webPrompt: opts.web ? part('exa.prompt.md') : '',
|
|
180
201
|
version: ownVersion(),
|
|
181
202
|
},
|
|
182
203
|
});
|
package/dist/tui/app.js
CHANGED
|
@@ -35,9 +35,9 @@ function Row({ line }) {
|
|
|
35
35
|
const style = useTheme().line[line.kind];
|
|
36
36
|
return (_jsxs(Box, { flexDirection: "row", marginTop: line.kind === 'you' ? 1 : 0, children: [_jsxs(Text, { color: style.color, dimColor: style.dim, children: [MARK[line.kind], ' '] }), _jsxs(Box, { flexDirection: "column", children: [line.kind === 'agent' ? (_jsx(Answer, { text: line.text })) : (
|
|
37
37
|
// The lead is what the eye scans for down the left edge, so
|
|
38
|
-
// it
|
|
38
|
+
// it carries the weight the row's text does not. Nested
|
|
39
39
|
// rather than one string: Ink dims a whole Text or none of it.
|
|
40
|
-
_jsxs(Text, { children: [line.lead ? _jsx(Text, { color: style.color, children: `${line.lead} ` }) : null, _jsx(Text, { color: style.color, dimColor: style.dim, bold: line.kind === 'you', children: line.text })] })), line.detail ? _jsx(Text, { dimColor: true, children: line.detail }) : null] })] }));
|
|
40
|
+
_jsxs(Text, { children: [line.lead ? _jsx(Text, { color: style.color, bold: true, children: `${line.lead} ` }) : null, _jsx(Text, { color: style.color, dimColor: style.dim, bold: line.kind === 'you', children: line.text })] })), line.detail ? _jsx(Text, { dimColor: true, children: line.detail }) : null] })] }));
|
|
41
41
|
}
|
|
42
42
|
function Answer({ text }) {
|
|
43
43
|
const { stdout } = useStdout();
|
|
@@ -511,11 +511,13 @@ function branchBoxesOf(tools, branches, now, width, lane, share) {
|
|
|
511
511
|
}),
|
|
512
512
|
// Between the calls it made and the one it is making: the reasoning
|
|
513
513
|
// is what got it from one to the other.
|
|
514
|
-
...(gist
|
|
514
|
+
...(gist
|
|
515
|
+
? [{ key: `g:${b.name}`, label: `… ${gist}`, detail: '', thinking: true }]
|
|
516
|
+
: []),
|
|
515
517
|
...live,
|
|
516
518
|
];
|
|
517
519
|
if (!rows.length) {
|
|
518
|
-
rows.push({ key: `w:${b.name}`, label: 'thinking…', detail: '',
|
|
520
|
+
rows.push({ key: `w:${b.name}`, label: 'thinking…', detail: '', thinking: true });
|
|
519
521
|
}
|
|
520
522
|
boxes.push({
|
|
521
523
|
name: b.name,
|
|
@@ -572,18 +574,19 @@ function fitActivity(boxes, trunk, allowance) {
|
|
|
572
574
|
};
|
|
573
575
|
}
|
|
574
576
|
function Branches({ boxes, spin, columns, }) {
|
|
577
|
+
const theme = useTheme();
|
|
575
578
|
return (_jsx(Box, { flexDirection: "column", children: boxes.map((b) => {
|
|
576
579
|
// Ink truncates rather than wraps, so an over-wide rule clips
|
|
577
580
|
// instead of costing the box a row it was not given.
|
|
578
581
|
const used = 3 + b.title.length + 2 + 2 + b.stats.length + 1;
|
|
579
|
-
return (_jsxs(Box, { flexDirection: "column", height: BOX_CHROME + b.rows.length, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '╭─ ' }), _jsx(Text, { color: b.color, children: b.title }), _jsx(Text, { color: b.color, children: ` ${spin}` }), _jsx(Text, { dimColor: true, children: ` ${b.stats} ` }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(0, columns - used)) })] }), b.rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: b.color, children: '│ ' }), _jsx(Text, { dimColor:
|
|
582
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOX_CHROME + b.rows.length, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '╭─ ' }), _jsx(Text, { color: b.color, children: b.title }), _jsx(Text, { color: b.color, children: ` ${spin}` }), _jsx(Text, { dimColor: true, children: ` ${b.stats} ` }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(0, columns - used)) })] }), b.rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: b.color, children: '│ ' }), _jsx(Text, { color: r.thinking ? theme.thinking.color : undefined, dimColor: r.thinking ? theme.thinking.dim : undefined, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), _jsx(Text, { dimColor: true, children: '╰─' })] }, b.name));
|
|
580
583
|
}) }));
|
|
581
584
|
}
|
|
582
585
|
function Activity({ rows, hidden, }) {
|
|
583
586
|
if (!rows.length && !hidden) {
|
|
584
587
|
return null;
|
|
585
588
|
}
|
|
586
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows.length + (hidden ? 1 : 0), overflow: "hidden", children: [rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: r.color,
|
|
589
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows.length + (hidden ? 1 : 0), overflow: "hidden", children: [rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: r.color, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), hidden ? (_jsx(Text, { dimColor: true, children: ` + ${hidden} more ${hidden === 1 ? 'branch' : 'branches'}` })) : null] }));
|
|
587
590
|
}
|
|
588
591
|
/**
|
|
589
592
|
* A stable colour per branch, handed out in order of first sight. Held in a ref
|
|
@@ -635,11 +638,13 @@ function Thinking({ text, columns, rows, spin, live, }) {
|
|
|
635
638
|
// Ink truncates rather than wraps, so an over-wide rule clips instead of
|
|
636
639
|
// costing the block a row it was not given.
|
|
637
640
|
const dashes = Math.max(0, columns - TITLE.length - (live ? 6 : 4));
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
+
const { color, dim } = theme.thinking;
|
|
642
|
+
return (_jsxs(Box, { flexDirection: "column", height: shown.length + THINKING_CHROME, overflow: "hidden", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '╭─ ' }), _jsx(Text, { color: color, children: TITLE }), live ? _jsx(Text, { color: color, children: ` ${spin}` }) : null, _jsx(Text, { dimColor: true, children: ` ${'─'.repeat(dashes)}` })] }), shown.map((row, i) => {
|
|
643
|
+
// Not italic, and not dim: dim italic on a dark terminal is the
|
|
644
|
+
// least legible thing available, and this is meant to be read.
|
|
645
|
+
// Its own hue is what separates it from the tool rows instead.
|
|
641
646
|
const head = HEADING.exec(row);
|
|
642
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, {
|
|
647
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: color, children: '│ ' }), _jsx(Text, { color: color, dimColor: dim, bold: head !== null, children: head ? head[1] : row })] }, i));
|
|
643
648
|
}), _jsx(Text, { dimColor: true, children: '╰─' })] }));
|
|
644
649
|
}
|
|
645
650
|
function Live({ text, columns, rows }) {
|
|
@@ -679,7 +684,10 @@ function Footer({ agent, busy, spin, running, forked, step, model, stats, inflig
|
|
|
679
684
|
: 'thinking';
|
|
680
685
|
// A clip has already ended it with one.
|
|
681
686
|
const status = what.endsWith('…') ? what : `${what}…`;
|
|
682
|
-
|
|
687
|
+
// Nothing of its own in flight means the word above is about reasoning, so
|
|
688
|
+
// it takes the same hue the reasoning block does.
|
|
689
|
+
const musing = !running.length && !forked;
|
|
690
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: agent }), busy ? (_jsxs(Text, { color: musing ? theme.thinking.color : theme.warn, children: [' ', spin, " ", status] })) : null, busy ? _jsx(Text, { dimColor: true, children: ` ${aside}` }) : null] }), inflight ? (_jsxs(Text, { dimColor: true, children: ['this turn'.padEnd(LABEL), tokens(inflight.usage), ` · ${secs(inflight.durationMs)}`] })) : (_jsxs(_Fragment, { children: [stats.turn ? (_jsxs(Text, { dimColor: true, children: ['last turn'.padEnd(LABEL), tokens(stats.turn), stats.durationMs === undefined
|
|
683
691
|
? ''
|
|
684
692
|
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, { dimColor: true, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
|
|
685
693
|
? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}`
|
package/dist/tui/theme.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface Theme {
|
|
|
13
13
|
readonly accent: string;
|
|
14
14
|
/** Read-only badge, busy label. */
|
|
15
15
|
readonly warn: string;
|
|
16
|
+
/**
|
|
17
|
+
* Reasoning. It is not a tool call and it is not the answer, and drawn in
|
|
18
|
+
* the same dim foreground as the rows around it there was nothing to say
|
|
19
|
+
* which — a wall of one weight reads as one thing. Its own hue is what
|
|
20
|
+
* separates the model's thinking from the work it went on to do.
|
|
21
|
+
*/
|
|
22
|
+
readonly thinking: LineStyle;
|
|
16
23
|
/**
|
|
17
24
|
* Branch colours, cycled in order of first sight. A fan-out is the one
|
|
18
25
|
* place where colour carries information rather than decoration: eight
|
package/dist/tui/theme.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// A terminal already has a colour scheme, and it is not ours to replace. The
|
|
5
5
|
// rule here is to name as few colours as possible and to name them by role:
|
|
6
6
|
// the answer is drawn in the terminal's *own* foreground (no colour at all),
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// and only the few things that must stand out — the person's own turn, the
|
|
8
|
+
// agent name, what it is reasoning about, a warning, an error — take a colour.
|
|
9
9
|
//
|
|
10
10
|
// That alone fixes most of it. `white` was the bug: it is legible on exactly
|
|
11
11
|
// one kind of background, and half the world runs the other kind. `gray` was
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// unreadable on dark and invisible on paper. What is left is the handful of
|
|
14
14
|
// accents that ANSI *does* let a light theme get wrong, so those swap.
|
|
15
15
|
//
|
|
16
|
+
// Dim is spent on *chrome* — box rules, clocks, the footer's numbers — and no
|
|
17
|
+
// longer on content. Tool rows are most of what is on screen, and dimming them
|
|
18
|
+
// put the bulk of the transcript one step from unreadable: the whole thing read
|
|
19
|
+
// as too dark. They are content, so they get the foreground like content does.
|
|
20
|
+
//
|
|
16
21
|
// Note what is *not* here: no hex, no 256-colour ramps, no attempt at a brand.
|
|
17
22
|
// A palette that ignores the user's scheme is worse on both schemes than one
|
|
18
23
|
// that mostly defers to it.
|
|
@@ -22,16 +27,17 @@ const DARK = {
|
|
|
22
27
|
line: {
|
|
23
28
|
you: { color: 'cyan' },
|
|
24
29
|
agent: {},
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
tool: {
|
|
29
|
-
note: { color: 'cyan'
|
|
30
|
+
// Never dim, and never `gray`: bright black is already the faintest
|
|
31
|
+
// colour a terminal has, and either one puts the bulk of the
|
|
32
|
+
// transcript a step from unreadable.
|
|
33
|
+
tool: {},
|
|
34
|
+
note: { color: 'cyan' },
|
|
30
35
|
error: { color: 'red' },
|
|
31
36
|
},
|
|
32
37
|
accent: 'cyan',
|
|
33
38
|
warn: 'yellow',
|
|
34
|
-
|
|
39
|
+
thinking: { color: 'magenta' },
|
|
40
|
+
lanes: ['cyan', 'green', 'yellow', 'blue', 'red', 'magenta'],
|
|
35
41
|
};
|
|
36
42
|
// On a light background `cyan` and `yellow` are barely darker than the paper.
|
|
37
43
|
// Dimmed default foreground and `blue`/`magenta` are the same information,
|
|
@@ -41,14 +47,15 @@ const LIGHT = {
|
|
|
41
47
|
line: {
|
|
42
48
|
you: { color: 'blue' },
|
|
43
49
|
agent: {},
|
|
44
|
-
tool: {
|
|
45
|
-
note: { color: 'blue'
|
|
50
|
+
tool: {},
|
|
51
|
+
note: { color: 'blue' },
|
|
46
52
|
error: { color: 'red' },
|
|
47
53
|
},
|
|
48
54
|
accent: 'blue',
|
|
49
55
|
warn: 'magenta',
|
|
56
|
+
thinking: { color: 'magenta' },
|
|
50
57
|
// No cyan or yellow: on paper they are barely darker than the paper.
|
|
51
|
-
lanes: ['
|
|
58
|
+
lanes: ['blue', 'green', 'red', 'magenta'],
|
|
52
59
|
};
|
|
53
60
|
export const THEMES = { dark: DARK, light: LIGHT };
|
|
54
61
|
export function parseChoice(value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.12",
|
|
4
4
|
"description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@inkjs/ui": "^2.0.0",
|
|
52
52
|
"ink": "^7.1.1",
|
|
53
53
|
"react": "^19.2.8",
|
|
54
|
-
"@zenera/neo": "^1.1.
|
|
54
|
+
"@zenera/neo": "^1.1.12",
|
|
55
55
|
"@anthropic-ai/sdk": "^0.120.0",
|
|
56
56
|
"@google/genai": "^2.18.0",
|
|
57
57
|
"@openrouter/sdk": "^1.2.80",
|