@cruxy/cli 0.8.0 → 0.10.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/README.md +40 -13
- package/dist/agent/loop.d.ts +28 -1
- package/dist/agent/loop.js +36 -4
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.js +4 -27
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +96 -0
- package/dist/cli/commands/run.js +10 -2
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +7 -1
- package/dist/cli/repl.js +23 -3
- package/dist/cli/session-factory.d.ts +14 -1
- package/dist/cli/session-factory.js +87 -22
- 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 +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +32 -0
- package/dist/errors/constructors.js +101 -0
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +18 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/onboarding/io.d.ts +3 -2
- package/dist/onboarding/io.js +35 -81
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.js +5 -1
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/diff.js +27 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +1 -0
- package/dist/render/plain-renderer.d.ts +7 -1
- package/dist/render/plain-renderer.js +26 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +41 -5
- package/dist/render/tty-renderer.js +150 -23
- package/dist/render/types.d.ts +85 -1
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -2,16 +2,19 @@ import { createProvider } from "@cruxy/sdk";
|
|
|
2
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
3
3
|
import { logger } from "../utils/logger.js";
|
|
4
4
|
import { getGitInfo } from "../utils/git.js";
|
|
5
|
-
import { ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, } from "../approval/index.js";
|
|
5
|
+
import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
|
|
6
6
|
import { shouldUseColor } from "../errors/index.js";
|
|
7
7
|
import { buildDefaultRegistry } from "../tools/index.js";
|
|
8
8
|
import { Session } from "../agent/index.js";
|
|
9
9
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
|
+
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
10
11
|
/**
|
|
11
|
-
* Wrap a PromptIO so the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
12
|
+
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
13
|
+
* (U.2/U.4): the prompt writes to stderr while the status line owns the last
|
|
14
|
+
* stdout row of the same terminal. Entering the `awaiting-approval` phase
|
|
15
|
+
* hides the live line (the prompt IS the visible state) while keeping the
|
|
16
|
+
* step-progress register intact, so the line comes back with full context on
|
|
17
|
+
* the next transition after the user decides.
|
|
15
18
|
*/
|
|
16
19
|
function suspendStatusOnPrompt(io, renderer) {
|
|
17
20
|
if (!renderer)
|
|
@@ -19,11 +22,58 @@ function suspendStatusOnPrompt(io, renderer) {
|
|
|
19
22
|
return {
|
|
20
23
|
...io,
|
|
21
24
|
write: (text) => {
|
|
22
|
-
renderer.
|
|
25
|
+
renderer.setPhase({ kind: "awaiting-approval" });
|
|
23
26
|
io.write(text);
|
|
24
27
|
},
|
|
25
28
|
};
|
|
26
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Restore the live line once an approval request fully settles (U.4). The
|
|
32
|
+
* settle point must be the service call, not the prompt's key read: the
|
|
33
|
+
* prompt writes a trailing newline AFTER the read, which re-enters
|
|
34
|
+
* `awaiting-approval` — resolving here is the first moment no more prompt
|
|
35
|
+
* bytes can follow. Fires on every decision (prompted or not); the renderer
|
|
36
|
+
* treats it as a no-op unless a prompt actually displaced the line.
|
|
37
|
+
*/
|
|
38
|
+
function resumeLineAfterApproval(requestApproval, renderer) {
|
|
39
|
+
if (!renderer)
|
|
40
|
+
return requestApproval;
|
|
41
|
+
return async (action) => {
|
|
42
|
+
try {
|
|
43
|
+
return await requestApproval(action);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
renderer.promptResolved();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
52
|
+
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
53
|
+
* snapshotting after an `allow` decision but before returning it means the
|
|
54
|
+
* checkpoint always lands before the run's first mutation — and a denied
|
|
55
|
+
* action never creates one. The same seam records which paths the run touched
|
|
56
|
+
* (file actions) or that attribution is lost (shell), for rollback's
|
|
57
|
+
* external-change detection.
|
|
58
|
+
*/
|
|
59
|
+
export function withCheckpointGate(requestApproval, checkpoints, cwd) {
|
|
60
|
+
if (!checkpoints)
|
|
61
|
+
return requestApproval;
|
|
62
|
+
return async (action) => {
|
|
63
|
+
const decision = await requestApproval(action);
|
|
64
|
+
if (!decision.allow)
|
|
65
|
+
return decision;
|
|
66
|
+
const request = classify(action, cwd);
|
|
67
|
+
if (request.tier === "read")
|
|
68
|
+
return decision;
|
|
69
|
+
await checkpoints.ensureCheckpoint();
|
|
70
|
+
if (action.kind === "shell")
|
|
71
|
+
await checkpoints.recordShellMutation();
|
|
72
|
+
else
|
|
73
|
+
await checkpoints.recordTouched([...request.targets]);
|
|
74
|
+
return decision;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
27
77
|
/**
|
|
28
78
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
29
79
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -33,7 +83,7 @@ function suspendStatusOnPrompt(io, renderer) {
|
|
|
33
83
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
34
84
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
35
85
|
*/
|
|
36
|
-
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer) {
|
|
86
|
+
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints) {
|
|
37
87
|
const provider = createProvider({
|
|
38
88
|
provider: config.model.provider,
|
|
39
89
|
apiKey,
|
|
@@ -45,10 +95,35 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
45
95
|
const execRegistry = buildDefaultRegistry();
|
|
46
96
|
const git = getGitInfo(cwd);
|
|
47
97
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
98
|
+
// One io shared by every prompt in the session (plan approval, the U.3 gate,
|
|
99
|
+
// and any gate inside a subagent), so they all coordinate with the same live
|
|
100
|
+
// region. The full wrapper stack around an ApprovalService is factored here
|
|
101
|
+
// because subagents must get the *identical* stack over a FRESH service: same
|
|
102
|
+
// prompt + same checkpoint hook, but a new (empty) session allowlist — a
|
|
103
|
+
// grant in the parent never silently widens a child's authority.
|
|
104
|
+
const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
|
|
105
|
+
const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, cwd);
|
|
106
|
+
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
107
|
+
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
108
|
+
// Registered before the plan wiring so plan-mode execution steps can
|
|
109
|
+
// dispatch subagents too; the propose phase filters it out (read-only).
|
|
110
|
+
const orchestrator = new SubagentOrchestrator({
|
|
111
|
+
provider,
|
|
112
|
+
config,
|
|
113
|
+
parentRegistry: execRegistry,
|
|
114
|
+
cwd,
|
|
115
|
+
logger,
|
|
116
|
+
git,
|
|
117
|
+
projectInstructions,
|
|
118
|
+
renderer,
|
|
119
|
+
makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
|
|
120
|
+
});
|
|
121
|
+
if (config.subagent.maxDepth > 0) {
|
|
122
|
+
execRegistry.register(makeSpawnSubagentTool(orchestrator, 0));
|
|
123
|
+
}
|
|
48
124
|
if (planMode) {
|
|
49
|
-
// One
|
|
125
|
+
// One allowlist shared by the plan-approval prompt and the per-action
|
|
50
126
|
// gate, so a grant recorded during execution is honored by U.3's own check.
|
|
51
|
-
const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
|
|
52
127
|
const allowlist = new SessionAllowlist();
|
|
53
128
|
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
|
|
54
129
|
const approval = new ApprovalService({
|
|
@@ -57,12 +132,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
57
132
|
policy: planPolicy,
|
|
58
133
|
io,
|
|
59
134
|
});
|
|
60
|
-
const ctx = {
|
|
61
|
-
cwd,
|
|
62
|
-
config,
|
|
63
|
-
logger,
|
|
64
|
-
requestApproval: (action) => approval.requestApproval(action),
|
|
65
|
-
};
|
|
135
|
+
const ctx = { cwd, config, logger, requestApproval: gate(approval) };
|
|
66
136
|
const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
|
|
67
137
|
provider,
|
|
68
138
|
config,
|
|
@@ -90,14 +160,9 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
90
160
|
const approval = new ApprovalService({
|
|
91
161
|
cwd,
|
|
92
162
|
interactive: ttyInteractive,
|
|
93
|
-
io
|
|
163
|
+
io,
|
|
94
164
|
});
|
|
95
|
-
const ctx = {
|
|
96
|
-
cwd,
|
|
97
|
-
config,
|
|
98
|
-
logger,
|
|
99
|
-
requestApproval: (action) => approval.requestApproval(action),
|
|
100
|
-
};
|
|
165
|
+
const ctx = { cwd, config, logger, requestApproval: gate(approval) };
|
|
101
166
|
return new Session({
|
|
102
167
|
provider,
|
|
103
168
|
registry: execRegistry,
|
|
@@ -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>>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { createFrame } from "./frame.js";
|
|
3
|
+
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
4
|
+
/** Word-boundary characters that earn the boundary bonus for the NEXT char. */
|
|
5
|
+
const SEPARATORS = new Set(["/", "-", "_", ".", " "]);
|
|
6
|
+
/** Per-character score weights — the entire ranking formula. */
|
|
7
|
+
const BASE = 1; // every matched character
|
|
8
|
+
const CONSECUTIVE = 2; // extra when adjacent to the previous match
|
|
9
|
+
const BOUNDARY = 2; // extra at label start or right after a separator
|
|
10
|
+
/**
|
|
11
|
+
* Case-insensitive greedy-leftmost subsequence match.
|
|
12
|
+
* - Empty query matches everything: score 0, no positions (nothing to
|
|
13
|
+
* highlight, nothing to fabricate).
|
|
14
|
+
* - Not a subsequence → `null`.
|
|
15
|
+
* - Score = Σ per matched char: {@link BASE} + {@link CONSECUTIVE} when the
|
|
16
|
+
* match continues a run + {@link BOUNDARY} when it starts a word.
|
|
17
|
+
* Deterministic by construction — same inputs, same output, no randomness,
|
|
18
|
+
* no length normalization (ties break in {@link rankItems}).
|
|
19
|
+
*/
|
|
20
|
+
export function fuzzyScore(query, label) {
|
|
21
|
+
if (query === "")
|
|
22
|
+
return { score: 0, positions: [] };
|
|
23
|
+
const q = query.toLowerCase();
|
|
24
|
+
const l = label.toLowerCase();
|
|
25
|
+
const positions = [];
|
|
26
|
+
let score = 0;
|
|
27
|
+
let li = 0;
|
|
28
|
+
for (let qi = 0; qi < q.length; qi++) {
|
|
29
|
+
const idx = l.indexOf(q[qi], li);
|
|
30
|
+
if (idx === -1)
|
|
31
|
+
return null;
|
|
32
|
+
score += BASE;
|
|
33
|
+
if (positions.length > 0 && idx === positions[positions.length - 1] + 1) {
|
|
34
|
+
score += CONSECUTIVE;
|
|
35
|
+
}
|
|
36
|
+
if (idx === 0 || SEPARATORS.has(label[idx - 1])) {
|
|
37
|
+
score += BOUNDARY;
|
|
38
|
+
}
|
|
39
|
+
positions.push(idx);
|
|
40
|
+
li = idx + 1;
|
|
41
|
+
}
|
|
42
|
+
return { score, positions };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Filter + rank a list against a query. Ordering is fully stable and
|
|
46
|
+
* deterministic: score descending, then shorter label, then original index.
|
|
47
|
+
* An empty query returns every item in original order.
|
|
48
|
+
*/
|
|
49
|
+
export function rankItems(items, toLabel, query) {
|
|
50
|
+
// Empty query: everything matches with score 0 — original order IS the
|
|
51
|
+
// ranking (the length tie-break must not silently reorder the full list).
|
|
52
|
+
if (query === "") {
|
|
53
|
+
return items.map((item) => ({
|
|
54
|
+
item,
|
|
55
|
+
label: toLabel(item),
|
|
56
|
+
match: { score: 0, positions: [] },
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
const ranked = [];
|
|
60
|
+
for (const [index, item] of items.entries()) {
|
|
61
|
+
const label = toLabel(item);
|
|
62
|
+
const match = fuzzyScore(query, label);
|
|
63
|
+
if (match)
|
|
64
|
+
ranked.push({ item, label, match, index });
|
|
65
|
+
}
|
|
66
|
+
ranked.sort((a, b) => b.match.score - a.match.score ||
|
|
67
|
+
a.label.length - b.label.length ||
|
|
68
|
+
a.index - b.index);
|
|
69
|
+
return ranked.map(({ item, label, match }) => ({ item, label, match }));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Bold the matched characters of a label. With color off (NO_COLOR, pipe)
|
|
73
|
+
* picocolors' disabled palette is the identity — plain text, zero ANSI.
|
|
74
|
+
*/
|
|
75
|
+
export function highlightMatch(label, positions, colors) {
|
|
76
|
+
if (positions.length === 0)
|
|
77
|
+
return label;
|
|
78
|
+
const matched = new Set(positions);
|
|
79
|
+
let out = "";
|
|
80
|
+
for (let i = 0; i < label.length; i++) {
|
|
81
|
+
out += matched.has(i) ? colors.bold(colors.cyan(label[i])) : label[i];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Interactive fuzzy finder: type to filter, ↑/↓ to move through the ranked
|
|
87
|
+
* results, Enter to select the highlighted item (inert while there are no
|
|
88
|
+
* matches), Esc / Ctrl-C / EOF to cancel. The transient frame is fully erased
|
|
89
|
+
* before resolving — the screen keeps no trace of the interaction.
|
|
90
|
+
*/
|
|
91
|
+
export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
92
|
+
const fallback = resolveNonInteractive(io, opts.title ?? "this picker", opts.defaultValue, opts.nonInteractiveHint);
|
|
93
|
+
if (fallback)
|
|
94
|
+
return fallback;
|
|
95
|
+
if (items.length === 0)
|
|
96
|
+
return { kind: "cancelled" };
|
|
97
|
+
const colors = pc.createColors(io.caps.color);
|
|
98
|
+
const maxVisible = opts.maxVisible ?? 10;
|
|
99
|
+
const frame = createFrame(io.write, io.caps);
|
|
100
|
+
let query = "";
|
|
101
|
+
let cursor = 0; // index into the ranked results
|
|
102
|
+
const paint = (ranked) => {
|
|
103
|
+
const lines = [];
|
|
104
|
+
if (opts.title)
|
|
105
|
+
lines.push(colors.bold(opts.title));
|
|
106
|
+
lines.push(`${colors.cyan("›")} ${query}${colors.dim("▏")}`);
|
|
107
|
+
if (ranked.length === 0) {
|
|
108
|
+
lines.push(colors.dim(" no results — backspace to widen"));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// Keep the highlighted row inside the viewport.
|
|
112
|
+
const top = Math.min(Math.max(0, cursor - maxVisible + 1), Math.max(0, ranked.length - maxVisible));
|
|
113
|
+
const visible = ranked.slice(top, top + maxVisible);
|
|
114
|
+
for (const [i, row] of visible.entries()) {
|
|
115
|
+
const selected = top + i === cursor;
|
|
116
|
+
const marker = selected ? colors.cyan("❯") : " ";
|
|
117
|
+
const label = highlightMatch(row.label, row.match.positions, colors);
|
|
118
|
+
lines.push(`${marker} ${selected ? label : colors.dim(label)}`);
|
|
119
|
+
}
|
|
120
|
+
const hidden = ranked.length - visible.length;
|
|
121
|
+
if (hidden > 0)
|
|
122
|
+
lines.push(colors.dim(` … ${hidden} more`));
|
|
123
|
+
}
|
|
124
|
+
frame.render(lines);
|
|
125
|
+
};
|
|
126
|
+
io.keys.begin();
|
|
127
|
+
try {
|
|
128
|
+
let ranked = rankItems(items, opts.toLabel, query);
|
|
129
|
+
paint(ranked);
|
|
130
|
+
for (;;) {
|
|
131
|
+
const key = await io.keys.read();
|
|
132
|
+
switch (key.kind) {
|
|
133
|
+
case "ctrl-c":
|
|
134
|
+
case "eof":
|
|
135
|
+
case "escape":
|
|
136
|
+
return { kind: "cancelled" };
|
|
137
|
+
case "enter":
|
|
138
|
+
if (ranked.length === 0)
|
|
139
|
+
break; // inert in the no-results state
|
|
140
|
+
return { kind: "selected", value: ranked[cursor].item };
|
|
141
|
+
case "up":
|
|
142
|
+
if (ranked.length > 0) {
|
|
143
|
+
cursor = (cursor - 1 + ranked.length) % ranked.length;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case "down":
|
|
147
|
+
if (ranked.length > 0)
|
|
148
|
+
cursor = (cursor + 1) % ranked.length;
|
|
149
|
+
break;
|
|
150
|
+
case "backspace":
|
|
151
|
+
if (query.length > 0) {
|
|
152
|
+
query = query.slice(0, -1);
|
|
153
|
+
ranked = rankItems(items, opts.toLabel, query);
|
|
154
|
+
cursor = 0;
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
case "char":
|
|
158
|
+
query += key.char;
|
|
159
|
+
ranked = rankItems(items, opts.toLabel, query);
|
|
160
|
+
cursor = 0;
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
break; // tab / left / right: no meaning here
|
|
164
|
+
}
|
|
165
|
+
paint(ranked);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
// Both on selection and on cancel: erase the frame and leave raw mode —
|
|
170
|
+
// no leftover artifact bytes, terminal always restored.
|
|
171
|
+
frame.clear();
|
|
172
|
+
io.keys.restore();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { RenderCapabilities } from "../render/index.js";
|
|
2
|
+
import { type Key } from "./keys.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one raw-mode input reader (U.7). Every interactive component — and the
|
|
5
|
+
* refactored approval/onboarding key readers — reads keys through this seam;
|
|
6
|
+
* nothing else in the CLI touches `setRawMode`. The lifecycle is strict:
|
|
7
|
+
* `begin()` → `read()` loop → `restore()` in a `finally`, so the terminal is
|
|
8
|
+
* never left in raw mode on any exit path (selection, cancel, throw).
|
|
9
|
+
*/
|
|
10
|
+
/** The minimal stdin surface the reader needs. Injectable for tests. */
|
|
11
|
+
export interface RawInput {
|
|
12
|
+
isTTY?: boolean;
|
|
13
|
+
setRawMode?(mode: boolean): unknown;
|
|
14
|
+
resume(): unknown;
|
|
15
|
+
pause(): unknown;
|
|
16
|
+
on(event: "data" | "end", listener: (chunk: Buffer) => void): unknown;
|
|
17
|
+
removeListener(event: "data" | "end", listener: (chunk: Buffer) => void): unknown;
|
|
18
|
+
}
|
|
19
|
+
/** Raw-mode key source with an explicit begin/read/restore lifecycle. */
|
|
20
|
+
export interface KeyReader {
|
|
21
|
+
/** Enter raw mode and start decoding. Safe to call once per interaction. */
|
|
22
|
+
begin(): void;
|
|
23
|
+
/**
|
|
24
|
+
* The next decoded key. After stream end (or `restore()`), resolves
|
|
25
|
+
* `{kind:"eof"}` forever — a component's read loop can never hang or reject.
|
|
26
|
+
*/
|
|
27
|
+
read(): Promise<Key>;
|
|
28
|
+
/** Leave raw mode, detach, pause. Idempotent; call in `finally`, always. */
|
|
29
|
+
restore(): void;
|
|
30
|
+
}
|
|
31
|
+
/** Build the real reader over `stdin` (or an injected fake in tests). */
|
|
32
|
+
export declare function createKeyReader(stdin?: RawInput): KeyReader;
|
|
33
|
+
/**
|
|
34
|
+
* Read exactly one key with the full begin/restore lifecycle — the shared
|
|
35
|
+
* backend for the approval prompt's single-key read and the onboarding
|
|
36
|
+
* `readKey`. Resolves the printable character, `"\n"` for enter, and `""` for
|
|
37
|
+
* anything that means "no answer" (Ctrl-C, EOF, escape, arrows) — preserving
|
|
38
|
+
* the callers' default-deny mapping.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readSingleKey(stdin?: RawInput): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Every component resolves to a selection or a clean, typed cancellation
|
|
43
|
+
* (Ctrl-C / EOF / escape). Cancellation is data for the caller to interpret —
|
|
44
|
+
* never a thrown error, never a partial value.
|
|
45
|
+
*/
|
|
46
|
+
export type InteractiveResult<T> = {
|
|
47
|
+
kind: "selected";
|
|
48
|
+
value: T;
|
|
49
|
+
} | {
|
|
50
|
+
kind: "cancelled";
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* What a component needs from the environment, bundled so tests can script
|
|
54
|
+
* all of it. `caps` is the existing U.2 capability detection — components
|
|
55
|
+
* never re-probe NO_COLOR / width / TTY themselves.
|
|
56
|
+
*/
|
|
57
|
+
export interface ComponentIO {
|
|
58
|
+
caps: RenderCapabilities;
|
|
59
|
+
/**
|
|
60
|
+
* The whole interactive contract in one flag: stdin is a TTY *and* the
|
|
61
|
+
* output supports cursor control (`TERM=dumb` fails this). When false, no
|
|
62
|
+
* frame is drawn and no key is read — ever.
|
|
63
|
+
*/
|
|
64
|
+
interactive: boolean;
|
|
65
|
+
/** Transient frame bytes. Defaults to stderr so stdout stays pipe-clean. */
|
|
66
|
+
write(text: string): void;
|
|
67
|
+
keys: KeyReader;
|
|
68
|
+
}
|
|
69
|
+
/** The real environment: frames to stderr, keys from stdin, caps from stderr. */
|
|
70
|
+
export declare function defaultComponentIO(): ComponentIO;
|
|
71
|
+
/**
|
|
72
|
+
* The shared non-TTY gate, run before any frame byte or key read:
|
|
73
|
+
* - interactive → proceed;
|
|
74
|
+
* - not interactive + a default was supplied → resolve to it immediately;
|
|
75
|
+
* - otherwise → throw `CRUXY_E_INTERACTIVE_REQUIRED` (usage, exit 2).
|
|
76
|
+
* Never blocks on a pipe, never silently picks an option.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveNonInteractive<T>(io: ComponentIO, what: string, defaultValue: T | undefined, alternatives?: string[]): InteractiveResult<T> | null;
|