@velum-labs/routekit-cli-ui 0.9.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/prompt.js ADDED
@@ -0,0 +1,246 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The prompt facade. On a raw-capable interactive TTY prompts render as Ink
4
+ * components (arrow-key select, checkbox multi-select, live confirm/text);
5
+ * otherwise they fall back to numbered/line prompts read from buffered stdin,
6
+ * so piped answers and CI keep working
7
+ * exactly as before. Every prompt settles into a persistent one-line answer.
8
+ */
9
+ import { createInterface } from "node:readline";
10
+ import { canPromptInteractively, isInteractive, uiStream } from "./runtime.js";
11
+ import { bold, cyan, dim, glyph, gray, green } from "./theme.js";
12
+ import { mountInk, settleInk } from "./ink/presenter.js";
13
+ import { ConfirmPrompt, FuzzySelectPrompt, GhostTextPrompt, MultiSelectPrompt, SelectPrompt, TextPrompt } from "./ink/prompts.js";
14
+ import { Store } from "./ink/store.js";
15
+ /** Returned by prompts with `allowBack: true` when the user presses Esc. */
16
+ export const BACK = Symbol("prompt.back");
17
+ const out = uiStream();
18
+ // For non-interactive input (piped/redirected/empty stdin) we read all of stdin
19
+ // exactly once and serve answers line by line. This supports scripted input
20
+ // and falls back to "" (the prompt
21
+ // default) once exhausted — without the fragile behavior of attaching multiple
22
+ // readline interfaces to an already-ended stdin.
23
+ let bufferedLines;
24
+ let bufferedRead = false;
25
+ async function ensureBufferedStdin() {
26
+ if (bufferedRead)
27
+ return;
28
+ bufferedRead = true;
29
+ if (process.stdin.isTTY || process.stdin.readableEnded) {
30
+ bufferedLines = [];
31
+ return;
32
+ }
33
+ const chunks = [];
34
+ await new Promise((resolve) => {
35
+ process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
36
+ process.stdin.once("end", () => resolve());
37
+ process.stdin.once("error", () => resolve());
38
+ });
39
+ bufferedLines = Buffer.concat(chunks).toString("utf8").split("\n");
40
+ }
41
+ /**
42
+ * Read a single line from stdin, prompting on stderr. On a TTY this reads live;
43
+ * otherwise it draws from buffered stdin and resolves to "" when there is no
44
+ * more input, so callers fall back to their default instead of hanging.
45
+ */
46
+ async function readLine(promptText) {
47
+ if (!process.stdin.isTTY) {
48
+ out.write(promptText);
49
+ await ensureBufferedStdin();
50
+ const next = bufferedLines?.shift();
51
+ out.write("\n");
52
+ return (next ?? "").trim();
53
+ }
54
+ return new Promise((resolve) => {
55
+ const rl = createInterface({ input: process.stdin, output: out });
56
+ let answered = false;
57
+ rl.question(promptText, (answer) => {
58
+ answered = true;
59
+ rl.close();
60
+ resolve(answer.trim());
61
+ });
62
+ rl.on("close", () => {
63
+ if (!answered)
64
+ resolve("");
65
+ });
66
+ });
67
+ }
68
+ /** True when prompts should render as Ink components. */
69
+ function richPrompts() {
70
+ return canPromptInteractively() && isInteractive();
71
+ }
72
+ /** Mount an Ink prompt, resolve on submit, and settle to a one-line answer. */
73
+ function runInkPrompt(build) {
74
+ return new Promise((resolve) => {
75
+ let settled = false;
76
+ const node = build({
77
+ submit: (value, answer) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ settleInk(instance);
82
+ out.write(`${green(glyph.tick())} ${answer}\n`);
83
+ resolve(value);
84
+ },
85
+ abort: () => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ settleInk(instance);
90
+ out.write("\n");
91
+ process.exit(130);
92
+ },
93
+ back: () => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ settleInk(instance);
98
+ out.write(`${gray(glyph.arrow())} ${dim("back")}\n`);
99
+ resolve(BACK);
100
+ }
101
+ });
102
+ const instance = mountInk(node);
103
+ });
104
+ }
105
+ function answerLine(message, answer) {
106
+ return `${bold(message)} ${dim(`· ${answer}`)}`;
107
+ }
108
+ function optionAt(options, index) {
109
+ const option = options[index];
110
+ if (option === undefined)
111
+ throw new Error(`option index out of range: ${index}`);
112
+ return option;
113
+ }
114
+ export async function select(input) {
115
+ const { options } = input;
116
+ if (options.length === 0)
117
+ throw new Error("select requires at least one option");
118
+ const fallbackIndex = Math.min(Math.max(input.defaultIndex ?? 0, 0), options.length - 1);
119
+ if (!richPrompts()) {
120
+ return selectNumbered(input.message, options, fallbackIndex);
121
+ }
122
+ return runInkPrompt(({ submit, abort, back }) => (_jsx(SelectPrompt, { message: input.message, options: options, defaultIndex: fallbackIndex, onSubmit: (value, label) => submit(value, answerLine(input.message, label)), onAbort: abort, onBack: input.allowBack === true ? back : undefined })));
123
+ }
124
+ export async function fuzzySelect(input) {
125
+ let options = input.options;
126
+ if (!richPrompts()) {
127
+ if (options.length === 0 && input.refresh !== undefined) {
128
+ try {
129
+ options = await input.refresh();
130
+ }
131
+ catch {
132
+ // no fresh data either; fall through to the empty-list guard
133
+ }
134
+ }
135
+ if (options.length === 0)
136
+ throw new Error(`no options available: ${input.message}`);
137
+ return selectNumbered(input.message, options, 0);
138
+ }
139
+ const feed = new Store({
140
+ options: options,
141
+ loading: input.refresh !== undefined,
142
+ ...(input.refreshNote !== undefined ? { note: input.refreshNote } : {})
143
+ });
144
+ if (input.refresh !== undefined) {
145
+ void input
146
+ .refresh()
147
+ .then((fresh) => {
148
+ feed.set((state) => ({ ...state, options: fresh, loading: false }));
149
+ })
150
+ .catch(() => {
151
+ feed.set((state) => ({ ...state, loading: false }));
152
+ });
153
+ }
154
+ return runInkPrompt(({ submit, abort, back }) => (_jsx(FuzzySelectPrompt, { message: input.message, feed: feed, placeholder: input.placeholder, onSubmit: (value, label) => submit(value, answerLine(input.message, label)), onAbort: abort, onBack: input.allowBack === true ? back : undefined })));
155
+ }
156
+ /**
157
+ * Free text with an inline ghost suggestion completed from `suggestions`
158
+ * (Tab or → accepts). Falls back to the plain text prompt off-TTY.
159
+ */
160
+ export async function autocompleteText(input) {
161
+ if (!richPrompts()) {
162
+ return text({
163
+ message: input.message,
164
+ ...(input.defaultValue !== undefined ? { defaultValue: input.defaultValue } : {}),
165
+ ...(input.placeholder !== undefined ? { placeholder: input.placeholder } : {})
166
+ });
167
+ }
168
+ return runInkPrompt(({ submit, abort, back }) => (_jsx(GhostTextPrompt, { message: input.message, suggestions: input.suggestions, defaultValue: input.defaultValue ?? "", placeholder: input.placeholder, onSubmit: (value) => submit(value, answerLine(input.message, value.length > 0 ? value : "(empty)")), onAbort: abort, onBack: input.allowBack === true ? back : undefined })));
169
+ }
170
+ async function selectNumbered(message, options, fallbackIndex) {
171
+ out.write(`${bold(message)}\n`);
172
+ options.forEach((option, index) => {
173
+ const marker = index === fallbackIndex ? cyan(`${index + 1}`) : `${index + 1}`;
174
+ const hint = option.hint !== undefined ? dim(` — ${option.hint}`) : "";
175
+ out.write(` ${marker}) ${option.label}${hint}\n`);
176
+ });
177
+ const answer = await readLine(`Choose [1-${options.length}] (${fallbackIndex + 1}): `);
178
+ if (answer.length === 0)
179
+ return optionAt(options, fallbackIndex).value;
180
+ const byNumber = Number.parseInt(answer, 10);
181
+ if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= options.length) {
182
+ return optionAt(options, byNumber - 1).value;
183
+ }
184
+ const byLabel = options.findIndex((option) => option.label.toLowerCase() === answer.toLowerCase());
185
+ if (byLabel >= 0)
186
+ return optionAt(options, byLabel).value;
187
+ return optionAt(options, fallbackIndex).value;
188
+ }
189
+ /**
190
+ * Multi-choice selection. On a raw-capable TTY this is an Ink checkbox list;
191
+ * otherwise it reads comma-separated numbers from stdin (empty input keeps the
192
+ * default selection).
193
+ */
194
+ export async function multiselect(input) {
195
+ const { options } = input;
196
+ if (options.length === 0)
197
+ return [];
198
+ const defaults = new Set((input.defaultSelected ?? []).filter((index) => index >= 0 && index < options.length));
199
+ if (!richPrompts()) {
200
+ out.write(`${bold(input.message)}\n`);
201
+ options.forEach((option, index) => {
202
+ const marker = defaults.has(index) ? cyan(`${index + 1}`) : `${index + 1}`;
203
+ const hint = option.hint !== undefined ? dim(` — ${option.hint}`) : "";
204
+ out.write(` ${marker}) ${option.label}${hint}\n`);
205
+ });
206
+ const fallback = [...defaults].sort((left, right) => left - right).map((index) => index + 1);
207
+ const answer = await readLine(`Choose numbers, comma-separated (${fallback.length > 0 ? fallback.join(",") : "none"}): `);
208
+ const picked = answer.length === 0 ? fallback : answer.split(",").map((part) => Number.parseInt(part.trim(), 10));
209
+ const indices = [...new Set(picked)]
210
+ .filter((num) => Number.isInteger(num) && num >= 1 && num <= options.length)
211
+ .map((num) => num - 1)
212
+ .sort((left, right) => left - right);
213
+ return indices.map((index) => optionAt(options, index).value);
214
+ }
215
+ // No onBack handler is mounted, so BACK can never resolve here.
216
+ return runInkPrompt(({ submit, abort }) => (_jsx(MultiSelectPrompt, { message: input.message, options: options, defaultSelected: defaults, onSubmit: (values, labels) => submit(values, answerLine(input.message, labels.length > 0 ? labels.join(", ") : "(none)")), onAbort: abort })));
217
+ }
218
+ export async function confirm(input) {
219
+ const def = input.defaultValue ?? false;
220
+ if (!richPrompts()) {
221
+ const hint = def ? "[Y/n]" : "[y/N]";
222
+ const answer = (await readLine(`${bold(input.message)} ${dim(hint)} `)).toLowerCase();
223
+ if (answer.length === 0)
224
+ return def;
225
+ return answer === "y" || answer === "yes";
226
+ }
227
+ return runInkPrompt(({ submit, abort, back }) => (_jsx(ConfirmPrompt, { message: input.message, defaultValue: def, onSubmit: (value) => submit(value, answerLine(input.message, value ? "yes" : "no")), onAbort: abort, onBack: input.allowBack === true ? back : undefined })));
228
+ }
229
+ export async function text(input) {
230
+ if (!richPrompts()) {
231
+ const suffix = input.defaultValue !== undefined && input.defaultValue.length > 0 ? dim(` (${input.defaultValue})`) : "";
232
+ const answer = await readLine(`${bold(input.message)}${suffix} `);
233
+ if (answer.length === 0)
234
+ return input.defaultValue ?? "";
235
+ return answer;
236
+ }
237
+ return runInkPrompt(({ submit, abort, back }) => (_jsx(TextPrompt, { message: input.message, defaultValue: input.defaultValue ?? "", ...(input.placeholder !== undefined ? { placeholder: input.placeholder } : {}), onSubmit: (value) => submit(value, answerLine(input.message, value.length > 0 ? value : "(empty)")), onAbort: abort, onBack: input.allowBack === true ? back : undefined })));
238
+ }
239
+ /** A success line for the end of a wizard. */
240
+ export function done(message) {
241
+ out.write(`${green(glyph.tick())} ${message}\n`);
242
+ }
243
+ /** A neutral note line. */
244
+ export function note(message) {
245
+ out.write(`${gray(glyph.arrow())} ${message}\n`);
246
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Interaction-mode detection. The CLI's rich surfaces (Ink components,
3
+ * prompts, live checklists) only render when we are attached to an interactive
4
+ * terminal and not running under CI; otherwise everything degrades to plain
5
+ * line logs so pipes, captures, and `node --test` stay deterministic.
6
+ */
7
+ /** True under a recognized CI environment. */
8
+ export declare function isCI(): boolean;
9
+ /** The stream all UI is written to (stderr; stdout is reserved for tool output). */
10
+ export declare function uiStream(): NodeJS.WriteStream;
11
+ /**
12
+ * Force non-interactive mode for the rest of the process (the `--no-input` /
13
+ * `--json` global flags). Also exports `ROUTEKIT_NO_TUI=1` so spawned children
14
+ * inherit the same posture.
15
+ */
16
+ export declare function forceNonInteractive(): void;
17
+ /** True when we should render rich, animated UI to `stream`. */
18
+ export declare function isInteractive(stream?: NodeJS.WriteStream): boolean;
19
+ /** True when we can read interactive keypresses (raw mode) from stdin. */
20
+ export declare function canPromptInteractively(): boolean;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Interaction-mode detection. The CLI's rich surfaces (Ink components,
3
+ * prompts, live checklists) only render when we are attached to an interactive
4
+ * terminal and not running under CI; otherwise everything degrades to plain
5
+ * line logs so pipes, captures, and `node --test` stay deterministic.
6
+ */
7
+ /** True under a recognized CI environment. */
8
+ export function isCI() {
9
+ const env = process.env;
10
+ return Boolean(env.CI === "true" ||
11
+ env.CI === "1" ||
12
+ env.CONTINUOUS_INTEGRATION ||
13
+ env.GITHUB_ACTIONS ||
14
+ env.GITLAB_CI ||
15
+ env.BUILDKITE ||
16
+ env.CIRCLECI);
17
+ }
18
+ /** The stream all UI is written to (stderr; stdout is reserved for tool output). */
19
+ export function uiStream() {
20
+ return process.stderr;
21
+ }
22
+ let forcedNonInteractive = false;
23
+ /**
24
+ * Force non-interactive mode for the rest of the process (the `--no-input` /
25
+ * `--json` global flags). Also exports `ROUTEKIT_NO_TUI=1` so spawned children
26
+ * inherit the same posture.
27
+ */
28
+ export function forceNonInteractive() {
29
+ forcedNonInteractive = true;
30
+ process.env.ROUTEKIT_NO_TUI = "1";
31
+ }
32
+ /** True when we should render rich, animated UI to `stream`. */
33
+ export function isInteractive(stream = uiStream()) {
34
+ if (forcedNonInteractive)
35
+ return false;
36
+ if (process.env.ROUTEKIT_NO_TUI === "1")
37
+ return false;
38
+ if (isCI())
39
+ return false;
40
+ return Boolean(stream.isTTY);
41
+ }
42
+ /** True when we can read interactive keypresses (raw mode) from stdin. */
43
+ export function canPromptInteractively() {
44
+ if (forcedNonInteractive)
45
+ return false;
46
+ return Boolean(process.stdin.isTTY) && !isCI() && process.env.ROUTEKIT_NO_TUI !== "1";
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,230 @@
1
+ import assert from "node:assert/strict";
2
+ import { getEventListeners } from "node:events";
3
+ import { test } from "node:test";
4
+ import { formatBytes, relativeTime } from "../format.js";
5
+ import { PlainPresenter, renderKeyValueLines, renderTableLines } from "../plain.js";
6
+ import { isInteractive } from "../runtime.js";
7
+ import { watch } from "../presenter.js";
8
+ import { bold, box, brandBanner, brandHeader, cyan, glyph, gradient, stripAnsi, supportsColor, wrapAnsi } from "../theme.js";
9
+ function withNoColor(work) {
10
+ const prev = process.env.NO_COLOR;
11
+ process.env.NO_COLOR = "1";
12
+ try {
13
+ work();
14
+ }
15
+ finally {
16
+ if (prev === undefined)
17
+ delete process.env.NO_COLOR;
18
+ else
19
+ process.env.NO_COLOR = prev;
20
+ }
21
+ }
22
+ /** A PlainPresenter capturing its stderr lines for assertions. */
23
+ function capturingPresenter() {
24
+ let output = "";
25
+ const stream = {
26
+ write: (chunk) => {
27
+ output += chunk;
28
+ return true;
29
+ }
30
+ };
31
+ return {
32
+ presenter: new PlainPresenter(stream),
33
+ lines: () => output.split("\n").filter((line) => line.length > 0)
34
+ };
35
+ }
36
+ test("color helpers no-op and glyphs use ASCII when color is disabled", () => {
37
+ withNoColor(() => {
38
+ assert.equal(supportsColor(), false);
39
+ assert.equal(bold("hi"), "hi");
40
+ assert.equal(cyan("hi"), "hi");
41
+ assert.equal(glyph.tick(), "[ok]");
42
+ assert.equal(glyph.cross(), "[x]");
43
+ });
44
+ });
45
+ test("isInteractive is false under the test runner (no TTY)", () => {
46
+ assert.equal(isInteractive(), false);
47
+ });
48
+ test("brandBanner degrades to the one-line header without color", () => {
49
+ withNoColor(() => {
50
+ assert.equal(brandBanner("subtitle"), brandHeader("subtitle"));
51
+ });
52
+ });
53
+ test("stripAnsi removes styling escapes", () => {
54
+ assert.equal(stripAnsi("\u001b[1m\u001b[36mhi\u001b[39m\u001b[22m"), "hi");
55
+ });
56
+ test("gradient returns the text unchanged without truecolor", () => {
57
+ withNoColor(() => {
58
+ assert.equal(gradient("fusionkit"), "fusionkit");
59
+ });
60
+ });
61
+ test("box frames a titled block, aligned to the widest line", () => {
62
+ withNoColor(() => {
63
+ const out = box("t", ["aa", "bbbb"]);
64
+ const lines = out.split("\n");
65
+ assert.equal(lines.length, 4, "top + two body lines + bottom");
66
+ const widths = lines.map((line) => stripAnsi(line).length);
67
+ assert.ok(widths.every((width) => width === widths[0]), `frame lines should share a width, got ${widths.join(",")}`);
68
+ assert.match(out, /bbbb/);
69
+ });
70
+ });
71
+ test("box wraps overflowing lines instead of overflowing the frame", () => {
72
+ withNoColor(() => {
73
+ const long = Array.from({ length: 30 }, (_, index) => `word${index}`).join(" ");
74
+ const out = box("t", ["short", long]);
75
+ const lines = out.split("\n");
76
+ const widths = lines.map((line) => stripAnsi(line).length);
77
+ assert.ok(lines.length > 4, "the long line should wrap into extra body lines");
78
+ assert.ok(widths.every((width) => width === widths[0]), `frame lines should share a width, got ${widths.join(",")}`);
79
+ // Without terminal columns the box caps at 84 total width.
80
+ assert.ok((widths[0] ?? 0) <= 84, `frame width should stay within the cap, got ${widths[0]}`);
81
+ });
82
+ });
83
+ test("wrapAnsi keeps every wrapped line within width and self-contained", () => {
84
+ const styled = `\u001b[2m${Array.from({ length: 12 }, () => "chunk").join(" ")}\u001b[22m end`;
85
+ const lines = wrapAnsi(styled, 24);
86
+ assert.ok(lines.length > 1, "should wrap");
87
+ for (const line of lines) {
88
+ assert.ok(stripAnsi(line).length <= 24, `line too wide: ${JSON.stringify(line)}`);
89
+ }
90
+ // The dim style stays open across breaks: continuations reopen it and every
91
+ // styled line closes with a full reset.
92
+ assert.ok(lines[0]?.startsWith("\u001b[2m"));
93
+ assert.ok(lines[0]?.endsWith("\u001b[0m"));
94
+ assert.ok(lines[1]?.startsWith("\u001b[2m"));
95
+ // A word longer than the width is hard-split rather than overflowing.
96
+ const hard = wrapAnsi("x".repeat(50), 20);
97
+ assert.deepEqual(hard.map((line) => line.length), [20, 20, 10]);
98
+ });
99
+ test("formatBytes uses binary units", () => {
100
+ assert.equal(formatBytes(0), "0 B");
101
+ assert.equal(formatBytes(1024), "1 KB");
102
+ assert.equal(formatBytes(1536), "1.5 KB");
103
+ assert.equal(formatBytes(5 * 1024 ** 3), "5 GB");
104
+ });
105
+ test("relativeTime buckets into s/m/h/d", () => {
106
+ assert.equal(relativeTime(Date.now()), "0s ago");
107
+ assert.equal(relativeTime(Date.now() - 90_000), "2m ago");
108
+ });
109
+ test("renderTableLines aligns columns against visible width", () => {
110
+ withNoColor(() => {
111
+ const lines = renderTableLines([
112
+ ["a", "bb"],
113
+ ["ccc", "d"]
114
+ ], { head: ["x", "y"] });
115
+ assert.deepEqual(lines, ["x y", "a bb", "ccc d"]);
116
+ });
117
+ });
118
+ test("renderKeyValueLines pads labels and appends tags", () => {
119
+ withNoColor(() => {
120
+ const lines = renderKeyValueLines([
121
+ { label: "tool", value: "codex", tag: "(default)" },
122
+ { label: "budget", value: "$5" }
123
+ ]);
124
+ assert.deepEqual(lines, [" tool codex (default)", " budget $5"]);
125
+ });
126
+ });
127
+ test("plain presenter checklist prints one line per transition", () => {
128
+ withNoColor(() => {
129
+ const { presenter, lines } = capturingPresenter();
130
+ const checklist = presenter.checklist([{ id: "a", label: "step a" }], { title: "boot" });
131
+ checklist.setActive("a");
132
+ checklist.setDone("a", "ok");
133
+ checklist.stop();
134
+ assert.deepEqual(lines(), ["boot", "> step a", "[ok] step a ok"]);
135
+ });
136
+ });
137
+ test("plain presenter task settles to a status line", () => {
138
+ withNoColor(() => {
139
+ const { presenter, lines } = capturingPresenter();
140
+ const task = presenter.task("working");
141
+ task.succeed("done working");
142
+ assert.deepEqual(lines(), ["> working", "[ok] done working"]);
143
+ });
144
+ });
145
+ test("plain presenter progress prints milestones, not every update", () => {
146
+ withNoColor(() => {
147
+ const { presenter, lines } = capturingPresenter();
148
+ const progress = presenter.progress("model");
149
+ progress.update({ downloaded: 10, total: 100 });
150
+ progress.update({ downloaded: 11, total: 100 });
151
+ progress.update({ downloaded: 55, total: 100 });
152
+ progress.succeed();
153
+ const output = lines();
154
+ assert.equal(output.filter((line) => line.includes("10%")).length, 1);
155
+ assert.equal(output.filter((line) => line.includes("50%")).length, 1);
156
+ assert.match(output[output.length - 1] ?? "", /\[ok\] model/);
157
+ });
158
+ });
159
+ test("plain presenter liveFrame appends timestamped snapshots", () => {
160
+ withNoColor(() => {
161
+ const { presenter, lines } = capturingPresenter();
162
+ const frame = presenter.liveFrame();
163
+ frame.render(["first"]);
164
+ frame.render(() => ["second", "detail"]);
165
+ frame.stop();
166
+ const output = lines();
167
+ assert.equal(output.filter((line) => /^\[\d{4}-\d{2}-\d{2}T/.test(line)).length, 2);
168
+ assert.deepEqual(output.filter((line) => !line.startsWith("[")), ["first", "second", "detail"]);
169
+ });
170
+ });
171
+ test("watch renders fetch errors and cancels an in-flight refresh on abort", async () => {
172
+ const { presenter, lines } = capturingPresenter();
173
+ const abort = new AbortController();
174
+ let calls = 0;
175
+ await watch(presenter, 0.1, () => {
176
+ calls += 1;
177
+ if (calls === 1)
178
+ throw new Error("temporary");
179
+ abort.abort();
180
+ return ["healthy"];
181
+ }, { signal: abort.signal });
182
+ assert.equal(calls, 2);
183
+ assert.ok(lines().some((line) => line.includes("error: temporary")));
184
+ assert.equal(lines().includes("healthy"), false);
185
+ });
186
+ test("watch does not poll with a pre-aborted signal", async () => {
187
+ const { presenter, lines } = capturingPresenter();
188
+ const abort = new AbortController();
189
+ abort.abort();
190
+ let calls = 0;
191
+ await watch(presenter, 0.1, () => {
192
+ calls += 1;
193
+ return ["unexpected"];
194
+ }, { signal: abort.signal });
195
+ assert.equal(calls, 0);
196
+ assert.deepEqual(lines(), []);
197
+ });
198
+ test("watch does not start a refresh when abort wins the microtask race", async () => {
199
+ const { presenter } = capturingPresenter();
200
+ const abort = new AbortController();
201
+ let calls = 0;
202
+ queueMicrotask(() => abort.abort());
203
+ await watch(presenter, 0.1, () => {
204
+ calls += 1;
205
+ return ["unexpected"];
206
+ }, { signal: abort.signal });
207
+ assert.equal(calls, 0);
208
+ });
209
+ test("watch removes interval abort listeners after each refresh", async () => {
210
+ const { presenter } = capturingPresenter();
211
+ const abort = new AbortController();
212
+ let calls = 0;
213
+ let maximumListeners = 0;
214
+ await watch(presenter, 0.1, (signal) => {
215
+ calls += 1;
216
+ maximumListeners = Math.max(maximumListeners, getEventListeners(signal, "abort").length);
217
+ if (calls === 3)
218
+ abort.abort();
219
+ return [`frame ${calls}`];
220
+ }, { signal: abort.signal });
221
+ assert.equal(calls, 3);
222
+ assert.ok(maximumListeners <= 1, `abort listeners accumulated: ${maximumListeners}`);
223
+ });
224
+ test("plain presenter status renders glyph, detail, and hint", () => {
225
+ withNoColor(() => {
226
+ const { presenter, lines } = capturingPresenter();
227
+ presenter.status("fail", "uv / uvx", "not found", "install uv");
228
+ assert.deepEqual(lines(), [" [x] uv / uvx not found", " > install uv"]);
229
+ });
230
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import assert from "node:assert/strict";
3
+ import { test } from "node:test";
4
+ import { render } from "ink-testing-library";
5
+ import { ChecklistView, LiveFrameView, ProgressView, TaskView } from "../ink/components.js";
6
+ import { ConfirmPrompt, SelectPrompt } from "../ink/prompts.js";
7
+ import { Store } from "../ink/store.js";
8
+ const ENTER = "\r";
9
+ const ARROW_DOWN = "\u001b[B";
10
+ function frame(text) {
11
+ return text ?? "";
12
+ }
13
+ test("ChecklistView renders steps and live detail updates", async () => {
14
+ const store = new Store({
15
+ title: "booting",
16
+ steps: [
17
+ { id: "a", label: "router", status: "active" },
18
+ { id: "b", label: "gateway", status: "pending" }
19
+ ]
20
+ });
21
+ const { lastFrame, unmount } = render(_jsx(ChecklistView, { store: store }));
22
+ assert.match(frame(lastFrame()), /booting/);
23
+ assert.match(frame(lastFrame()), /router/);
24
+ assert.match(frame(lastFrame()), /gateway/);
25
+ store.set((state) => ({
26
+ ...state,
27
+ steps: state.steps.map((step) => (step.id === "a" ? { ...step, status: "done", detail: "ready" } : step))
28
+ }));
29
+ await new Promise((resolve) => setTimeout(resolve, 20));
30
+ assert.match(frame(lastFrame()), /ready/);
31
+ unmount();
32
+ });
33
+ test("TaskView renders the spinner text and settles", async () => {
34
+ const store = new Store({ text: "warming engine" });
35
+ const { lastFrame, unmount } = render(_jsx(TaskView, { store: store }));
36
+ assert.match(frame(lastFrame()), /warming engine/);
37
+ store.set(() => ({ text: "warming engine", settled: { kind: "success", text: "engine ready" } }));
38
+ await new Promise((resolve) => setTimeout(resolve, 20));
39
+ assert.match(frame(lastFrame()), /engine ready/);
40
+ unmount();
41
+ });
42
+ test("ProgressView renders a bar with percent and totals", () => {
43
+ const store = new Store({
44
+ label: "model",
45
+ downloaded: 512,
46
+ total: 1024,
47
+ startedAt: Date.now() - 1000
48
+ });
49
+ const { lastFrame, unmount } = render(_jsx(ProgressView, { store: store }));
50
+ assert.match(frame(lastFrame()), /50%/);
51
+ assert.match(frame(lastFrame()), /512 B \/ 1 KB/);
52
+ unmount();
53
+ });
54
+ test("LiveFrameView replaces the complete multi-line frame", async () => {
55
+ const store = new Store({ lines: ["first", "old detail"] });
56
+ const { lastFrame, unmount } = render(_jsx(LiveFrameView, { store: store }));
57
+ assert.match(frame(lastFrame()), /first/);
58
+ assert.match(frame(lastFrame()), /old detail/);
59
+ store.set(() => ({ lines: ["second", "new detail"] }));
60
+ await new Promise((resolve) => setTimeout(resolve, 20));
61
+ assert.doesNotMatch(frame(lastFrame()), /old detail/);
62
+ assert.match(frame(lastFrame()), /second/);
63
+ assert.match(frame(lastFrame()), /new detail/);
64
+ unmount();
65
+ });
66
+ test("SelectPrompt navigates with arrows and submits on enter", async () => {
67
+ let submitted;
68
+ const { stdin, unmount } = render(_jsx(SelectPrompt, { message: "Pick a tool", options: [
69
+ { value: "codex", label: "codex" },
70
+ { value: "claude", label: "claude" }
71
+ ], defaultIndex: 0, onSubmit: (value) => {
72
+ submitted = value;
73
+ }, onAbort: () => {
74
+ throw new Error("aborted");
75
+ } }));
76
+ await new Promise((resolve) => setTimeout(resolve, 20));
77
+ stdin.write(ARROW_DOWN);
78
+ await new Promise((resolve) => setTimeout(resolve, 20));
79
+ stdin.write(ENTER);
80
+ await new Promise((resolve) => setTimeout(resolve, 20));
81
+ assert.equal(submitted, "claude");
82
+ unmount();
83
+ });
84
+ test("ConfirmPrompt submits the default on enter and toggles with y/n", async () => {
85
+ let answer;
86
+ const { stdin, unmount } = render(_jsx(ConfirmPrompt, { message: "Proceed?", defaultValue: true, onSubmit: (value) => {
87
+ answer = value;
88
+ }, onAbort: () => {
89
+ throw new Error("aborted");
90
+ } }));
91
+ await new Promise((resolve) => setTimeout(resolve, 20));
92
+ stdin.write("n");
93
+ await new Promise((resolve) => setTimeout(resolve, 20));
94
+ assert.equal(answer, false);
95
+ unmount();
96
+ });