@bunizao/cli-kit 0.5.1 → 0.6.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 CHANGED
@@ -187,8 +187,10 @@ spin.stop("Uploaded 2 files");
187
187
  ui.outro("Done");
188
188
  ```
189
189
 
190
- `select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices. Ctrl+C inside a
191
- prompt throws a `cancelled` error (exit 130). `confirm(plan, options)` is built on the same
190
+ `select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices, or always with
191
+ `{ search: true }`; every typed word must appear in the label or hint, in any order.
192
+ `{ initial }` highlights a row. A multi-step picker passes `{ back: true }` and gets `BACK`
193
+ when the person presses Escape. Ctrl+C inside a prompt throws a `cancelled` error (exit 130). `confirm(plan, options)` is built on the same
192
194
  layer and keeps its contract: `--dry-run` prints the plan, `--yes` skips the question, a pipe
193
195
  without `--yes` is a usage error.
194
196
 
package/dist/ui.d.ts CHANGED
@@ -9,6 +9,19 @@ export interface Choice<T> {
9
9
  readonly label: string;
10
10
  readonly hint?: string;
11
11
  }
12
+ /** What `select` resolves when the person presses Escape on a prompt opened with `back: true`. */
13
+ export declare const BACK: unique symbol;
14
+ export interface SelectOptions<T> {
15
+ /** Type-to-filter at any length; a short list still wants it when the person knows the name. */
16
+ readonly search?: boolean;
17
+ /** The row highlighted when the list opens. */
18
+ readonly initial?: T;
19
+ /**
20
+ * Escape resolves `BACK` instead of cancelling, so a multi-step picker can return to the
21
+ * previous step. Ctrl+C still cancels.
22
+ */
23
+ readonly back?: boolean;
24
+ }
12
25
  export interface UiOptions {
13
26
  /** Keyboard input. Defaults to `process.stdin`. */
14
27
  readonly input?: NodeJS.ReadStream;
@@ -30,6 +43,8 @@ export interface Spinner {
30
43
  /** End the step and leave `message` (or the last running text) as the permanent line. */
31
44
  stop(message?: string): void;
32
45
  error(message?: string): void;
46
+ /** End the step and erase it, for progress whose outcome is printed elsewhere. */
47
+ clear(): void;
33
48
  }
34
49
  export interface Ui {
35
50
  readonly interactive: boolean;
@@ -47,10 +62,14 @@ export interface Ui {
47
62
  spinner(): Spinner;
48
63
  /**
49
64
  * Let the person pick one row. Above `AUTOCOMPLETE_FROM` choices the list becomes
50
- * type-to-filter, because arrowing through thirty forum threads is not a picker.
65
+ * type-to-filter, because arrowing through thirty forum threads is not a picker. The
66
+ * filter matches every typed word anywhere in the label or hint, in any order.
51
67
  * Without a terminal this throws a usage error that lists the choices.
52
68
  */
53
- select<T>(message: string, choices: readonly Choice<T>[]): Promise<T>;
69
+ select<T>(message: string, choices: readonly Choice<T>[], options: SelectOptions<T> & {
70
+ readonly back: true;
71
+ }): Promise<T | typeof BACK>;
72
+ select<T>(message: string, choices: readonly Choice<T>[], options?: SelectOptions<T>): Promise<T>;
54
73
  /** Yes or no. Without a terminal this throws a usage error. */
55
74
  confirm(message: string, options?: {
56
75
  readonly initial?: boolean;
package/dist/ui.js CHANGED
@@ -6,6 +6,8 @@ import * as clack from "@clack/prompts";
6
6
  import { isAgentEnvironment } from "./audience.js";
7
7
  import { colorEnabled, painter } from "./color.js";
8
8
  import { CliError } from "./errors.js";
9
+ /** What `select` resolves when the person presses Escape on a prompt opened with `back: true`. */
10
+ export const BACK = Symbol("cli-kit.back");
9
11
  export const AUTOCOMPLETE_FROM = 8;
10
12
  /**
11
13
  * Every CLI in the family draws the same way: prompts, steps and spinners on stderr
@@ -18,8 +20,15 @@ export function createUi(options = {}) {
18
20
  const output = options.output ?? process.stderr;
19
21
  const interactive = options.interactive
20
22
  ?? (Boolean(input.isTTY) && Boolean(output.isTTY) && !isAgentEnvironment(options.env));
21
- const common = { input, output, ...(options.signal ? { signal: options.signal } : {}) };
22
- const plain = (text) => output.write(`${text}\n`);
23
+ // Escape erases the abandoned step and the answer above it, so going back looks like
24
+ // the earlier question was never answered. That needs to know how many rows each prompt
25
+ // drew; clack redraws with relative moves, so counting newlines and cursor-up/down
26
+ // sequences written through the ui gives it.
27
+ let row = 0;
28
+ const answered = [];
29
+ const drawn = interactive ? trackRows(output, delta => { row += delta; }) : output;
30
+ const common = { input, output: drawn, ...(options.signal ? { signal: options.signal } : {}) };
31
+ const plain = (text) => drawn.write(`${text}\n`);
23
32
  const paint = painter(colorEnabled(output, options.env));
24
33
  return {
25
34
  interactive,
@@ -30,7 +39,7 @@ export function createUi(options = {}) {
30
39
  return;
31
40
  }
32
41
  const lines = art.replace(/^\n+|\s+$/gu, "").split("\n").map(line => paint("cyan", line));
33
- output.write(`${lines.join("\n")}\n${tagline ? `${paint("dim", tagline)}\n` : ""}\n`);
42
+ drawn.write(`${lines.join("\n")}\n${tagline ? `${paint("dim", tagline)}\n` : ""}\n`);
34
43
  },
35
44
  intro: title => (interactive ? clack.intro(title, common) : plain(title)),
36
45
  outro: message => (interactive ? clack.outro(message, common) : plain(message)),
@@ -41,11 +50,31 @@ export function createUi(options = {}) {
41
50
  spinner() {
42
51
  if (interactive) {
43
52
  const inner = clack.spinner(common);
53
+ // Clack's spinner holds stdin in raw mode and answers Ctrl+C with process.exit(0),
54
+ // which skips the caller's cleanup and leaves half-written files behind. Handing
55
+ // the key back to the terminal makes it a real SIGINT: the spinner erases itself,
56
+ // the CLI's own handler runs, and a CLI without one still dies as Ctrl+C always has.
57
+ const onInterrupt = () => {
58
+ process.off("SIGINT", onInterrupt);
59
+ inner.clear();
60
+ if (!process.listenerCount("SIGINT"))
61
+ process.kill(process.pid, "SIGINT");
62
+ };
63
+ const end = (finish) => {
64
+ process.off("SIGINT", onInterrupt);
65
+ finish();
66
+ };
44
67
  return {
45
- start: message => inner.start(message),
68
+ start: message => {
69
+ inner.start(message);
70
+ if (process.stdin.isTTY)
71
+ process.stdin.setRawMode(false);
72
+ process.prependListener("SIGINT", onInterrupt);
73
+ },
46
74
  message: message => inner.message(message),
47
- stop: message => inner.stop(message),
48
- error: message => inner.error(message),
75
+ stop: message => end(() => inner.stop(message)),
76
+ error: message => end(() => inner.error(message)),
77
+ clear: () => end(() => inner.clear()),
49
78
  };
50
79
  }
51
80
  // A pipe is read after the fact: only the outcome of a step is worth a line.
@@ -55,9 +84,10 @@ export function createUi(options = {}) {
55
84
  message: message => { running = message; },
56
85
  stop: message => plain(message ?? running),
57
86
  error: message => plain(`error: ${message ?? running}`),
87
+ clear: () => { running = ""; },
58
88
  };
59
89
  },
60
- async select(message, choices) {
90
+ async select(message, choices, selectOptions = {}) {
61
91
  if (!choices.length)
62
92
  throw new CliError("unexpected", `Nothing to choose for "${message}".`);
63
93
  if (!interactive) {
@@ -65,10 +95,33 @@ export function createUi(options = {}) {
65
95
  }
66
96
  // `Option<T>` is conditional on T being primitive, which an open generic cannot resolve.
67
97
  const list = choices.map(c => ({ value: c.value, label: c.label, ...(c.hint ? { hint: c.hint } : {}) }));
68
- const picked = choices.length >= AUTOCOMPLETE_FROM
69
- ? await clack.autocomplete({ message, options: list, placeholder: "Type to filter", maxItems: AUTOCOMPLETE_FROM, ...common })
70
- : await clack.select({ message, options: list, ...common });
71
- return unwrap(picked);
98
+ const initial = selectOptions.initial !== undefined ? { initialValue: selectOptions.initial } : {};
99
+ // Clack reports Escape and Ctrl+C as the same cancel, so the key itself tells them apart.
100
+ let escaped = false;
101
+ const onKey = (_, key) => { escaped = key?.name === "escape"; };
102
+ if (selectOptions.back)
103
+ input.on("keypress", onKey);
104
+ const start = row;
105
+ let picked;
106
+ try {
107
+ picked = selectOptions.search || choices.length >= AUTOCOMPLETE_FROM
108
+ ? await clack.autocomplete({ message, options: list, placeholder: "Type to filter", maxItems: AUTOCOMPLETE_FROM, filter: matchesWords, ...initial, ...common })
109
+ : await clack.select({ message, options: list, ...initial, ...common });
110
+ }
111
+ finally {
112
+ input.off("keypress", onKey);
113
+ }
114
+ if (selectOptions.back && escaped && clack.isCancel(picked)) {
115
+ // The answer above is erased too only when nothing was drawn between it and this step.
116
+ const previous = answered.at(-1)?.end === start ? answered.pop() : undefined;
117
+ const up = row - (previous?.start ?? start);
118
+ if (up > 0)
119
+ drawn.write(`\u001b[${up}A\r\u001b[J`);
120
+ return BACK;
121
+ }
122
+ const value = unwrap(picked);
123
+ answered.push({ start, end: row });
124
+ return value;
72
125
  },
73
126
  async confirm(message, confirmOptions = {}) {
74
127
  if (!interactive)
@@ -117,6 +170,32 @@ export function createUi(options = {}) {
117
170
  },
118
171
  };
119
172
  }
173
+ // "2086 week 5" should find "FIT2086 · Week 5": each typed word on its own, anywhere in
174
+ // the label or hint. A number starts a run of digits, so "5" finds "Week 5" and "2086"
175
+ // finds "FIT2086", but "5" does not find "Week 15".
176
+ function matchesWords(search, option) {
177
+ const text = `${option.label ?? ""} ${option.hint ?? ""}`.toLowerCase();
178
+ return search.toLowerCase().split(/\s+/u).filter(Boolean).every(word => /^\d+$/u.test(word)
179
+ ? new RegExp(`(?<!\\d)${word}`, "u").test(text)
180
+ : text.includes(word));
181
+ }
182
+ // Everything else about the stream (columns, isTTY, resize events) is the real one.
183
+ function trackRows(output, moved) {
184
+ const write = (chunk, ...rest) => {
185
+ for (const match of String(chunk).matchAll(/\u001b\[(\d*)([AB])|\n/gu)) {
186
+ moved(match[0] === "\n" ? 1 : (match[2] === "A" ? -1 : 1) * Number(match[1] || 1));
187
+ }
188
+ return output.write(chunk, ...rest);
189
+ };
190
+ return new Proxy(output, {
191
+ get(target, property) {
192
+ if (property === "write")
193
+ return write;
194
+ const value = Reflect.get(target, property, target);
195
+ return typeof value === "function" ? value.bind(target) : value;
196
+ },
197
+ });
198
+ }
120
199
  /** Ctrl+C or Escape inside a prompt is the person's decision, reported with exit 130 like a signal. */
121
200
  function unwrap(value) {
122
201
  if (clack.isCancel(value))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunizao/cli-kit",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Shared command-line contract for Bunizao CLI tools",
5
5
  "type": "module",
6
6
  "exports": {