caique 0.2.0 → 0.3.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.
@@ -0,0 +1,207 @@
1
+ import { AsyncResource } from 'node:async_hooks';
2
+ import { resolve as resolvePath } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import exitHook from 'closeout/exit-hook';
5
+ import { AbortPromptError, CancelPromptError, ExitPromptError } from './inquirer-errors.js';
6
+ import { effectScheduler, useEffect, useRef, useState, withHooks, withUpdates } from './inquirer-hooks.js';
7
+ import { MuteStream, ScreenManager } from './inquirer-screen.js';
8
+ import { LINE, makeTheme } from './inquirer-theme.js';
9
+ import { processRuntime } from './runtime.js';
10
+ const SEPARATOR_WIDTH = 15;
11
+ const SPINNER_DELAY_MS = 300;
12
+ export class Separator {
13
+ separator = Array.from({ length: SEPARATOR_WIDTH }).join(LINE);
14
+ type = 'separator';
15
+ constructor(separator) {
16
+ if (separator !== undefined && separator !== '')
17
+ this.separator = separator;
18
+ }
19
+ static isSeparator(choice) {
20
+ return Boolean(choice) && typeof choice === 'object' && choice !== null && 'type' in choice && choice.type === 'separator';
21
+ }
22
+ }
23
+ export function usePrefix({ status = 'idle', theme }) {
24
+ const [showLoader, setShowLoader] = useState(false);
25
+ const [tick, setTick] = useState(0);
26
+ const { prefix, spinner } = makeTheme(theme);
27
+ useEffect(() => {
28
+ if (status !== 'loading') {
29
+ setShowLoader(false);
30
+ return undefined;
31
+ }
32
+ let tickInterval;
33
+ let inc = -1;
34
+ const delayTimeout = setTimeout(() => {
35
+ setShowLoader(true);
36
+ tickInterval = setInterval(() => {
37
+ inc = inc + 1;
38
+ setTick(inc % spinner.frames.length);
39
+ }, spinner.interval);
40
+ }, SPINNER_DELAY_MS);
41
+ return () => {
42
+ clearTimeout(delayTimeout);
43
+ clearInterval(tickInterval);
44
+ };
45
+ }, [status]);
46
+ if (showLoader === true)
47
+ return spinner.frames[tick ?? 0] ?? '';
48
+ const iconName = status === 'loading' ? 'idle' : status;
49
+ if (typeof prefix === 'string')
50
+ return prefix;
51
+ return prefix[iconName] ?? prefix['idle'] ?? '';
52
+ }
53
+ export function useKeypress(userHandler) {
54
+ const signal = useRef(userHandler);
55
+ signal.current = userHandler;
56
+ useEffect((rl) => {
57
+ let ignore = false;
58
+ const handler = withUpdates((_input, event) => {
59
+ if (ignore)
60
+ return;
61
+ void signal.current(event, rl);
62
+ });
63
+ rl.input.on('keypress', handler);
64
+ return () => {
65
+ ignore = true;
66
+ rl.input.removeListener('keypress', handler);
67
+ };
68
+ }, []);
69
+ }
70
+ function callerFile() {
71
+ const saved = Error.prepareStackTrace;
72
+ let frames = [];
73
+ try {
74
+ Error.prepareStackTrace = (_error, callSites) => {
75
+ frames = [...callSites];
76
+ return frames;
77
+ };
78
+ void new Error('trace').stack;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ Error.prepareStackTrace = saved;
84
+ const fileName = frames[2]?.getFileName() ?? undefined;
85
+ if (fileName === undefined || fileName.startsWith('file://'))
86
+ return fileName;
87
+ return resolvePath(fileName);
88
+ }
89
+ function listenTo(target, event, listener) {
90
+ const [add, remove] = 'on' in target
91
+ ? [target.on.bind(target), target.removeListener.bind(target)]
92
+ : [target.addEventListener.bind(target), target.removeEventListener.bind(target)];
93
+ add(event, listener);
94
+ return () => {
95
+ remove(event, listener);
96
+ };
97
+ }
98
+ const nativeSetImmediate = globalThis.setImmediate;
99
+ function readlineOver(input, output) {
100
+ const stream = output;
101
+ return createInterface({ terminal: true, input, output: stream });
102
+ }
103
+ function runPrompt(view, origin, config, context) {
104
+ const runtime = processRuntime();
105
+ const { input = runtime.stdin, signal } = context;
106
+ const cleanups = new Set();
107
+ const output = new MuteStream();
108
+ output.pipe((context.output ?? runtime.stdout));
109
+ const rl = readlineOver(input, output);
110
+ output.mute();
111
+ const screen = new ScreenManager(rl);
112
+ const { promise, resolve, reject } = Promise.withResolvers();
113
+ return withHooks(rl, (cycle) => {
114
+ const clearEffects = AsyncResource.bind(() => {
115
+ effectScheduler.clearAll();
116
+ });
117
+ const settlePrompt = (settle) => {
118
+ try {
119
+ clearEffects();
120
+ settle();
121
+ }
122
+ catch (error) {
123
+ reject(error);
124
+ }
125
+ };
126
+ const settler = (finish) => (settled) => {
127
+ settlePrompt(() => {
128
+ finish(settled);
129
+ });
130
+ };
131
+ const resolvePrompt = settler(resolve);
132
+ const rejectPrompt = settler(reject);
133
+ const endsWith = (make) => () => {
134
+ rejectPrompt(make());
135
+ };
136
+ const promptPromise = Object.assign(promise
137
+ .finally(() => {
138
+ for (const cleanup of cleanups)
139
+ cleanup();
140
+ screen.done({ clearContent: Boolean(context.clearPromptOnDone) });
141
+ output.end();
142
+ })
143
+ .then(() => promise), { cancel: endsWith(() => new CancelPromptError()) });
144
+ if (signal) {
145
+ const abort = endsWith(() => new AbortPromptError({ cause: signal.reason }));
146
+ if (signal.aborted) {
147
+ abort();
148
+ return promptPromise;
149
+ }
150
+ cleanups.add(listenTo(signal, 'abort', abort));
151
+ }
152
+ const forceClosed = (how) => {
153
+ rejectPrompt(new ExitPromptError(`User force closed the prompt with ${how}`));
154
+ };
155
+ cleanups.add(exitHook((code) => {
156
+ forceClosed(String(code));
157
+ }));
158
+ cleanups.add(listenTo(rl, 'SIGINT', () => {
159
+ forceClosed('SIGINT');
160
+ }));
161
+ cleanups.add(listenTo(rl, 'close', clearEffects));
162
+ const startCycle = () => {
163
+ cleanups.add(listenTo(rl.input, 'keypress', () => {
164
+ screen.checkCursorPos();
165
+ }));
166
+ let pendingDone = null;
167
+ cycle(() => {
168
+ let effectsSettled = false;
169
+ try {
170
+ const nextView = view(config, (value) => {
171
+ if (effectsSettled)
172
+ resolvePrompt(value);
173
+ else
174
+ pendingDone = { value };
175
+ });
176
+ if (nextView === undefined)
177
+ throw new Error(`Prompt functions must return a string.\n at ${origin ?? '<unknown>'}`);
178
+ const [content, bottomContent] = typeof nextView === 'string' ? [nextView, undefined] : nextView;
179
+ screen.render(content, bottomContent);
180
+ effectScheduler.run();
181
+ }
182
+ catch (error) {
183
+ rejectPrompt(error);
184
+ }
185
+ effectsSettled = true;
186
+ if (pendingDone !== null) {
187
+ const { value } = pendingDone;
188
+ pendingDone = null;
189
+ resolvePrompt(value);
190
+ }
191
+ });
192
+ };
193
+ if ('readableFlowing' in input)
194
+ nativeSetImmediate(startCycle);
195
+ else
196
+ startCycle();
197
+ return promptPromise;
198
+ });
199
+ }
200
+ export function createPrompt(view) {
201
+ const origin = callerFile();
202
+ return (config, context = {}) => runPrompt(view, origin, config, context);
203
+ }
204
+ export { AbortPromptError, CancelPromptError, ExitPromptError, HookError, ValidationError } from './inquirer-errors.js';
205
+ export { useEffect, useMemo, useRef, useState } from './inquirer-hooks.js';
206
+ export { getDefaultKeybindings, isBackspaceKey, isDownKey, isEnterKey, isNumberKey, isShiftKey, isSpaceKey, isTabKey, isUpKey, } from './inquirer-keys.js';
207
+ export { defaultTheme, getDefaultTheme, makeTheme } from './inquirer-theme.js';
package/dist/plugin.js CHANGED
@@ -1,37 +1,6 @@
1
- /**
2
- * The plugin host for caique's half of the contract (`plugin-contract` R1, R5, R6, R7, R8).
3
- *
4
- * A plugin is one plain object shared by the whole family. This file keeps the key caique
5
- * understands — `widgets`, a prompt kind and how to draw it — and **ignores every other key
6
- * without complaining**, which is what makes the same object work on any subset of the
7
- * family that is installed. A plugin written for flagstaff registers here and contributes
8
- * its widgets; its `tokens`, `glyphs` and `components` are not caique's business and are
9
- * not an error.
10
- *
11
- * **Nothing here imports another layer**, and the plugin shape is declared rather than
12
- * imported (R3). Types erase, so an import would cost nothing at run time — and it would
13
- * still put flagstaff in caique's dependency story, which is the one thing the family
14
- * promises it does not do.
15
- *
16
- * **A widget is the same shape a flagstaff component is** — `{ static, frame?, sample? }` —
17
- * and a widget without `static` is refused with `E_NO_STATIC_PROJECTION`, the same code and
18
- * the same fix shape. That sameness is the whole content of R5: the moment the two shapes
19
- * differ by a key, "the same shape" stops being a fact a lock can hold and becomes prose.
20
- *
21
- * **Why this file, and not `ask.ts`, raises `E_UNKNOWN_KIND`.** `PromptKind` is an open
22
- * union now, so `{ kind: 'acme-rating' }` type-checks whether or not anyone can draw it.
23
- * The refusal therefore has to happen where the registry is, and it has to be *loud*: a
24
- * kind nobody registered rendered as a text prompt is the silent-wrong-answer failure this
25
- * package exists to prevent, wearing a hat.
26
- */
27
1
  import { projection } from './ask.js';
28
2
  import { BUILT_IN_KINDS } from './spec.js';
29
- /**
30
- * The plugin contract version. One number for the family — the same `1` flagstaff and
31
- * roundel declare, written out rather than imported for the reason in the file comment.
32
- */
33
3
  export const CONTRACT = 1;
34
- /** A refused plugin says what is wrong and what to do about it — the family's one vocabulary. */
35
4
  export class PluginError extends Error {
36
5
  code;
37
6
  fix;
@@ -43,14 +12,7 @@ export class PluginError extends Error {
43
12
  }
44
13
  }
45
14
  const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
46
- /** The six, in the order a person reads them — for the `fix` on an unknown kind. */
47
15
  const builtIns = () => [...BUILT_IN_KINDS].join(', ');
48
- /**
49
- * Refuse a plugin that cannot contribute a widget, at the door.
50
- *
51
- * Every refusal here is about the `widgets` key or the plugin's own identity. A key another
52
- * layer owns is not inspected and not rejected (R1) — caique has no opinion about a spinner.
53
- */
54
16
  export function validate(plugin) {
55
17
  if (!isRecord(plugin))
56
18
  throw new PluginError('E_PLUGIN_SCHEMA', 'a plugin is a plain object', 'export an object, not a function or an array');
@@ -71,19 +33,11 @@ function validateWidgets(widgetMap, name) {
71
33
  for (const [kind, widget] of Object.entries(widgetMap)) {
72
34
  if (kind === '')
73
35
  throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": a widget’s kind is empty`, 'name the kind — it is what a `PromptSpec` sets as `kind`');
74
- /**
75
- * A plugin may not replace one of the six. The built-ins are the accessible floor and
76
- * the drop-in surface, and `password` in particular guarantees that nothing writes back
77
- * what it read — a third party that could override it could defeat that from a config
78
- * file. Extension is the space *outside* the six, which is exactly what widening
79
- * `PromptKind` opened up.
80
- */
81
36
  if (BUILT_IN_KINDS.has(kind)) {
82
37
  throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": "${kind}" is a built-in prompt kind`, `caique draws the six itself (${builtIns()}); name a kind of your own`);
83
38
  }
84
39
  if (!isRecord(widget))
85
40
  throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": widget "${kind}" is not an object`, 'a widget is `{ static, frame?, sample? }`');
86
- /** U3 everywhere else, and the reason a third-party prompt cannot break the non-TTY guarantee. */
87
41
  if (typeof widget['static'] !== 'function') {
88
42
  throw new PluginError('E_NO_STATIC_PROJECTION', `plugin "${name}": widget "${kind}" has no static projection`, 'add `static: (spec) => "…"` — it is what a pipe, an agent and a screen reader get');
89
43
  }
@@ -101,23 +55,16 @@ function validateSample(sample, name, kind) {
101
55
  }
102
56
  }
103
57
  const order = [];
104
- /**
105
- * Register a plugin. Later wins, like ESLint flat config: the array is ordered, a caller
106
- * reads it top to bottom, and the last word on a kind is the one nearest the program.
107
- */
108
58
  export function register(plugin) {
109
59
  validate(plugin);
110
60
  order.push(plugin);
111
61
  }
112
- /** Forget every registered plugin. For tests, and for a program that re-registers at runtime. */
113
62
  export function reset() {
114
63
  order.length = 0;
115
64
  }
116
- /** The plugins registered, in registration order. */
117
65
  export function registered() {
118
66
  return order;
119
67
  }
120
- /** Every kind a plugin contributed, with who won it and who it shadowed. */
121
68
  export function widgets() {
122
69
  const by = new Map();
123
70
  for (const plugin of order) {
@@ -128,9 +75,7 @@ export function widgets() {
128
75
  }
129
76
  return [...by.values()];
130
77
  }
131
- /** The widget that draws `kind`, or nothing when no plugin registered one. */
132
78
  export function widgetFor(kind) {
133
- // Walked backwards because later wins, and the first hit from the end is the winner.
134
79
  for (let i = order.length - 1; i >= 0; i -= 1) {
135
80
  const widget = order[i].widgets?.[kind];
136
81
  if (widget !== undefined)
@@ -138,17 +83,9 @@ export function widgetFor(kind) {
138
83
  }
139
84
  return undefined;
140
85
  }
141
- /** Every kind that can be drawn right now: the six, plus whatever is registered. */
142
86
  export function kinds() {
143
87
  return [...BUILT_IN_KINDS, ...widgets().map((c) => c.kind)];
144
88
  }
145
- /**
146
- * The static projection for *any* kind — the one surface a caller needs.
147
- *
148
- * The six are still drawn by caique; anything else is a registered widget's `static`. A
149
- * kind that is neither is a refusal naming what *is* registered, so the reader can see the
150
- * typo rather than a text prompt where their rating widget should have been.
151
- */
152
89
  export function projectionOf(spec) {
153
90
  if (BUILT_IN_KINDS.has(spec.kind))
154
91
  return projection(spec);
@@ -160,4 +97,3 @@ export function projectionOf(spec) {
160
97
  }
161
98
  return widget.static(spec).trimEnd();
162
99
  }
163
- //# sourceMappingURL=plugin.js.map
package/dist/raw.js CHANGED
@@ -1,36 +1,8 @@
1
- /**
2
- * The raw-mode renderer: arrow keys and a moving highlight for `select` and `multiselect`,
3
- * on a terminal that can take them.
4
- *
5
- * **It answers the same questions `ask()` does, and returns the same answers.** That is the
6
- * whole arrangement: line mode is the floor (R5), this sits on top, and a caller chooses
7
- * between them by asking whether the terminal is one. Anything this can do that line mode
8
- * cannot is decoration; anything line mode can do that this cannot would be a bug.
9
- *
10
- * **On its one dependency.** The design said "spinner from flagstaff"; this imports
11
- * `closeout` instead, and the difference is the point. A repaint is one escape sequence
12
- * and belongs here. Hiding the cursor is a global side effect on someone else's terminal,
13
- * and the obligation it creates — put it back however the process dies — is not a repaint.
14
- * This file used to own `HIDE_CURSOR`/`SHOW_CURSOR`, the family's third copy, and restore
15
- * on one path only: the keypress loop, which sees Ctrl-C because raw mode delivers it as a
16
- * byte. A `SIGINT` from a parent, a `SIGTERM`, a crash or a `process.exit()` elsewhere
17
- * never reached it, and left the cursor invisible until the user typed `reset` (measured
18
- * against `dist/raw.js`, 2026-09-15: hide 1, show 0). `hideCursor()` registers the restore
19
- * in the call that hides, so the two cannot drift.
20
- */
21
1
  import { hideCursor } from 'closeout/cursor';
22
2
  import exitHook from 'closeout/exit-hook';
23
- import {} from './ask.js';
24
- import {} from './spec.js';
25
3
  const ESC = '\u001B';
26
4
  const CSI = `${ESC}[`;
27
- /** Column 1, up `n` lines, clear to the end of the screen — the only repaint this needs. */
28
5
  const erase = (lines) => `${CSI}1G${lines > 1 ? `${CSI}${lines - 1}A` : ''}${CSI}0J`;
29
- /**
30
- * What a keypress means. Only the six that drive a list — everything else is `other`, and
31
- * a widget that does not know what to do with a key does nothing, which is what a person
32
- * expects from a key they pressed by accident.
33
- */
34
6
  export function keyOf(data) {
35
7
  if (data === `${CSI}A` || data === 'k')
36
8
  return 'up';
@@ -40,26 +12,21 @@ export function keyOf(data) {
40
12
  return 'space';
41
13
  if (data === '\r' || data === '\n')
42
14
  return 'enter';
43
- // Ctrl-C and Ctrl-D. In raw mode the terminal delivers these as bytes rather than
44
- // signals, so a widget that did not read them would leave a person unable to leave.
45
15
  if (data === '\u0003' || data === '\u0004' || data === ESC)
46
16
  return 'cancel';
47
17
  return 'other';
48
18
  }
49
- /** Whether this runtime can drive the raw renderer at all. */
50
19
  export function canRender(keys) {
51
20
  return keys.isTTY === true && typeof keys.setRawMode === 'function';
52
21
  }
53
22
  const MARK = { on: '◉', off: '◯' };
54
23
  const POINTER = '❯';
55
24
  const CANCELLED = { ok: false, reason: 'cancelled' };
56
- /** The `◉`/`◯` column, which only a multiselect has. Empty for a single select. */
57
25
  function markFor(state, index, multi) {
58
26
  if (!multi)
59
27
  return '';
60
28
  return `${state.selected.has(index) ? MARK.on : MARK.off} `;
61
29
  }
62
- /** One frame of the list. Exported so a test asserts the drawing rather than a screenshot. */
63
30
  export function renderList(spec, choices, state, multi) {
64
31
  const rows = choices.map((choice, index) => {
65
32
  const pointer = index === state.cursor ? POINTER : ' ';
@@ -69,11 +36,6 @@ export function renderList(spec, choices, state, multi) {
69
36
  return [spec.message, ...rows].join('\n');
70
37
  }
71
38
  const clamp = (index, length) => (index + length) % length;
72
- /**
73
- * Apply a navigation key, and say whether anything changed — a key with no meaning here
74
- * changes nothing and repaints nothing, which is what a person expects from a key they
75
- * pressed by accident.
76
- */
77
39
  function moved(key, state, length, multi) {
78
40
  if (key === 'up') {
79
41
  state.cursor = clamp(state.cursor - 1, length);
@@ -91,29 +53,12 @@ function moved(key, state, length, multi) {
91
53
  state.selected.add(state.cursor);
92
54
  return true;
93
55
  }
94
- /** What enter answers with. List order, not press order: a set of choices has no sequence. */
95
56
  function chosen(choices, state, multi) {
96
57
  if (!multi)
97
58
  return choices[state.cursor]?.value ?? '';
98
59
  return [...state.selected].sort((a, b) => a - b).map((index) => choices[index]?.value ?? '');
99
60
  }
100
- /**
101
- * The writer, seen as a terminal, for `hideCursor()` — which refuses a non-TTY. caique's
102
- * `Writer` is one method and has no `isTTY`: the terminal test this renderer makes is
103
- * `canRender(io.keys)`, on the other half of the same terminal, and it has already been
104
- * made. So it is answered here rather than re-asked of a stream that cannot answer.
105
- */
106
61
  const asTerminal = (writer) => ({ write: (text) => writer.write(text), isTTY: true });
107
- /**
108
- * Drive a list prompt with the arrow keys, repainting in place.
109
- *
110
- * Returns the same `Asked` shape `ask()` does, so a caller can swap the two without
111
- * knowing which ran — and cancels the same way, because `Ctrl-C` in raw mode is a byte and
112
- * not a signal, and a person who presses it means to leave.
113
- *
114
- * The cursor is hidden through `closeout`: the byte path resolves and `restore()` runs in
115
- * the `finally`; every path that never reaches the `finally` is the exit hook's.
116
- */
117
62
  export async function askList(spec, io, multi = false) {
118
63
  const choices = spec.choices ?? [];
119
64
  const state = { cursor: 0, selected: new Set() };
@@ -125,8 +70,6 @@ export async function askList(spec, io, multi = false) {
125
70
  };
126
71
  io.keys.setRawMode?.(true);
127
72
  io.keys.resume?.();
128
- // Hide and register the restore together. `restore()` shows the cursor and unregisters,
129
- // so a prompt that ends normally leaves nothing behind for exit to do.
130
73
  const restore = hideCursor(asTerminal(io.writer), exitHook);
131
74
  paint();
132
75
  try {
@@ -146,8 +89,6 @@ export async function askList(spec, io, multi = false) {
146
89
  };
147
90
  const done = (answer) => {
148
91
  io.keys.off('data', onData);
149
- // Leave the answered question on screen; the `finally` puts the cursor and the
150
- // terminal back, because a prompt that exits in raw mode leaves the shell unusable.
151
92
  io.writer.write(`${erase(painted)}${renderList(spec, choices, state, multi)}\n`);
152
93
  resolve(answer);
153
94
  };
@@ -160,4 +101,3 @@ export async function askList(spec, io, multi = false) {
160
101
  io.keys.pause?.();
161
102
  }
162
103
  }
163
- //# sourceMappingURL=raw.js.map
package/dist/runtime.js CHANGED
@@ -1,18 +1,6 @@
1
- /**
2
- * The slice of the world caique needs, and the one file here that names `process` (Y9) —
3
- * the same seam `paratext/src/runtime.ts` declares. `decide()` already took the fields it
4
- * reads and `createIo()` already took its streams; this is the other half, so a program can
5
- * get a real one without writing `process.stdin` itself.
6
- *
7
- * A **function**, for paratext's reason: a runtime built at import freezes the environment
8
- * as it was when the module graph loaded, which is before a test can say what it wants.
9
- * `runtime.test.ts` asks twice across a change, so a captured constant cannot pass.
10
- */
11
- /** What a real process looks like. Callers that have not got one pass their own. */
12
1
  export const processRuntime = () => ({
13
2
  env: process.env,
14
3
  stdin: process.stdin,
15
4
  stdout: process.stdout,
16
5
  isTTY: { stdin: process.stdin.isTTY === true, stdout: process.stdout.isTTY === true },
17
6
  });
18
- //# sourceMappingURL=runtime.js.map