@zenera/cli 1.1.10 → 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 +69 -15
- package/dist/banner.js +69 -47
- package/dist/cache.d.ts +98 -0
- package/dist/cache.js +301 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +35 -11
- package/dist/commands/cache.d.ts +7 -0
- package/dist/commands/cache.js +245 -0
- package/dist/commands/check.js +6 -3
- package/dist/commands/index.js +5 -0
- package/dist/commands/init.js +8 -4
- package/dist/commands/key.js +68 -14
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.js +375 -0
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/engine.js +14 -9
- package/dist/home.d.ts +2 -2
- package/dist/home.js +2 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +105 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.js +11 -0
- package/dist/main.js +9 -0
- package/dist/podman.js +43 -9
- package/dist/projects.js +10 -2
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/scaffold.js +22 -1
- package/dist/term.d.ts +23 -2
- package/dist/term.js +215 -8
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +478 -54
- package/dist/tui/theme.d.ts +13 -2
- package/dist/tui/theme.js +26 -13
- package/dist/tui/wrap.d.ts +92 -0
- package/dist/tui/wrap.js +147 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +87 -2
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +200 -55
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +11 -4
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
- package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
- package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
- 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 +575 -0
- package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +4 -4
- 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/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/resolve.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface Target {
|
|
|
13
13
|
session: SessionPaths;
|
|
14
14
|
/** true when this call created the session */
|
|
15
15
|
created: boolean;
|
|
16
|
+
/** absolute path the agent's file tools are rooted at */
|
|
17
|
+
workspace: string;
|
|
18
|
+
/** true when that directory is the session's own, made by this call */
|
|
19
|
+
freshWorkspace: boolean;
|
|
16
20
|
}
|
|
17
21
|
export declare function project(want: Wanted): Promise<Projects.Project>;
|
|
18
22
|
export declare function target(want: Wanted): Promise<Target>;
|
package/dist/resolve.js
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { stamp } from "./ids.js";
|
|
4
4
|
import * as Projects from "./projects.js";
|
|
5
|
-
import { createSession, listSessions, requireSession, sessionPaths, } from "./session.js";
|
|
6
|
-
import { ago, choose, confirm, dim, isInteractive, usageError, warn, yellow } from "./term.js";
|
|
5
|
+
import { createSession, listSessions, readSessionMeta, requireSession, sessionPaths, } from "./session.js";
|
|
6
|
+
import { ago, bold, choose, confirm, cyan, dim, isInteractive, usageError, warn, yellow, } from "./term.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Project
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -36,44 +36,70 @@ export async function target(want) {
|
|
|
36
36
|
throw usageError('--session and --new contradict each other');
|
|
37
37
|
}
|
|
38
38
|
if (want.session) {
|
|
39
|
-
return
|
|
40
|
-
project: found,
|
|
41
|
-
session: requireSession(dir, want.session),
|
|
42
|
-
created: false,
|
|
43
|
-
};
|
|
39
|
+
return await resumed(found, requireSession(dir, want.session));
|
|
44
40
|
}
|
|
45
41
|
if (!want.fresh) {
|
|
46
42
|
const existing = await pickExisting(dir);
|
|
47
43
|
if (existing) {
|
|
48
|
-
return
|
|
44
|
+
return await resumed(found, existing);
|
|
49
45
|
}
|
|
50
46
|
}
|
|
51
|
-
|
|
47
|
+
const made = await create(dir, want);
|
|
48
|
+
return {
|
|
49
|
+
project: found,
|
|
50
|
+
session: made.session,
|
|
51
|
+
created: true,
|
|
52
|
+
workspace: made.workspace,
|
|
53
|
+
freshWorkspace: contains(made.session.dir, made.workspace),
|
|
54
|
+
};
|
|
52
55
|
}
|
|
56
|
+
/** A session that already exists brings its own workspace; it is never re-asked. */
|
|
57
|
+
async function resumed(project, session) {
|
|
58
|
+
const meta = await readSessionMeta(session);
|
|
59
|
+
return {
|
|
60
|
+
project,
|
|
61
|
+
session,
|
|
62
|
+
created: false,
|
|
63
|
+
workspace: resolve(meta.workspace),
|
|
64
|
+
freshWorkspace: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Past this many, you are looking for a session by name rather than scrolling
|
|
69
|
+
* to it — and `--session <id>` is how you say a name.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_SESSIONS = 25;
|
|
53
72
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
73
|
+
* Starting fresh is first, and it is the default: `zen run` followed by Enter
|
|
74
|
+
* should be a new conversation in a new directory, because that is what the
|
|
75
|
+
* command is asked for most often and the only answer that cannot go wrong.
|
|
76
|
+
* Everything already here is offered under it, newest first.
|
|
57
77
|
*
|
|
58
78
|
* A session that has never run has nothing to continue — it is indistinguishable
|
|
59
79
|
* from a fresh one, so it is left out rather than offered as a choice.
|
|
60
80
|
*/
|
|
61
81
|
async function pickExisting(projectDir) {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
82
|
+
const all = (await listSessions(projectDir)).filter((s) => s.runs > 0 || s.busy);
|
|
83
|
+
if (all.length === 0) {
|
|
64
84
|
return undefined;
|
|
65
85
|
}
|
|
66
86
|
if (!isInteractive()) {
|
|
67
|
-
return sessionPaths(projectDir,
|
|
87
|
+
return sessionPaths(projectDir, all[0].id);
|
|
68
88
|
}
|
|
89
|
+
const sessions = all.slice(0, MAX_SESSIONS);
|
|
69
90
|
const choice = await choose('Session', [
|
|
91
|
+
{
|
|
92
|
+
key: '0',
|
|
93
|
+
label: bold(cyan('New session…')),
|
|
94
|
+
detail: dim('a fresh conversation, in a directory of its own'),
|
|
95
|
+
value: undefined,
|
|
96
|
+
},
|
|
70
97
|
...sessions.map((s) => ({
|
|
71
98
|
label: s.title ?? s.id,
|
|
72
99
|
detail: `${s.id} ${ago(s.lastRunAt ?? s.createdAt)} ` +
|
|
73
100
|
`${s.runs} run${s.runs === 1 ? '' : 's'}${s.busy ? yellow(' running') : ''}`,
|
|
74
101
|
value: s.id,
|
|
75
102
|
})),
|
|
76
|
-
{ key: '0', label: dim('New session…'), value: undefined },
|
|
77
103
|
]);
|
|
78
104
|
return choice ? sessionPaths(projectDir, choice) : undefined;
|
|
79
105
|
}
|
|
@@ -86,7 +112,7 @@ async function create(projectDir, want) {
|
|
|
86
112
|
const id = stamp();
|
|
87
113
|
const planned = sessionPaths(projectDir, id);
|
|
88
114
|
const workspace = await chooseWorkspace(planned, want);
|
|
89
|
-
return createSession(projectDir, id, workspace);
|
|
115
|
+
return { session: createSession(projectDir, id, workspace), workspace };
|
|
90
116
|
}
|
|
91
117
|
// ---------------------------------------------------------------------------
|
|
92
118
|
// Workspace
|
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/term.d.ts
CHANGED
|
@@ -62,8 +62,29 @@ export interface Choice<T> {
|
|
|
62
62
|
/** Listed and picked by this instead of its position, e.g. `0` for an escape hatch. */
|
|
63
63
|
key?: string;
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
export interface ChooseOptions {
|
|
66
|
+
/**
|
|
67
|
+
* The choice Enter takes, as an index. It is marked `*`, because a default
|
|
68
|
+
* nobody can see is a default nobody uses.
|
|
69
|
+
*/
|
|
70
|
+
initial?: number;
|
|
71
|
+
/** How many rows stay on screen at once; anything beyond them scrolls. */
|
|
72
|
+
window?: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A list, arrow-driven where the terminal allows it and numbered where it does
|
|
76
|
+
* not. Both forms agree on the two things that matter: every row has a stable
|
|
77
|
+
* number you can type, and one of them is the default Enter takes.
|
|
78
|
+
*/
|
|
79
|
+
export declare function choose<T>(title: string, choices: readonly Choice<T>[], options?: ChooseOptions): Promise<T>;
|
|
80
|
+
/** A chunk of raw input, split into one string per keystroke. */
|
|
81
|
+
export declare function keysIn(data: string): Generator<string>;
|
|
82
|
+
/**
|
|
83
|
+
* Cut to a visible width, carrying the style codes over. Styling is invisible
|
|
84
|
+
* to the terminal's column count but not to `String.length`, so a naive slice
|
|
85
|
+
* either cuts too early or leaves a colour turned on.
|
|
86
|
+
*/
|
|
87
|
+
export declare function cut(s: string, max: number): string;
|
|
67
88
|
/** Piped input, or undefined when stdin is a terminal (i.e. nobody piped). */
|
|
68
89
|
export declare function readStdin(): Promise<string | undefined>;
|
|
69
90
|
export declare function ago(iso: string | undefined): string;
|
package/dist/term.js
CHANGED
|
@@ -173,8 +173,14 @@ export async function confirm(question, fallback = false) {
|
|
|
173
173
|
}
|
|
174
174
|
return /^y(es)?$/i.test(answer);
|
|
175
175
|
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
176
|
+
/** Rows kept on screen when the caller does not say. */
|
|
177
|
+
const WINDOW = 10;
|
|
178
|
+
/**
|
|
179
|
+
* A list, arrow-driven where the terminal allows it and numbered where it does
|
|
180
|
+
* not. Both forms agree on the two things that matter: every row has a stable
|
|
181
|
+
* number you can type, and one of them is the default Enter takes.
|
|
182
|
+
*/
|
|
183
|
+
export async function choose(title, choices, options = {}) {
|
|
178
184
|
if (choices.length === 0) {
|
|
179
185
|
throw usageError(`nothing to choose from: ${title}`);
|
|
180
186
|
}
|
|
@@ -182,20 +188,43 @@ export async function choose(title, choices) {
|
|
|
182
188
|
return choices[0].value;
|
|
183
189
|
}
|
|
184
190
|
requireTty(title, 'the matching flag');
|
|
185
|
-
|
|
191
|
+
const initial = Math.min(Math.max(options.initial ?? 0, 0), choices.length - 1);
|
|
186
192
|
// A keyed choice is out of the sequence, so it does not consume a number.
|
|
187
193
|
let seq = 0;
|
|
188
194
|
const keys = choices.map((c) => c.key ?? String(++seq));
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
// A terminal that cannot move its own cursor gets the list printed once and
|
|
196
|
+
// answers with a number; anything else gets the arrows.
|
|
197
|
+
const dumb = typeof process.stdin.setRawMode !== 'function' ||
|
|
198
|
+
(process.env['TERM'] ?? 'dumb') === 'dumb';
|
|
199
|
+
if (dumb) {
|
|
200
|
+
return await numbered(title, choices, keys, initial);
|
|
201
|
+
}
|
|
202
|
+
return await pick(title, choices, keys, initial, options.window ?? WINDOW);
|
|
203
|
+
}
|
|
204
|
+
/** The rows themselves, column-aligned once and then reused every frame. */
|
|
205
|
+
function listOf(choices, keys, initial) {
|
|
206
|
+
// A key that is not where counting would have put it is the one thing on
|
|
207
|
+
// the row you have to be told, so it is the one that takes a colour.
|
|
208
|
+
return table(choices.map((c, i) => [
|
|
209
|
+
`${i === initial ? cyan('*') : ' '} ` +
|
|
210
|
+
(c.key === undefined ? dim(`${keys[i]}.`) : cyan(`${keys[i]}.`)),
|
|
211
|
+
c.label,
|
|
212
|
+
dim(c.detail ?? ''),
|
|
213
|
+
]));
|
|
214
|
+
}
|
|
215
|
+
/** The fallback: print everything, read a number. No raw mode, no repainting. */
|
|
216
|
+
async function numbered(title, choices, keys, initial) {
|
|
217
|
+
note(bold(title));
|
|
218
|
+
for (const line of listOf(choices, keys, initial)) {
|
|
219
|
+
note(` ${line}`);
|
|
192
220
|
}
|
|
193
221
|
const extra = keys.filter((_, i) => choices[i].key !== undefined);
|
|
194
|
-
const
|
|
222
|
+
const counted = keys.length - extra.length;
|
|
223
|
+
const hint = `Enter a number between 1 and ${counted}` +
|
|
195
224
|
(extra.length ? `, or ${extra.join(' / ')}` : '') +
|
|
196
225
|
'.';
|
|
197
226
|
for (;;) {
|
|
198
|
-
const answer = await ask('Choose',
|
|
227
|
+
const answer = await ask('Choose', keys[initial]);
|
|
199
228
|
const at = keys.indexOf(answer);
|
|
200
229
|
if (at >= 0) {
|
|
201
230
|
return choices[at].value;
|
|
@@ -203,6 +232,184 @@ export async function choose(title, choices) {
|
|
|
203
232
|
note(dim(hint));
|
|
204
233
|
}
|
|
205
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* The arrow-driven form. A long list is shown through a window rather than all
|
|
237
|
+
* at once: twenty-five sessions printed in full push the question itself off
|
|
238
|
+
* the screen, and the question is the part being answered.
|
|
239
|
+
*
|
|
240
|
+
* The frame is a fixed number of lines so it can be erased by counting them
|
|
241
|
+
* back, which is also why every line is cut to the terminal's width — a row
|
|
242
|
+
* that wraps is two rows the erase does not know about. When it is over the
|
|
243
|
+
* whole frame is replaced by the one line saying what was chosen.
|
|
244
|
+
*/
|
|
245
|
+
async function pick(title, choices, keys, initial, window) {
|
|
246
|
+
const out = process.stderr;
|
|
247
|
+
const stdin = process.stdin;
|
|
248
|
+
const rows = listOf(choices, keys, initial);
|
|
249
|
+
const shown = Math.max(1, Math.min(window, choices.length, (out.rows ?? 24) - 3));
|
|
250
|
+
const height = shown + 2;
|
|
251
|
+
let at = initial;
|
|
252
|
+
let top = Math.min(Math.max(0, at - shown + 1), Math.max(0, choices.length - shown));
|
|
253
|
+
let typed = '';
|
|
254
|
+
let painted = false;
|
|
255
|
+
let done = false;
|
|
256
|
+
const columns = Math.max(20, (out.columns ?? 80) - 1);
|
|
257
|
+
const paint = () => {
|
|
258
|
+
if (painted) {
|
|
259
|
+
out.write(`\u001b[${height}A`);
|
|
260
|
+
}
|
|
261
|
+
const lines = [bold(title)];
|
|
262
|
+
for (let i = top; i < top + shown; i++) {
|
|
263
|
+
lines.push(`${i === at ? cyan('\u276f') : ' '} ${rows[i]}`);
|
|
264
|
+
}
|
|
265
|
+
lines.push(dim(' \u2191\u2193 move \u00b7 enter choose \u00b7 esc cancel' +
|
|
266
|
+
(typed ? ` \u00b7 ${typed}` : '') +
|
|
267
|
+
(choices.length > shown ? ` ${at + 1}/${choices.length}` : '')));
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
out.write(`\u001b[2K${cut(line, columns)}\n`);
|
|
270
|
+
}
|
|
271
|
+
painted = true;
|
|
272
|
+
};
|
|
273
|
+
const goto = (i) => {
|
|
274
|
+
at = Math.min(Math.max(i, 0), choices.length - 1);
|
|
275
|
+
top = Math.min(Math.max(top, at - shown + 1), at);
|
|
276
|
+
paint();
|
|
277
|
+
};
|
|
278
|
+
const move = (delta) => {
|
|
279
|
+
typed = '';
|
|
280
|
+
goto(at + delta);
|
|
281
|
+
};
|
|
282
|
+
return await new Promise((settle, reject) => {
|
|
283
|
+
const wasRaw = stdin.isRaw;
|
|
284
|
+
stdin.setRawMode(true);
|
|
285
|
+
stdin.resume();
|
|
286
|
+
stdin.setEncoding('utf8');
|
|
287
|
+
out.write('\u001b[?25l');
|
|
288
|
+
const close = (summary) => {
|
|
289
|
+
stdin.off('data', onData);
|
|
290
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
291
|
+
if (!wasRaw) {
|
|
292
|
+
stdin.pause();
|
|
293
|
+
}
|
|
294
|
+
// The list has served its purpose; the answer has not.
|
|
295
|
+
out.write(`\u001b[${height}A\u001b[0J\u001b[?25h`);
|
|
296
|
+
out.write(`${cut(`${bold(title)} ${cyan('\u276f')} ${summary}`, columns)}\n`);
|
|
297
|
+
};
|
|
298
|
+
const jump = (digit) => {
|
|
299
|
+
const next = typed + digit;
|
|
300
|
+
const to = keys.indexOf(next);
|
|
301
|
+
typed = to >= 0 ? next : digit;
|
|
302
|
+
const found = to >= 0 ? to : keys.indexOf(digit);
|
|
303
|
+
goto(found >= 0 ? found : at);
|
|
304
|
+
};
|
|
305
|
+
const onData = (data) => {
|
|
306
|
+
// One read can carry several keystrokes — held keys, a paste, or a
|
|
307
|
+
// fast typist — so it is split into keys before any of it is acted
|
|
308
|
+
// on. Reading the chunk as one key loses everything after the first.
|
|
309
|
+
for (const key of keysIn(data)) {
|
|
310
|
+
if (done) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
act(key);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const act = (key) => {
|
|
317
|
+
switch (key) {
|
|
318
|
+
case '\r':
|
|
319
|
+
case '\n': {
|
|
320
|
+
const picked = choices[at];
|
|
321
|
+
done = true;
|
|
322
|
+
close(picked.detail ? `${picked.label} ${dim(picked.detail)}` : picked.label);
|
|
323
|
+
settle(picked.value);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
case '\u0003':
|
|
327
|
+
case '\u001b':
|
|
328
|
+
case 'q':
|
|
329
|
+
done = true;
|
|
330
|
+
close(dim('cancelled'));
|
|
331
|
+
reject(usageError('cancelled'));
|
|
332
|
+
return;
|
|
333
|
+
case '\u001b[A':
|
|
334
|
+
case '\u001bOA':
|
|
335
|
+
case 'k':
|
|
336
|
+
return move(-1);
|
|
337
|
+
case '\u001b[B':
|
|
338
|
+
case '\u001bOB':
|
|
339
|
+
case 'j':
|
|
340
|
+
return move(1);
|
|
341
|
+
case '\u001b[5~':
|
|
342
|
+
return move(-shown);
|
|
343
|
+
case '\u001b[6~':
|
|
344
|
+
return move(shown);
|
|
345
|
+
case '\u001b[H':
|
|
346
|
+
case '\u001b[1~':
|
|
347
|
+
return move(-choices.length);
|
|
348
|
+
case '\u001b[F':
|
|
349
|
+
case '\u001b[4~':
|
|
350
|
+
return move(choices.length);
|
|
351
|
+
case '\u007f':
|
|
352
|
+
case '\u0008':
|
|
353
|
+
typed = '';
|
|
354
|
+
return paint();
|
|
355
|
+
default:
|
|
356
|
+
// Typing digits is how a two-digit row is reached without
|
|
357
|
+
// twelve keystrokes, so they accumulate until one matches.
|
|
358
|
+
if (key >= '0' && key <= '9') {
|
|
359
|
+
jump(key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
stdin.on('data', onData);
|
|
364
|
+
paint();
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** Escape sequences a keyboard sends: CSI (`ESC [ … final`) and SS3 (`ESC O x`). */
|
|
368
|
+
const SEQUENCE = /^\u001b(?:\[[0-9;]*[A-Za-z~]|O[A-Za-z])/;
|
|
369
|
+
/** A chunk of raw input, split into one string per keystroke. */
|
|
370
|
+
export function* keysIn(data) {
|
|
371
|
+
let i = 0;
|
|
372
|
+
while (i < data.length) {
|
|
373
|
+
if (data[i] === '\u001b') {
|
|
374
|
+
const found = SEQUENCE.exec(data.slice(i));
|
|
375
|
+
if (found) {
|
|
376
|
+
yield found[0];
|
|
377
|
+
i += found[0].length;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
yield data[i++];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Cut to a visible width, carrying the style codes over. Styling is invisible
|
|
386
|
+
* to the terminal's column count but not to `String.length`, so a naive slice
|
|
387
|
+
* either cuts too early or leaves a colour turned on.
|
|
388
|
+
*/
|
|
389
|
+
export function cut(s, max) {
|
|
390
|
+
if (width(s).length <= max) {
|
|
391
|
+
return s;
|
|
392
|
+
}
|
|
393
|
+
let out = '';
|
|
394
|
+
let seen = 0;
|
|
395
|
+
for (let i = 0; i < s.length;) {
|
|
396
|
+
if (s[i] === '\u001b') {
|
|
397
|
+
const end = s.indexOf('m', i);
|
|
398
|
+
if (end < 0) {
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
out += s.slice(i, end + 1);
|
|
402
|
+
i = end + 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (seen === max - 1) {
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
out += s[i++];
|
|
409
|
+
seen++;
|
|
410
|
+
}
|
|
411
|
+
return `${out}\u2026\u001b[0m`;
|
|
412
|
+
}
|
|
206
413
|
// ---------------------------------------------------------------------------
|
|
207
414
|
// stdin
|
|
208
415
|
// ---------------------------------------------------------------------------
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ export interface AppOptions {
|
|
|
3
3
|
readOnly: boolean;
|
|
4
4
|
/** `dark`, `light` or `auto`. Unset means `auto`. */
|
|
5
5
|
theme?: string;
|
|
6
|
+
/**
|
|
7
|
+
* How this session was arrived at. The two questions before the first
|
|
8
|
+
* frame can be answered with two keystrokes, so the header repeats which
|
|
9
|
+
* way they went — a run that resumed when you meant to start over should
|
|
10
|
+
* say so before the first turn, not after it.
|
|
11
|
+
*/
|
|
12
|
+
started?: {
|
|
13
|
+
created: boolean;
|
|
14
|
+
freshWorkspace: boolean;
|
|
15
|
+
};
|
|
6
16
|
}
|
|
7
17
|
export declare function start(engine: Engine.Engine, options: AppOptions): Promise<void>;
|
|
8
18
|
//# sourceMappingURL=app.d.ts.map
|