@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.
Files changed (35) hide show
  1. package/README.md +20 -0
  2. package/dist/banner.js +69 -47
  3. package/dist/commands/index.js +3 -0
  4. package/dist/commands/init.js +8 -4
  5. package/dist/commands/memory.d.ts +3 -0
  6. package/dist/commands/memory.js +375 -0
  7. package/dist/engine.js +14 -9
  8. package/dist/main.js +9 -0
  9. package/dist/podman.js +43 -9
  10. package/dist/projects.js +10 -2
  11. package/dist/scaffold.js +22 -1
  12. package/dist/tui/app.js +19 -11
  13. package/dist/tui/theme.d.ts +7 -0
  14. package/dist/tui/theme.js +18 -11
  15. package/package.json +2 -2
  16. package/templates/editor/.github/copilot-instructions.md +151 -43
  17. package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
  18. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +7 -4
  19. package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
  20. package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
  21. package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
  22. package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
  23. package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
  24. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +16 -16
  25. package/templates/editor/.github/skills/zen-rag-schema/SKILL.md +2 -2
  26. package/templates/editor/.vscode/settings.json +4 -2
  27. package/templates/parts/exa.prompt.md.tmpl +4 -0
  28. package/templates/parts/exa.spec.md.tmpl +2 -0
  29. package/templates/project/INSTRUCTIONS.md +8 -0
  30. package/templates/project/SPECIFICATION.md.tmpl +47 -0
  31. package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
  32. package/templates/project/agents.yaml.tmpl +4 -0
  33. package/templates/project/gitignore +7 -1
  34. package/templates/project/scripts/_setup.sh +101 -0
  35. package/templates/project/SPECIFICATION.md +0 -6
package/dist/engine.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
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
- memory,
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 until the Dockerfile changes'));
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 keeps the row's colour without its dimming. Nested
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 ? [{ key: `g:${b.name}`, label: `… ${gist}`, detail: '', past: true }] : []),
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: '', past: false });
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: true, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), _jsx(Text, { dimColor: true, children: '╰─' })] }, b.name));
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, dimColor: r.color === undefined, 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] }));
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
- 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, { dimColor: true, children: TITLE }), live ? _jsx(Text, { color: theme.warn, children: ` ${spin}` }) : null, _jsx(Text, { dimColor: true, children: ` ${'─'.repeat(dashes)}` })] }), shown.map((row, i) => {
639
- // Not italic: dim italic on a dark terminal is the least
640
- // legible thing available, and this is meant to be read.
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, { dimColor: true, children: '│ ' }), _jsx(Text, { dimColor: true, bold: head !== null, children: head ? head[1] : row })] }, i));
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
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, dimColor: true, children: agent }), busy ? (_jsxs(Text, { 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
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'}`
@@ -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
- // asides are drawn dim, and only the few things that must stand out —
8
- // the person's own turn, the agent name, a warning, an error — take a colour.
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
- // Dim alone, never dim *and* `gray`: bright black is already the
26
- // faintest colour a terminal has, and dimming it again puts the bulk
27
- // of the transcript a step from unreadable.
28
- tool: { dim: true },
29
- note: { color: 'cyan', dim: true },
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
- lanes: ['magenta', 'cyan', 'green', 'yellow', 'blue', 'red'],
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: { dim: true },
45
- note: { color: 'blue', dim: true },
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: ['magenta', 'blue', 'green', 'red'],
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.11",
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.11",
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",