@cruxy/cli 0.9.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.
@@ -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`
@@ -75,37 +76,13 @@ function scopeLabel(scope) {
75
76
  export function defaultPromptIO(color) {
76
77
  return {
77
78
  write: (text) => void process.stderr.write(text),
78
- readKey: readKeyFromStdin,
79
+ // The shared U.7 raw-mode reader: one keypress, cooked mode always
80
+ // restored. Ctrl-C / EOF / escape resolve "" → the default-deny path.
81
+ readKey: () => readSingleKey(),
79
82
  readLine: readLineFromStdin,
80
83
  color,
81
84
  };
82
85
  }
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
86
  /** Read one line in cooked mode; "" on EOF. */
110
87
  function readLineFromStdin() {
111
88
  const stdin = process.stdin;
@@ -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,
@@ -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;
@@ -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,6 @@
1
+ export * from "./keys.js";
2
+ export * from "./input.js";
3
+ export * from "./frame.js";
4
+ export * from "./select.js";
5
+ export * from "./fuzzy.js";
6
+ export * from "./autocomplete.js";
@@ -0,0 +1,6 @@
1
+ export * from "./keys.js";
2
+ export * from "./input.js";
3
+ export * from "./frame.js";
4
+ export * from "./select.js";
5
+ export * from "./fuzzy.js";
6
+ export * from "./autocomplete.js";
@@ -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;
@@ -0,0 +1,111 @@
1
+ import { interactiveRequired } from "../errors/index.js";
2
+ import { detectCapabilities } from "../render/index.js";
3
+ import { decodeKeys } from "./keys.js";
4
+ /** Build the real reader over `stdin` (or an injected fake in tests). */
5
+ export function createKeyReader(stdin = process.stdin) {
6
+ const queue = [];
7
+ const waiters = [];
8
+ let active = false;
9
+ let ended = false;
10
+ const push = (key) => {
11
+ const waiter = waiters.shift();
12
+ if (waiter)
13
+ waiter(key);
14
+ else
15
+ queue.push(key);
16
+ };
17
+ const onData = (chunk) => {
18
+ for (const key of decodeKeys(chunk))
19
+ push(key);
20
+ };
21
+ const onEnd = () => {
22
+ ended = true;
23
+ // Wake every waiter: EOF is a key, not an error.
24
+ while (waiters.length > 0)
25
+ push({ kind: "eof" });
26
+ };
27
+ return {
28
+ begin() {
29
+ if (active)
30
+ return;
31
+ active = true;
32
+ if (stdin.isTTY)
33
+ stdin.setRawMode?.(true);
34
+ stdin.resume();
35
+ stdin.on("data", onData);
36
+ stdin.on("end", onEnd);
37
+ },
38
+ read() {
39
+ const next = queue.shift();
40
+ if (next)
41
+ return Promise.resolve(next);
42
+ if (ended || !active)
43
+ return Promise.resolve({ kind: "eof" });
44
+ return new Promise((resolve) => waiters.push(resolve));
45
+ },
46
+ restore() {
47
+ if (!active)
48
+ return;
49
+ active = false;
50
+ stdin.removeListener("data", onData);
51
+ stdin.removeListener("end", onEnd);
52
+ if (stdin.isTTY)
53
+ stdin.setRawMode?.(false);
54
+ stdin.pause();
55
+ // Unblock anything still awaiting a key — restore means the interaction
56
+ // is over, and a hung promise would leak the caller.
57
+ while (waiters.length > 0)
58
+ waiters.shift()({ kind: "eof" });
59
+ },
60
+ };
61
+ }
62
+ /**
63
+ * Read exactly one key with the full begin/restore lifecycle — the shared
64
+ * backend for the approval prompt's single-key read and the onboarding
65
+ * `readKey`. Resolves the printable character, `"\n"` for enter, and `""` for
66
+ * anything that means "no answer" (Ctrl-C, EOF, escape, arrows) — preserving
67
+ * the callers' default-deny mapping.
68
+ */
69
+ export async function readSingleKey(stdin = process.stdin) {
70
+ const keys = createKeyReader(stdin);
71
+ keys.begin();
72
+ try {
73
+ const key = await keys.read();
74
+ switch (key.kind) {
75
+ case "char":
76
+ return key.char;
77
+ case "enter":
78
+ return "\n";
79
+ default:
80
+ return "";
81
+ }
82
+ }
83
+ finally {
84
+ keys.restore();
85
+ }
86
+ }
87
+ /** The real environment: frames to stderr, keys from stdin, caps from stderr. */
88
+ export function defaultComponentIO() {
89
+ const caps = detectCapabilities(process.stderr);
90
+ return {
91
+ caps,
92
+ interactive: Boolean(process.stdin.isTTY) && caps.cursor,
93
+ write: (text) => void process.stderr.write(text),
94
+ keys: createKeyReader(process.stdin),
95
+ };
96
+ }
97
+ /**
98
+ * The shared non-TTY gate, run before any frame byte or key read:
99
+ * - interactive → proceed;
100
+ * - not interactive + a default was supplied → resolve to it immediately;
101
+ * - otherwise → throw `CRUXY_E_INTERACTIVE_REQUIRED` (usage, exit 2).
102
+ * Never blocks on a pipe, never silently picks an option.
103
+ */
104
+ export function resolveNonInteractive(io, what, defaultValue, alternatives = []) {
105
+ if (io.interactive)
106
+ return null;
107
+ if (defaultValue !== undefined) {
108
+ return { kind: "selected", value: defaultValue };
109
+ }
110
+ throw interactiveRequired(what, alternatives);
111
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Byte → key decoding for the interactive components (U.7). Pure data
3
+ * transformation — no terminal, no state — so every mapping row is directly
4
+ * unit-testable. The stateful raw-mode plumbing lives in `input.ts`.
5
+ */
6
+ /** One decoded keypress. The full vocabulary the U.7 components consume. */
7
+ export type Key =
8
+ /** A printable character (UTF-8; may be multi-byte, never a control char). */
9
+ {
10
+ kind: "char";
11
+ char: string;
12
+ } | {
13
+ kind: "enter";
14
+ } | {
15
+ kind: "backspace";
16
+ } | {
17
+ kind: "tab";
18
+ } | {
19
+ kind: "escape";
20
+ } | {
21
+ kind: "up";
22
+ } | {
23
+ kind: "down";
24
+ } | {
25
+ kind: "left";
26
+ } | {
27
+ kind: "right";
28
+ }
29
+ /** Ctrl-C in raw mode (no SIGINT is delivered) — components must cancel. */
30
+ | {
31
+ kind: "ctrl-c";
32
+ }
33
+ /** Ctrl-D or stream end — components must cancel. */
34
+ | {
35
+ kind: "eof";
36
+ };
37
+ /**
38
+ * Decode one raw-mode chunk into keys. A paste or fast typing delivers many
39
+ * keys per chunk; all are returned in order.
40
+ *
41
+ * Escape handling is deliberately simple: `ESC [ A..D` decodes to an arrow,
42
+ * any other CSI sequence (`ESC [ …final`) is swallowed whole (unmapped keys
43
+ * must not leak garbage chars into a query), and a lone ESC decodes to
44
+ * `escape`. Terminals send arrow sequences atomically in practice; a sequence
45
+ * split across chunks degrades to `escape` + literal chars, which is safe
46
+ * (escape cancels).
47
+ */
48
+ export declare function decodeKeys(chunk: Buffer | string): Key[];
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Byte → key decoding for the interactive components (U.7). Pure data
3
+ * transformation — no terminal, no state — so every mapping row is directly
4
+ * unit-testable. The stateful raw-mode plumbing lives in `input.ts`.
5
+ */
6
+ const CTRL_C = 0x03;
7
+ const CTRL_D = 0x04;
8
+ const BACKSPACE = 0x08;
9
+ const TAB = 0x09;
10
+ const LF = 0x0a;
11
+ const CR = 0x0d;
12
+ const ESC = 0x1b;
13
+ const DELETE = 0x7f;
14
+ /** CSI final byte → arrow key, for `ESC [ <final>` sequences. */
15
+ const ARROWS = {
16
+ 0x41: "up", // A
17
+ 0x42: "down", // B
18
+ 0x43: "right", // C
19
+ 0x44: "left", // D
20
+ };
21
+ /**
22
+ * Decode one raw-mode chunk into keys. A paste or fast typing delivers many
23
+ * keys per chunk; all are returned in order.
24
+ *
25
+ * Escape handling is deliberately simple: `ESC [ A..D` decodes to an arrow,
26
+ * any other CSI sequence (`ESC [ …final`) is swallowed whole (unmapped keys
27
+ * must not leak garbage chars into a query), and a lone ESC decodes to
28
+ * `escape`. Terminals send arrow sequences atomically in practice; a sequence
29
+ * split across chunks degrades to `escape` + literal chars, which is safe
30
+ * (escape cancels).
31
+ */
32
+ export function decodeKeys(chunk) {
33
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
34
+ const keys = [];
35
+ for (let i = 0; i < buf.length;) {
36
+ const byte = buf[i];
37
+ if (byte === ESC) {
38
+ if (buf[i + 1] === 0x5b /* [ */) {
39
+ // CSI: consume parameter/intermediate bytes (0x20–0x3f) up to the
40
+ // final byte (0x40–0x7e); map arrows, swallow everything else.
41
+ let j = i + 2;
42
+ while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x3f)
43
+ j++;
44
+ if (j < buf.length) {
45
+ // Only a bare `ESC [ <final>` maps to an arrow; parameterized
46
+ // sequences (modifier arrows, Home/End variants) are swallowed —
47
+ // an unmapped combo must do nothing, not act as a plain arrow.
48
+ const arrow = j === i + 2 ? ARROWS[buf[j]] : undefined;
49
+ if (arrow)
50
+ keys.push({ kind: arrow });
51
+ i = j + 1;
52
+ continue;
53
+ }
54
+ // Truncated CSI at chunk end: treat as escape, drop the partial tail.
55
+ keys.push({ kind: "escape" });
56
+ i = buf.length;
57
+ continue;
58
+ }
59
+ keys.push({ kind: "escape" });
60
+ i++;
61
+ continue;
62
+ }
63
+ if (byte === CTRL_C) {
64
+ keys.push({ kind: "ctrl-c" });
65
+ i++;
66
+ continue;
67
+ }
68
+ if (byte === CTRL_D) {
69
+ keys.push({ kind: "eof" });
70
+ i++;
71
+ continue;
72
+ }
73
+ if (byte === CR || byte === LF) {
74
+ keys.push({ kind: "enter" });
75
+ // Swallow a CRLF pair as one enter.
76
+ if (byte === CR && buf[i + 1] === LF)
77
+ i++;
78
+ i++;
79
+ continue;
80
+ }
81
+ if (byte === BACKSPACE || byte === DELETE) {
82
+ keys.push({ kind: "backspace" });
83
+ i++;
84
+ continue;
85
+ }
86
+ if (byte === TAB) {
87
+ keys.push({ kind: "tab" });
88
+ i++;
89
+ continue;
90
+ }
91
+ if (byte < 0x20) {
92
+ // Other control chars: ignore (same rule as the secret reader).
93
+ i++;
94
+ continue;
95
+ }
96
+ // Printable: consume one UTF-8 code point (1–4 bytes by lead byte).
97
+ const len = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
98
+ keys.push({
99
+ kind: "char",
100
+ char: buf.subarray(i, Math.min(i + len, buf.length)).toString("utf8"),
101
+ });
102
+ i += len;
103
+ }
104
+ return keys;
105
+ }
@@ -0,0 +1,28 @@
1
+ import { type ComponentIO, type InteractiveResult } from "./input.js";
2
+ /**
3
+ * SelectList (U.7): arrow-key pick from a short list of options. Single-select
4
+ * only — multi-select is a deliberate seam for a later build (the result type
5
+ * and key loop extend without breaking callers).
6
+ */
7
+ export interface SelectOptions<T> {
8
+ /** Label an item renders under. Defaults to `String(item)`. */
9
+ toLabel?: (item: T) => string;
10
+ /** Header row above the options. */
11
+ title?: string;
12
+ /** Index highlighted first (so Enter-once picks a sensible default). */
13
+ initialIndex?: number;
14
+ /** Non-TTY fallback: resolve to this instead of failing loud. */
15
+ defaultValue?: T;
16
+ /** `--flag` alternatives listed in the non-TTY error. */
17
+ nonInteractiveHint?: string[];
18
+ /** Visible rows before the list scrolls (default 10). */
19
+ maxVisible?: number;
20
+ }
21
+ /**
22
+ * Pick one item: ↑/↓ move (wrapping past either end), Enter selects the
23
+ * highlighted item, Esc / Ctrl-C / EOF cancel. Renders a transient frame via
24
+ * the shared U.7 frame (width-truncated rows, no soft-wrap) and erases it
25
+ * completely before resolving. Empty `items` resolves cancelled — there is
26
+ * nothing to select.
27
+ */
28
+ export declare function selectList<T>(items: readonly T[], opts?: SelectOptions<T>, io?: ComponentIO): Promise<InteractiveResult<T>>;
@@ -0,0 +1,69 @@
1
+ import pc from "picocolors";
2
+ import { createFrame } from "./frame.js";
3
+ import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
4
+ /**
5
+ * Pick one item: ↑/↓ move (wrapping past either end), Enter selects the
6
+ * highlighted item, Esc / Ctrl-C / EOF cancel. Renders a transient frame via
7
+ * the shared U.7 frame (width-truncated rows, no soft-wrap) and erases it
8
+ * completely before resolving. Empty `items` resolves cancelled — there is
9
+ * nothing to select.
10
+ */
11
+ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
12
+ const fallback = resolveNonInteractive(io, opts.title ?? "this picker", opts.defaultValue, opts.nonInteractiveHint);
13
+ if (fallback)
14
+ return fallback;
15
+ if (items.length === 0)
16
+ return { kind: "cancelled" };
17
+ const toLabel = opts.toLabel ?? ((item) => String(item));
18
+ const colors = pc.createColors(io.caps.color);
19
+ const maxVisible = opts.maxVisible ?? 10;
20
+ const frame = createFrame(io.write, io.caps);
21
+ let cursor = Math.min(Math.max(opts.initialIndex ?? 0, 0), items.length - 1);
22
+ const paint = () => {
23
+ const lines = [];
24
+ if (opts.title)
25
+ lines.push(colors.bold(opts.title));
26
+ const top = Math.min(Math.max(0, cursor - maxVisible + 1), Math.max(0, items.length - maxVisible));
27
+ const visible = items.slice(top, top + maxVisible);
28
+ for (const [i, item] of visible.entries()) {
29
+ const selected = top + i === cursor;
30
+ const marker = selected ? colors.cyan("❯") : " ";
31
+ const label = toLabel(item);
32
+ lines.push(`${marker} ${selected ? label : colors.dim(label)}`);
33
+ }
34
+ const hidden = items.length - visible.length;
35
+ if (hidden > 0)
36
+ lines.push(colors.dim(` … ${hidden} more`));
37
+ lines.push(colors.dim(" ↑/↓ move · enter select · esc cancel"));
38
+ frame.render(lines);
39
+ };
40
+ io.keys.begin();
41
+ try {
42
+ paint();
43
+ for (;;) {
44
+ const key = await io.keys.read();
45
+ switch (key.kind) {
46
+ case "ctrl-c":
47
+ case "eof":
48
+ case "escape":
49
+ return { kind: "cancelled" };
50
+ case "enter":
51
+ return { kind: "selected", value: items[cursor] };
52
+ case "up":
53
+ cursor = (cursor - 1 + items.length) % items.length;
54
+ break;
55
+ case "down":
56
+ cursor = (cursor + 1) % items.length;
57
+ break;
58
+ default:
59
+ break; // chars/tab have no meaning in a plain select
60
+ }
61
+ paint();
62
+ }
63
+ }
64
+ finally {
65
+ // Selection or cancel alike: no leftover frame bytes, raw mode released.
66
+ frame.clear();
67
+ io.keys.restore();
68
+ }
69
+ }
@@ -2,6 +2,13 @@ import { CruxyError } from "./types.js";
2
2
  /** Best-effort human message for an arbitrary thrown value. */
3
3
  export declare function messageOf(underlying: unknown): string | undefined;
4
4
  export declare function usageError(title: string, nextSteps?: string[]): CruxyError;
5
+ /**
6
+ * An interactive component (picker, fuzzy finder) was needed but stdin is not
7
+ * an interactive terminal and the caller supplied no default (U.7). Never
8
+ * silently picks an option, never blocks on a pipe — same discipline as the
9
+ * approval/onboarding layers.
10
+ */
11
+ export declare function interactiveRequired(what: string, alternatives?: string[]): CruxyError;
5
12
  export declare function configKeyUnknown(key: string): CruxyError;
6
13
  export declare function providerUnsupported(provider: string): CruxyError;
7
14
  export declare function configParse(path: string, underlying?: unknown): CruxyError;
@@ -23,6 +23,21 @@ export function usageError(title, nextSteps) {
23
23
  nextSteps: nextSteps ?? ["run `cruxy --help` for usage"],
24
24
  });
25
25
  }
26
+ /**
27
+ * An interactive component (picker, fuzzy finder) was needed but stdin is not
28
+ * an interactive terminal and the caller supplied no default (U.7). Never
29
+ * silently picks an option, never blocks on a pipe — same discipline as the
30
+ * approval/onboarding layers.
31
+ */
32
+ export function interactiveRequired(what, alternatives = []) {
33
+ return new CruxyError({
34
+ code: ErrorCode.InteractiveRequired,
35
+ title: `${what} needs an interactive terminal`,
36
+ cause: "stdin is not a TTY (or the terminal cannot render an interactive picker)",
37
+ nextSteps: ["run cruxy in an interactive terminal", ...alternatives],
38
+ meta: { what },
39
+ });
40
+ }
26
41
  export function configKeyUnknown(key) {
27
42
  return new CruxyError({
28
43
  code: ErrorCode.ConfigKeyUnknown,
@@ -14,6 +14,7 @@
14
14
  export declare const ErrorCode: {
15
15
  readonly Internal: "CRUXY_E_INTERNAL";
16
16
  readonly Usage: "CRUXY_E_USAGE";
17
+ readonly InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED";
17
18
  readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
18
19
  readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
19
20
  readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
@@ -16,6 +16,7 @@ export const ErrorCode = {
16
16
  Internal: "CRUXY_E_INTERNAL",
17
17
  // usage (exit 2)
18
18
  Usage: "CRUXY_E_USAGE",
19
+ InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED",
19
20
  ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
20
21
  ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
21
22
  GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
@@ -67,6 +68,7 @@ export const ErrorCode = {
67
68
  const EXIT_CODES = {
68
69
  [ErrorCode.Internal]: 1,
69
70
  [ErrorCode.Usage]: 2,
71
+ [ErrorCode.InteractiveRequired]: 2,
70
72
  [ErrorCode.ConfigKeyUnknown]: 2,
71
73
  [ErrorCode.ProviderUnsupported]: 2,
72
74
  [ErrorCode.GitProtectedBranch]: 2,
@@ -2,7 +2,8 @@ import type { OnboardingIO } from "./types.js";
2
2
  /**
3
3
  * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
4
  * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
- * the real character. All readers restore cooked mode on the way out, even on
6
- * Ctrl-C / EOF — the terminal is never left in raw mode.
5
+ * the real character. Raw-mode key handling is the shared U.7 reader
6
+ * (`components/input.ts`) — the single owner of `setRawMode` so cooked mode
7
+ * is restored on every exit path, Ctrl-C / EOF included.
7
8
  */
8
9
  export declare function defaultOnboardingIO(color?: boolean): OnboardingIO;
@@ -1,54 +1,21 @@
1
+ import { createKeyReader, readSingleKey } from "../components/input.js";
1
2
  import { shouldUseColor } from "../errors/index.js";
2
3
  /**
3
4
  * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
5
  * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
- * the real character. All readers restore cooked mode on the way out, even on
6
- * Ctrl-C / EOF — the terminal is never left in raw mode.
6
+ * the real character. Raw-mode key handling is the shared U.7 reader
7
+ * (`components/input.ts`) — the single owner of `setRawMode` so cooked mode
8
+ * is restored on every exit path, Ctrl-C / EOF included.
7
9
  */
8
10
  export function defaultOnboardingIO(color = shouldUseColor()) {
9
11
  return {
10
12
  write: (text) => void process.stderr.write(text),
11
13
  readLine: readLineFromStdin,
12
- readKey: readKeyFromStdin,
14
+ readKey: () => readSingleKey(),
13
15
  readSecret: readSecretFromStdin,
14
16
  color,
15
17
  };
16
18
  }
17
- const CTRL_C = 0x03;
18
- const CTRL_D = 0x04;
19
- const BACKSPACE = 0x08;
20
- const DELETE = 0x7f;
21
- const LF = 0x0a;
22
- const CR = 0x0d;
23
- /** Read one keypress in raw mode; "" on EOF / Ctrl-C / Ctrl-D. Restores cooked mode. */
24
- function readKeyFromStdin() {
25
- const stdin = process.stdin;
26
- return new Promise((resolve) => {
27
- const cleanup = () => {
28
- stdin.removeListener("data", onData);
29
- stdin.removeListener("end", onEnd);
30
- if (stdin.isTTY)
31
- stdin.setRawMode(false);
32
- stdin.pause();
33
- };
34
- const onData = (buf) => {
35
- cleanup();
36
- const code = buf[0];
37
- resolve(code === CTRL_C || code === CTRL_D
38
- ? ""
39
- : buf.toString("utf8").slice(0, 1));
40
- };
41
- const onEnd = () => {
42
- cleanup();
43
- resolve("");
44
- };
45
- if (stdin.isTTY)
46
- stdin.setRawMode(true);
47
- stdin.resume();
48
- stdin.once("data", onData);
49
- stdin.once("end", onEnd);
50
- });
51
- }
52
19
  /** Read one line in cooked mode; "" on EOF. */
53
20
  function readLineFromStdin() {
54
21
  const stdin = process.stdin;
@@ -80,54 +47,41 @@ function readLineFromStdin() {
80
47
  }
81
48
  /**
82
49
  * Read a secret with no echo: each printable keystroke shows a `*`, backspace
83
- * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The real
84
- * characters are never written anywhere.
50
+ * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The
51
+ * real characters are never written anywhere. Built on the shared key reader,
52
+ * which also keeps arrow/escape sequences from leaking into the secret.
85
53
  */
86
- function readSecretFromStdin() {
87
- const stdin = process.stdin;
54
+ async function readSecretFromStdin() {
88
55
  const out = process.stderr;
89
- return new Promise((resolve) => {
90
- let buf = "";
91
- let done = false;
92
- const cleanup = () => {
93
- stdin.removeListener("data", onData);
94
- stdin.removeListener("end", onEnd);
95
- if (stdin.isTTY)
96
- stdin.setRawMode(false);
97
- stdin.pause();
98
- };
99
- const finish = (value) => {
100
- if (done)
101
- return;
102
- done = true;
103
- cleanup();
104
- out.write("\n");
105
- resolve(value);
106
- };
107
- const onData = (chunk) => {
108
- for (const byte of chunk) {
109
- if (byte === CR || byte === LF)
110
- return finish(buf); // Enter → submit
111
- if (byte === CTRL_C || byte === CTRL_D)
112
- return finish(""); // abort
113
- if (byte === DELETE || byte === BACKSPACE) {
56
+ const keys = createKeyReader(process.stdin);
57
+ keys.begin();
58
+ let buf = "";
59
+ try {
60
+ for (;;) {
61
+ const key = await keys.read();
62
+ switch (key.kind) {
63
+ case "enter":
64
+ return buf;
65
+ case "ctrl-c":
66
+ case "eof":
67
+ return "";
68
+ case "backspace":
114
69
  if (buf.length > 0) {
115
70
  buf = buf.slice(0, -1);
116
71
  out.write("\b \b"); // erase one star
117
72
  }
118
- continue;
119
- }
120
- if (byte < 0x20)
121
- continue; // ignore other control chars
122
- buf += String.fromCharCode(byte);
123
- out.write("*");
73
+ break;
74
+ case "char":
75
+ buf += key.char;
76
+ out.write("*");
77
+ break;
78
+ default:
79
+ break; // arrows / tab / escape: ignored, never echoed
124
80
  }
125
- };
126
- const onEnd = () => finish("");
127
- if (stdin.isTTY)
128
- stdin.setRawMode(true);
129
- stdin.resume();
130
- stdin.on("data", onData);
131
- stdin.once("end", onEnd);
132
- });
81
+ }
82
+ }
83
+ finally {
84
+ keys.restore();
85
+ out.write("\n");
86
+ }
133
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {