@cruxy/cli 0.9.0 → 0.11.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/dist/approval/classify.js +21 -0
- package/dist/approval/policy.js +6 -0
- package/dist/approval/prompt.js +8 -29
- package/dist/approval/types.d.ts +5 -0
- package/dist/cli/commands/rollback.js +45 -0
- package/dist/cli/commands/test.d.ts +9 -0
- package/dist/cli/commands/test.js +47 -0
- package/dist/cli/program.js +2 -0
- package/dist/cli/repl.d.ts +5 -0
- package/dist/cli/repl.js +17 -0
- package/dist/cli/session-factory.js +6 -2
- package/dist/components/autocomplete.d.ts +32 -0
- package/dist/components/autocomplete.js +50 -0
- package/dist/components/frame.d.ts +25 -0
- package/dist/components/frame.js +49 -0
- package/dist/components/fuzzy.d.ts +61 -0
- package/dist/components/fuzzy.js +174 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +6 -0
- package/dist/components/input.d.ts +78 -0
- package/dist/components/input.js +111 -0
- package/dist/components/keys.d.ts +48 -0
- package/dist/components/keys.js +105 -0
- package/dist/components/select.d.ts +28 -0
- package/dist/components/select.js +69 -0
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +20 -0
- package/dist/errors/constructors.d.ts +12 -0
- package/dist/errors/constructors.js +31 -0
- package/dist/errors/types.d.ts +4 -0
- package/dist/errors/types.js +10 -0
- package/dist/onboarding/io.d.ts +3 -2
- package/dist/onboarding/io.js +35 -81
- package/dist/testing/detect.d.ts +3 -0
- package/dist/testing/detect.js +44 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/parse.d.ts +33 -0
- package/dist/testing/parse.js +137 -0
- package/dist/testing/run-tests-tool.d.ts +42 -0
- package/dist/testing/run-tests-tool.js +128 -0
- package/dist/testing/runner.d.ts +26 -0
- package/dist/testing/runner.js +124 -0
- package/dist/testing/types.d.ts +61 -0
- package/dist/testing/types.js +7 -0
- package/dist/tools/registry.js +3 -0
- package/dist/tools/types.d.ts +2 -2
- package/package.json +1 -1
|
@@ -16,6 +16,8 @@ export function classify(action, cwd) {
|
|
|
16
16
|
return fileRequest(action, patchHasDelete(action) ? "destructive" : "mutate", root);
|
|
17
17
|
case "shell":
|
|
18
18
|
return shellRequest(action, root);
|
|
19
|
+
case "test":
|
|
20
|
+
return testRequest(action, root);
|
|
19
21
|
case "vcs":
|
|
20
22
|
return vcsRequest(action, root);
|
|
21
23
|
case "rollback":
|
|
@@ -69,6 +71,25 @@ function shellRequest(action, root) {
|
|
|
69
71
|
cwd: root,
|
|
70
72
|
};
|
|
71
73
|
}
|
|
74
|
+
// ── test (run the project's test suite, C.13) ──────────────────────────────────
|
|
75
|
+
/**
|
|
76
|
+
* A test-suite execution. Destructive tier — a test script is arbitrary code
|
|
77
|
+
* from package.json — but grantable at the tightest possible scope: the exact
|
|
78
|
+
* command string. That is precisely what an edit→re-run iteration needs
|
|
79
|
+
* (approve once, re-run the same suite freely) without widening to a program
|
|
80
|
+
* prefix the way a shell grant would.
|
|
81
|
+
*/
|
|
82
|
+
function testRequest(action, root) {
|
|
83
|
+
const command = (action.command ?? "").trim();
|
|
84
|
+
return {
|
|
85
|
+
action,
|
|
86
|
+
tier: "destructive",
|
|
87
|
+
scope: command === "" ? { kind: "none" } : { kind: "shell-exact", command },
|
|
88
|
+
summary: `run tests: ${command}`,
|
|
89
|
+
targets: [],
|
|
90
|
+
cwd: root,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
72
93
|
// ── vcs (open pull request) ─────────────────────────────────────────────────────
|
|
73
94
|
/**
|
|
74
95
|
* A pull-request publish (C.15): branch → commit → push → open PR. Always
|
package/dist/approval/policy.js
CHANGED
|
@@ -38,6 +38,12 @@ export function scopeCovers(scope, request) {
|
|
|
38
38
|
const tokens = commandTokens(request.action.command ?? "");
|
|
39
39
|
return tokens !== null && tokens[0] === scope.token;
|
|
40
40
|
}
|
|
41
|
+
if (scope.kind === "shell-exact") {
|
|
42
|
+
// Test grants (C.13): the exact command string, test actions only — a
|
|
43
|
+
// grant for `pnpm test` can never cover run_command or any other command.
|
|
44
|
+
return (request.action.kind === "test" &&
|
|
45
|
+
(request.action.command ?? "").trim() === scope.command);
|
|
46
|
+
}
|
|
41
47
|
// file-subtree
|
|
42
48
|
return (request.targets.length > 0 &&
|
|
43
49
|
request.targets.every((t) => isInside(scope.root, t)));
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
+
import { readSingleKey } from "../components/input.js";
|
|
3
4
|
import { renderActionPreview } from "../render/diff.js";
|
|
4
5
|
/**
|
|
5
6
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -44,9 +45,9 @@ export function render(request, color) {
|
|
|
44
45
|
lines.push(choices(request.scope, c));
|
|
45
46
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
46
47
|
}
|
|
47
|
-
/** The action detail: a diff for file actions, the command + cwd for shell. */
|
|
48
|
+
/** The action detail: a diff for file actions, the command + cwd for shell/test. */
|
|
48
49
|
function detail(request, c) {
|
|
49
|
-
if (request.action.kind === "shell") {
|
|
50
|
+
if (request.action.kind === "shell" || request.action.kind === "test") {
|
|
50
51
|
return [
|
|
51
52
|
` ${c.dim("$")} ${request.action.command ?? ""}`,
|
|
52
53
|
` ${c.dim(`in ${request.cwd}`)}`,
|
|
@@ -66,6 +67,8 @@ function choices(scope, c) {
|
|
|
66
67
|
function scopeLabel(scope) {
|
|
67
68
|
if (scope.kind === "shell-prefix")
|
|
68
69
|
return `${scope.token} commands`;
|
|
70
|
+
if (scope.kind === "shell-exact")
|
|
71
|
+
return `re-runs of \`${scope.command}\``;
|
|
69
72
|
if (scope.kind === "file-subtree")
|
|
70
73
|
return `changes under ${path.basename(scope.root)}/`;
|
|
71
74
|
return null;
|
|
@@ -75,37 +78,13 @@ function scopeLabel(scope) {
|
|
|
75
78
|
export function defaultPromptIO(color) {
|
|
76
79
|
return {
|
|
77
80
|
write: (text) => void process.stderr.write(text),
|
|
78
|
-
|
|
81
|
+
// The shared U.7 raw-mode reader: one keypress, cooked mode always
|
|
82
|
+
// restored. Ctrl-C / EOF / escape resolve "" → the default-deny path.
|
|
83
|
+
readKey: () => readSingleKey(),
|
|
79
84
|
readLine: readLineFromStdin,
|
|
80
85
|
color,
|
|
81
86
|
};
|
|
82
87
|
}
|
|
83
|
-
/** Read a single keypress in raw mode; "" on EOF. Always restores cooked mode. */
|
|
84
|
-
function readKeyFromStdin() {
|
|
85
|
-
const stdin = process.stdin;
|
|
86
|
-
return new Promise((resolve) => {
|
|
87
|
-
const cleanup = () => {
|
|
88
|
-
stdin.removeListener("data", onData);
|
|
89
|
-
stdin.removeListener("end", onEnd);
|
|
90
|
-
if (stdin.isTTY)
|
|
91
|
-
stdin.setRawMode(false);
|
|
92
|
-
stdin.pause();
|
|
93
|
-
};
|
|
94
|
-
const onData = (buf) => {
|
|
95
|
-
cleanup();
|
|
96
|
-
resolve(buf.toString("utf8").slice(0, 1));
|
|
97
|
-
};
|
|
98
|
-
const onEnd = () => {
|
|
99
|
-
cleanup();
|
|
100
|
-
resolve("");
|
|
101
|
-
};
|
|
102
|
-
if (stdin.isTTY)
|
|
103
|
-
stdin.setRawMode(true);
|
|
104
|
-
stdin.resume();
|
|
105
|
-
stdin.once("data", onData);
|
|
106
|
-
stdin.once("end", onEnd);
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
88
|
/** Read one line in cooked mode; "" on EOF. */
|
|
110
89
|
function readLineFromStdin() {
|
|
111
90
|
const stdin = process.stdin;
|
package/dist/approval/types.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type RiskTier = "read" | "mutate" | "destructive";
|
|
|
17
17
|
* The tight scope a session grant is keyed by. Never blanket.
|
|
18
18
|
* - `shell-prefix` — a command's leading program token (e.g. `git`); only ever
|
|
19
19
|
* matches commands we can *positively* prove are simple (no shell features).
|
|
20
|
+
* - `shell-exact` — one exact command string, for `test` actions only (C.13):
|
|
21
|
+
* a grant covers re-runs of precisely that test command, nothing else.
|
|
20
22
|
* - `file-subtree` — an absolute directory (or, under the root-cap, an exact
|
|
21
23
|
* file path); matches targets that resolve inside it.
|
|
22
24
|
* - `none` — nothing safe to grant (e.g. a multi-file patch spanning the root).
|
|
@@ -24,6 +26,9 @@ export type RiskTier = "read" | "mutate" | "destructive";
|
|
|
24
26
|
export type Scope = {
|
|
25
27
|
readonly kind: "shell-prefix";
|
|
26
28
|
readonly token: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: "shell-exact";
|
|
31
|
+
readonly command: string;
|
|
27
32
|
} | {
|
|
28
33
|
readonly kind: "file-subtree";
|
|
29
34
|
readonly root: string;
|
|
@@ -3,8 +3,43 @@ import pc from "picocolors";
|
|
|
3
3
|
import { loadConfig } from "../../config/index.js";
|
|
4
4
|
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
5
|
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
6
|
+
import { fuzzyFind, selectList } from "../../components/index.js";
|
|
6
7
|
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
7
8
|
import { logger } from "../../utils/logger.js";
|
|
9
|
+
/** One picker row: id, age, and what the run was about. */
|
|
10
|
+
function checkpointLabel(c) {
|
|
11
|
+
return `${c.id} ${c.createdAt} ${c.runSummary}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Choose which checkpoint to restore when the user gave no id (U.7 dogfood).
|
|
15
|
+
* One checkpoint → it, no ceremony. A short list → arrow-key SelectList with
|
|
16
|
+
* the newest preselected, so Enter-once matches the old "defaults to the
|
|
17
|
+
* most recent" behavior. A long list → type-to-filter FuzzyFinder. Returns
|
|
18
|
+
* `null` on cancel (nothing restored) — cancellation is a result, not an
|
|
19
|
+
* error.
|
|
20
|
+
*/
|
|
21
|
+
async function pickCheckpoint(service) {
|
|
22
|
+
const checkpoints = await service.list(); // newest first
|
|
23
|
+
if (checkpoints.length === 0)
|
|
24
|
+
return undefined; // let rollback() fail loud
|
|
25
|
+
if (checkpoints.length === 1)
|
|
26
|
+
return checkpoints[0];
|
|
27
|
+
const common = {
|
|
28
|
+
title: "pick a checkpoint to roll back to (newest first)",
|
|
29
|
+
nonInteractiveHint: ["or pass the id directly: `cruxy rollback <id>`"],
|
|
30
|
+
// Non-TTY can't reach here (refused above), but the components' own
|
|
31
|
+
// fallback still names the flag if that ever changes.
|
|
32
|
+
defaultValue: checkpoints[0],
|
|
33
|
+
};
|
|
34
|
+
const result = checkpoints.length > 10
|
|
35
|
+
? await fuzzyFind(checkpoints, { ...common, toLabel: checkpointLabel })
|
|
36
|
+
: await selectList(checkpoints, {
|
|
37
|
+
...common,
|
|
38
|
+
toLabel: checkpointLabel,
|
|
39
|
+
initialIndex: 0,
|
|
40
|
+
});
|
|
41
|
+
return result.kind === "selected" ? result.value : null;
|
|
42
|
+
}
|
|
8
43
|
/**
|
|
9
44
|
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
10
45
|
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
@@ -31,6 +66,16 @@ export function rollbackCommand() {
|
|
|
31
66
|
interactive,
|
|
32
67
|
io: defaultPromptIO(shouldUseColor()),
|
|
33
68
|
});
|
|
69
|
+
// No id given → pick one interactively (U.7). Enter-once still restores
|
|
70
|
+
// the newest, exactly as before the picker existed.
|
|
71
|
+
if (id === undefined) {
|
|
72
|
+
const picked = await pickCheckpoint(service);
|
|
73
|
+
if (picked === null) {
|
|
74
|
+
logger.print(pc.dim("rollback cancelled — nothing was changed"));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
id = picked?.id;
|
|
78
|
+
}
|
|
34
79
|
const result = await service.rollback(id, {
|
|
35
80
|
requestApproval: (action) => approval.requestApproval(action),
|
|
36
81
|
interactive,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy test` (C.13) — run the project's detected/configured test command
|
|
4
|
+
* once and print the structured result the agent would see. Directly
|
|
5
|
+
* user-invoked, so there is no approval gate (typing the command IS the
|
|
6
|
+
* consent — same as running the suite by hand); the process exit code mirrors
|
|
7
|
+
* the suite's pass/fail so scripts and CI can branch on it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function testCommand(): Command;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { testCommandNotFound } from "../../errors/index.js";
|
|
5
|
+
import { CommandTestRunner, detectTestCommand } from "../../testing/index.js";
|
|
6
|
+
import { logger } from "../../utils/logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* `cruxy test` (C.13) — run the project's detected/configured test command
|
|
9
|
+
* once and print the structured result the agent would see. Directly
|
|
10
|
+
* user-invoked, so there is no approval gate (typing the command IS the
|
|
11
|
+
* consent — same as running the suite by hand); the process exit code mirrors
|
|
12
|
+
* the suite's pass/fail so scripts and CI can branch on it.
|
|
13
|
+
*/
|
|
14
|
+
export function testCommand() {
|
|
15
|
+
return new Command("test")
|
|
16
|
+
.description("run the project's test suite once and show the parsed result")
|
|
17
|
+
.action(async () => {
|
|
18
|
+
const { config } = loadConfig();
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
const detected = detectTestCommand(cwd, config);
|
|
21
|
+
if (detected === null)
|
|
22
|
+
throw testCommandNotFound();
|
|
23
|
+
logger.print(pc.dim(`running: ${detected.command} [${detected.source}]`));
|
|
24
|
+
const result = await new CommandTestRunner().run(detected.command, {
|
|
25
|
+
cwd,
|
|
26
|
+
timeoutMs: config.shell.timeoutMs,
|
|
27
|
+
captureBytes: config.test.captureBytes,
|
|
28
|
+
});
|
|
29
|
+
const seconds = (result.durationMs / 1000).toFixed(1);
|
|
30
|
+
if (result.passed) {
|
|
31
|
+
logger.print(`${pc.green("✓")} tests passed${result.total !== undefined ? ` (${result.total})` : ""} in ${seconds}s`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
logger.print(`${pc.red("✗")} tests failed (exit ${result.exitCode ?? "signal"}) in ${seconds}s`);
|
|
35
|
+
for (const failure of result.failures) {
|
|
36
|
+
const where = failure.file !== undefined
|
|
37
|
+
? pc.dim(` ${failure.file}${failure.line !== undefined ? `:${failure.line}` : ""}`)
|
|
38
|
+
: "";
|
|
39
|
+
logger.print(` ${pc.red("✗")} ${failure.name}${where}`);
|
|
40
|
+
}
|
|
41
|
+
if (result.failures.length === 0) {
|
|
42
|
+
// Nothing parseable — show the honest tail instead of fake structure.
|
|
43
|
+
logger.print(pc.dim(result.output.trimEnd()));
|
|
44
|
+
}
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
});
|
|
47
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -12,6 +12,7 @@ import { loginCommand } from "./commands/login.js";
|
|
|
12
12
|
import { initCommand } from "./commands/init.js";
|
|
13
13
|
import { checkpointCommand } from "./commands/checkpoint.js";
|
|
14
14
|
import { rollbackCommand } from "./commands/rollback.js";
|
|
15
|
+
import { testCommand } from "./commands/test.js";
|
|
15
16
|
import { loadConfig } from "../config/index.js";
|
|
16
17
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
17
18
|
export function buildProgram() {
|
|
@@ -40,6 +41,7 @@ export function buildProgram() {
|
|
|
40
41
|
program.addCommand(initCommand());
|
|
41
42
|
program.addCommand(checkpointCommand());
|
|
42
43
|
program.addCommand(rollbackCommand());
|
|
44
|
+
program.addCommand(testCommand());
|
|
43
45
|
// Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
|
|
44
46
|
// means an unknown command (Commander runs the default action with it as an
|
|
45
47
|
// operand rather than erroring), so reject it as a usage error.
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ import type { Readable, Writable } from "node:stream";
|
|
|
2
2
|
import type { Session } from "../agent/index.js";
|
|
3
3
|
import type { CheckpointService } from "../checkpoint/index.js";
|
|
4
4
|
import { type StreamRenderer } from "../render/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
7
|
+
* sync with the dispatch below and the HELP text.
|
|
8
|
+
*/
|
|
9
|
+
export declare const REPL_COMMANDS: readonly ["/help", "/clear", "/compact", "/reload", "/plan", "/exit", "/quit"];
|
|
5
10
|
/** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
|
|
6
11
|
export interface ReplIO {
|
|
7
12
|
input: Readable;
|
package/dist/cli/repl.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
+
import { makeReplCompleter } from "../components/index.js";
|
|
3
4
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
4
5
|
import { createRenderer } from "../render/index.js";
|
|
5
6
|
import { logger } from "../utils/logger.js";
|
|
6
7
|
const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
|
|
8
|
+
/**
|
|
9
|
+
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
10
|
+
* sync with the dispatch below and the HELP text.
|
|
11
|
+
*/
|
|
12
|
+
export const REPL_COMMANDS = [
|
|
13
|
+
"/help",
|
|
14
|
+
"/clear",
|
|
15
|
+
"/compact",
|
|
16
|
+
"/reload",
|
|
17
|
+
"/plan",
|
|
18
|
+
"/exit",
|
|
19
|
+
"/quit",
|
|
20
|
+
];
|
|
7
21
|
const HELP = `Commands:
|
|
8
22
|
/help show this help
|
|
9
23
|
/clear clear the conversation history (keep the session)
|
|
@@ -35,6 +49,9 @@ function readLine(io, prompt) {
|
|
|
35
49
|
const rl = readline.createInterface({
|
|
36
50
|
input: io.input,
|
|
37
51
|
output: io.output,
|
|
52
|
+
// Tab-completion for slash commands (U.7): readline rewrites the edit
|
|
53
|
+
// buffer only — completing never submits, Enter remains the sole trigger.
|
|
54
|
+
completer: makeReplCompleter(() => REPL_COMMANDS),
|
|
38
55
|
});
|
|
39
56
|
return new Promise((resolve) => {
|
|
40
57
|
let answered = false;
|
|
@@ -67,10 +67,14 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
|
|
|
67
67
|
if (request.tier === "read")
|
|
68
68
|
return decision;
|
|
69
69
|
await checkpoints.ensureCheckpoint();
|
|
70
|
-
|
|
70
|
+
// Shell AND test executions (C.13) can mutate files we can't attribute
|
|
71
|
+
// (scripts, snapshot writers) — record the lost attribution the same way.
|
|
72
|
+
if (action.kind === "shell" || action.kind === "test") {
|
|
71
73
|
await checkpoints.recordShellMutation();
|
|
72
|
-
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
73
76
|
await checkpoints.recordTouched([...request.targets]);
|
|
77
|
+
}
|
|
74
78
|
return decision;
|
|
75
79
|
};
|
|
76
80
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete (U.7): pure completion over an injectable candidate list, plus
|
|
3
|
+
* the readline adapter the REPL uses for Tab-completion. Non-destructive by
|
|
4
|
+
* construction — this module transforms strings and returns suggestions; it
|
|
5
|
+
* has no access to the session, so completing can never execute anything.
|
|
6
|
+
* Enter (the existing REPL path) remains the only way to act.
|
|
7
|
+
*/
|
|
8
|
+
/** The outcome of one completion attempt. */
|
|
9
|
+
export interface Completion {
|
|
10
|
+
/** The (possibly extended) input line. Unchanged when nothing matches. */
|
|
11
|
+
line: string;
|
|
12
|
+
/** Every candidate the input currently prefixes (shown on ambiguity). */
|
|
13
|
+
suggestions: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Complete `line` against `candidates` (exact-prefix filter):
|
|
17
|
+
* - no match → line unchanged, no suggestions;
|
|
18
|
+
* - one match → completed fully to it;
|
|
19
|
+
* - several → extended to their longest common prefix, all listed.
|
|
20
|
+
* Pure data in, data out — deterministic and order-preserving.
|
|
21
|
+
*/
|
|
22
|
+
export declare function completeLine(line: string, candidates: readonly string[]): Completion;
|
|
23
|
+
/**
|
|
24
|
+
* A Node-readline `completer` over a live candidate source. Only the leading
|
|
25
|
+
* word of a line starting with `/` completes (slash commands); everything
|
|
26
|
+
* else — prompts to the model — is left alone, so Tab never mangles prose.
|
|
27
|
+
*
|
|
28
|
+
* readline's contract: return `[hits, prefixBeingReplaced]`; readline itself
|
|
29
|
+
* extends to the common prefix and lists hits on a second Tab. It rewrites
|
|
30
|
+
* only the edit buffer — submission stays on Enter.
|
|
31
|
+
*/
|
|
32
|
+
export declare function makeReplCompleter(candidates: () => readonly string[]): (line: string) => [string[], string];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete (U.7): pure completion over an injectable candidate list, plus
|
|
3
|
+
* the readline adapter the REPL uses for Tab-completion. Non-destructive by
|
|
4
|
+
* construction — this module transforms strings and returns suggestions; it
|
|
5
|
+
* has no access to the session, so completing can never execute anything.
|
|
6
|
+
* Enter (the existing REPL path) remains the only way to act.
|
|
7
|
+
*/
|
|
8
|
+
/** The longest common prefix of a non-empty candidate list. */
|
|
9
|
+
function commonPrefix(candidates) {
|
|
10
|
+
let prefix = candidates[0];
|
|
11
|
+
for (const candidate of candidates.slice(1)) {
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < prefix.length && prefix[i] === candidate[i])
|
|
14
|
+
i++;
|
|
15
|
+
prefix = prefix.slice(0, i);
|
|
16
|
+
}
|
|
17
|
+
return prefix;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Complete `line` against `candidates` (exact-prefix filter):
|
|
21
|
+
* - no match → line unchanged, no suggestions;
|
|
22
|
+
* - one match → completed fully to it;
|
|
23
|
+
* - several → extended to their longest common prefix, all listed.
|
|
24
|
+
* Pure data in, data out — deterministic and order-preserving.
|
|
25
|
+
*/
|
|
26
|
+
export function completeLine(line, candidates) {
|
|
27
|
+
const matches = candidates.filter((c) => c.startsWith(line));
|
|
28
|
+
if (matches.length === 0)
|
|
29
|
+
return { line, suggestions: [] };
|
|
30
|
+
if (matches.length === 1)
|
|
31
|
+
return { line: matches[0], suggestions: matches };
|
|
32
|
+
return { line: commonPrefix(matches), suggestions: matches };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A Node-readline `completer` over a live candidate source. Only the leading
|
|
36
|
+
* word of a line starting with `/` completes (slash commands); everything
|
|
37
|
+
* else — prompts to the model — is left alone, so Tab never mangles prose.
|
|
38
|
+
*
|
|
39
|
+
* readline's contract: return `[hits, prefixBeingReplaced]`; readline itself
|
|
40
|
+
* extends to the common prefix and lists hits on a second Tab. It rewrites
|
|
41
|
+
* only the edit buffer — submission stays on Enter.
|
|
42
|
+
*/
|
|
43
|
+
export function makeReplCompleter(candidates) {
|
|
44
|
+
return (line) => {
|
|
45
|
+
if (!line.startsWith("/") || /\s/.test(line))
|
|
46
|
+
return [[], line];
|
|
47
|
+
const { suggestions } = completeLine(line, candidates());
|
|
48
|
+
return [suggestions, line];
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RenderCapabilities } from "../render/index.js";
|
|
2
|
+
/** The visible text of a possibly-styled row. */
|
|
3
|
+
export declare function stripAnsi(text: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* The transient multi-line region interactive components draw into (U.7) —
|
|
6
|
+
* the multi-row analog of the TTY renderer's single managed status line, with
|
|
7
|
+
* the same discipline:
|
|
8
|
+
*
|
|
9
|
+
* - Only the frame is ever rewritten (erase + redraw in place) — committed
|
|
10
|
+
* output above it is never touched.
|
|
11
|
+
* - Every line is hard-truncated to the terminal width so a row can never
|
|
12
|
+
* soft-wrap; wrapped rows would break erasure and leave artifacts.
|
|
13
|
+
* - `clear()` removes the frame entirely — after a component resolves, the
|
|
14
|
+
* screen holds zero leftover bytes from the interaction.
|
|
15
|
+
*
|
|
16
|
+
* Requires cursor control (`caps.cursor`); components guard on that before
|
|
17
|
+
* constructing one.
|
|
18
|
+
*/
|
|
19
|
+
export interface Frame {
|
|
20
|
+
/** Repaint the frame with these rows (erases the previous paint first). */
|
|
21
|
+
render(lines: string[]): void;
|
|
22
|
+
/** Erase the frame completely. Idempotent. */
|
|
23
|
+
clear(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare function createFrame(write: (text: string) => void, caps: RenderCapabilities): Frame;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Erase the current line and return the cursor to column 0 (same as U.2). */
|
|
2
|
+
const CLEAR_LINE = "\r\x1b[2K";
|
|
3
|
+
/** Move the cursor up one row. */
|
|
4
|
+
const CURSOR_UP = "\x1b[1A";
|
|
5
|
+
/** SGR escape sequences (the only ANSI the components emit — via picocolors). */
|
|
6
|
+
// eslint-disable-next-line no-control-regex
|
|
7
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
8
|
+
/** The visible text of a possibly-styled row. */
|
|
9
|
+
export function stripAnsi(text) {
|
|
10
|
+
return text.replace(SGR, "");
|
|
11
|
+
}
|
|
12
|
+
export function createFrame(write, caps) {
|
|
13
|
+
let drawn = 0;
|
|
14
|
+
/**
|
|
15
|
+
* Truncate to width-1 (cursor rests after the last cell; a full-width row
|
|
16
|
+
* would auto-wrap on some terminals). Width is measured on VISIBLE
|
|
17
|
+
* characters — rows may carry ANSI color. A row that fits passes through
|
|
18
|
+
* styled; an overflowing row is truncated on its stripped text (style is
|
|
19
|
+
* dropped rather than risking a cut escape sequence).
|
|
20
|
+
*/
|
|
21
|
+
const fit = (line) => {
|
|
22
|
+
const room = Math.max(1, caps.width - 1);
|
|
23
|
+
const plain = stripAnsi(line);
|
|
24
|
+
if (plain.length <= room)
|
|
25
|
+
return line;
|
|
26
|
+
return plain.slice(0, room - 1) + "…";
|
|
27
|
+
};
|
|
28
|
+
const erase = () => {
|
|
29
|
+
if (drawn === 0)
|
|
30
|
+
return;
|
|
31
|
+
// Cursor sits at the end of the last drawn row: clear it, then walk up
|
|
32
|
+
// clearing each prior row, ending at column 0 of the first frame row.
|
|
33
|
+
let out = CLEAR_LINE;
|
|
34
|
+
for (let i = 1; i < drawn; i++)
|
|
35
|
+
out += CURSOR_UP + CLEAR_LINE;
|
|
36
|
+
write(out);
|
|
37
|
+
drawn = 0;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
render(lines) {
|
|
41
|
+
erase();
|
|
42
|
+
if (lines.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
write(lines.map(fit).join("\n"));
|
|
45
|
+
drawn = lines.length;
|
|
46
|
+
},
|
|
47
|
+
clear: erase,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { type ComponentIO, type InteractiveResult } from "./input.js";
|
|
3
|
+
/**
|
|
4
|
+
* Fuzzy finding (U.7): a deterministic, honest subsequence scorer (pure,
|
|
5
|
+
* headless-tested) plus the interactive type-to-filter component built on it.
|
|
6
|
+
* No fabricated relevance — a label either contains the query as a
|
|
7
|
+
* subsequence or it does not, and the score is three documented bonuses.
|
|
8
|
+
*/
|
|
9
|
+
/** A successful match: its rank score and the label indices that matched. */
|
|
10
|
+
export interface FuzzyMatch {
|
|
11
|
+
score: number;
|
|
12
|
+
/** Indices into the label of the matched characters (for highlighting). */
|
|
13
|
+
positions: number[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Case-insensitive greedy-leftmost subsequence match.
|
|
17
|
+
* - Empty query matches everything: score 0, no positions (nothing to
|
|
18
|
+
* highlight, nothing to fabricate).
|
|
19
|
+
* - Not a subsequence → `null`.
|
|
20
|
+
* - Score = Σ per matched char: {@link BASE} + {@link CONSECUTIVE} when the
|
|
21
|
+
* match continues a run + {@link BOUNDARY} when it starts a word.
|
|
22
|
+
* Deterministic by construction — same inputs, same output, no randomness,
|
|
23
|
+
* no length normalization (ties break in {@link rankItems}).
|
|
24
|
+
*/
|
|
25
|
+
export declare function fuzzyScore(query: string, label: string): FuzzyMatch | null;
|
|
26
|
+
/** An item that survived filtering, with everything the finder renders. */
|
|
27
|
+
export interface RankedItem<T> {
|
|
28
|
+
item: T;
|
|
29
|
+
label: string;
|
|
30
|
+
match: FuzzyMatch;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Filter + rank a list against a query. Ordering is fully stable and
|
|
34
|
+
* deterministic: score descending, then shorter label, then original index.
|
|
35
|
+
* An empty query returns every item in original order.
|
|
36
|
+
*/
|
|
37
|
+
export declare function rankItems<T>(items: readonly T[], toLabel: (item: T) => string, query: string): RankedItem<T>[];
|
|
38
|
+
/**
|
|
39
|
+
* Bold the matched characters of a label. With color off (NO_COLOR, pipe)
|
|
40
|
+
* picocolors' disabled palette is the identity — plain text, zero ANSI.
|
|
41
|
+
*/
|
|
42
|
+
export declare function highlightMatch(label: string, positions: readonly number[], colors: ReturnType<typeof pc.createColors>): string;
|
|
43
|
+
export interface FuzzyFindOptions<T> {
|
|
44
|
+
/** Label an item filters/renders under. Required — items are opaque. */
|
|
45
|
+
toLabel: (item: T) => string;
|
|
46
|
+
/** Header above the query line (e.g. "pick a checkpoint"). */
|
|
47
|
+
title?: string;
|
|
48
|
+
/** Non-TTY fallback: resolve to this instead of failing loud. */
|
|
49
|
+
defaultValue?: T;
|
|
50
|
+
/** `--flag` alternatives listed in the non-TTY error. */
|
|
51
|
+
nonInteractiveHint?: string[];
|
|
52
|
+
/** Visible result rows before the list scrolls (default 10). */
|
|
53
|
+
maxVisible?: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Interactive fuzzy finder: type to filter, ↑/↓ to move through the ranked
|
|
57
|
+
* results, Enter to select the highlighted item (inert while there are no
|
|
58
|
+
* matches), Esc / Ctrl-C / EOF to cancel. The transient frame is fully erased
|
|
59
|
+
* before resolving — the screen keeps no trace of the interaction.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fuzzyFind<T>(items: readonly T[], opts: FuzzyFindOptions<T>, io?: ComponentIO): Promise<InteractiveResult<T>>;
|