diff --git a/package.json b/package.json index 4746ad6..541a695 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ ".": "./src/core/index.js", "./core": "./src/core/index.js", "./core/observability": "./src/core/observability/index.js", - "./core/sinks": "./src/core/sinks/index.js" + "./core/sinks": "./src/core/sinks/index.js", + "./tui": "./src/core/cli/tui/index.js" }, "imports": { "#core/observability": "./src/core/observability/index.js" @@ -27,7 +28,7 @@ }, "scripts": { "lint": "node scripts/check-syntax.js", - "test": "node --test test/**/*.test.js", + "test": "find test -name '*.test.js' -print0 | xargs -0 node --test", "typecheck": "tsc -p tsconfig.json --noEmit", "smoke": "node ./hypaware-core/smoke/index.js" }, diff --git a/src/core/cli/tui/index.js b/src/core/cli/tui/index.js new file mode 100644 index 0000000..60511b8 --- /dev/null +++ b/src/core/cli/tui/index.js @@ -0,0 +1,180 @@ +// @ts-check + +import process from 'node:process' + +import { run, PromptCancelledError } from './runtime.js' + +/** @typedef {import('./keypress.js').State} State */ + +export { PromptCancelledError } + +/** + * @typedef {Object} MultiSelectOption + * @property {string|number} value + * @property {string} label + * @property {string} [summary] + * @property {boolean} [checked] + */ + +/** + * @typedef {Object} MultiSelectSpec + * @property {string} title + * @property {string} [hint] + * @property {MultiSelectOption[]} options + * @property {{ min?: number, max?: number }} [bounds] + * @property {NodeJS.ReadableStream} [stdin] + * @property {NodeJS.WritableStream} [stdout] + */ + +/** + * Render an interactive multi-select prompt with checkbox toggling and + * resolve to the array of `value`s the user confirmed, in the order + * they appear in `options`. + * + * @param {MultiSelectSpec} spec + * @returns {Promise>} + */ +export async function multiselect(spec) { + /** @type {import('./keypress.js').MultiselectState} */ + const initial = { + kind: 'multiselect', + title: spec.title, + options: spec.options.map((o) => ({ + value: o.value, + label: o.label, + ...(o.summary !== undefined ? { summary: o.summary } : {}), + checked: !!o.checked, + })), + cursor: 0, + status: 'active', + ...(spec.hint !== undefined ? { hint: spec.hint } : {}), + ...(spec.bounds !== undefined ? { bounds: spec.bounds } : {}), + } + const io = resolveIo(spec) + const final = /** @type {import('./keypress.js').MultiselectState} */ (await run(initial, io)) + return final.options.filter((o) => o.checked).map((o) => o.value) +} + +/** + * @typedef {Object} SelectSpecOption + * @property {string|number} value + * @property {string} label + * @property {string} [summary] + */ + +/** + * @typedef {Object} SelectSpec + * @property {string} title + * @property {string} [hint] + * @property {SelectSpecOption[]} options + * @property {string|number} [default] + * @property {NodeJS.ReadableStream} [stdin] + * @property {NodeJS.WritableStream} [stdout] + */ + +/** + * Render a single-select prompt and resolve to the selected `value`. + * + * @param {SelectSpec} spec + * @returns {Promise} + */ +export async function select(spec) { + if (spec.options.length === 0) { + throw new Error('select() requires at least one option') + } + const defaultIdx = spec.default !== undefined + ? Math.max(0, spec.options.findIndex((o) => o.value === spec.default)) + : 0 + /** @type {import('./keypress.js').SelectState} */ + const initial = { + kind: 'select', + title: spec.title, + options: spec.options.map((o) => ({ + value: o.value, + label: o.label, + ...(o.summary !== undefined ? { summary: o.summary } : {}), + })), + cursor: defaultIdx, + status: 'active', + ...(spec.hint !== undefined ? { hint: spec.hint } : {}), + } + const io = resolveIo(spec) + const final = /** @type {import('./keypress.js').SelectState} */ (await run(initial, io)) + return final.options[final.cursor].value +} + +/** + * @typedef {Object} TextSpec + * @property {string} title + * @property {string} [hint] + * @property {string} [default] + * @property {((v: string) => string | null)} [validate] + * @property {boolean} [mask] + * @property {NodeJS.ReadableStream} [stdin] + * @property {NodeJS.WritableStream} [stdout] + */ + +/** + * Render a single-line text prompt and resolve to the string the user + * confirmed. When `default` is set and the user presses enter with an + * empty buffer, the default is returned. + * + * @param {TextSpec} spec + * @returns {Promise} + */ +export async function text(spec) { + /** @type {import('./keypress.js').TextState} */ + const initial = { + kind: 'text', + title: spec.title, + value: '', + mask: spec.mask === true, + status: 'active', + ...(spec.hint !== undefined ? { hint: spec.hint } : {}), + ...(spec.default !== undefined ? { default: spec.default } : {}), + ...(spec.validate !== undefined ? { validate: spec.validate } : {}), + } + const io = resolveIo(spec) + const final = /** @type {import('./keypress.js').TextState} */ (await run(initial, io)) + return final.value +} + +/** + * @typedef {Object} ConfirmSpec + * @property {string} title + * @property {string} [hint] + * @property {boolean} [default] + * @property {NodeJS.ReadableStream} [stdin] + * @property {NodeJS.WritableStream} [stdout] + */ + +/** + * Render a yes/no confirmation prompt and resolve to a boolean. + * + * @param {ConfirmSpec} spec + * @returns {Promise} + */ +export async function confirm(spec) { + /** @type {import('./keypress.js').ConfirmState} */ + const initial = { + kind: 'confirm', + title: spec.title, + default: spec.default === true, + status: 'active', + ...(spec.hint !== undefined ? { hint: spec.hint } : {}), + } + const io = resolveIo(spec) + const final = /** @type {import('./keypress.js').ConfirmState} */ (await run(initial, io)) + return final.value === true +} + +/** + * @param {{ stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream }} spec + * @returns {{ stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream }} + */ +function resolveIo(spec) { + return { + stdin: spec.stdin ?? process.stdin, + stdout: spec.stdout ?? process.stdout, + } +} diff --git a/src/core/cli/tui/keypress.js b/src/core/cli/tui/keypress.js new file mode 100644 index 0000000..7fb50e3 --- /dev/null +++ b/src/core/cli/tui/keypress.js @@ -0,0 +1,233 @@ +// @ts-check + +/** + * Pure reducer for the TUI primitives. The runtime (runtime.js) is + * responsible for capturing raw key events and routing them through + * here; this module performs no I/O and never reads from `process.*`. + * + * State is intentionally serializable so reducer behavior can be + * exhaustively driven by synthetic key events in unit tests. + */ + +/** + * @typedef {Object} Key + * @property {string} [name] Special name: 'up', 'down', 'space', + * 'return', 'escape', 'backspace', or a + * single character ('a', '1', ...). + * @property {string} [sequence] Raw character(s) — used for printable + * text input in `text` mode and the y/n + * chars in `confirm` mode. + * @property {boolean} [ctrl] + * @property {boolean} [shift] + * @property {boolean} [meta] + */ + +/** + * @typedef {Object} MultiselectOption + * @property {string|number} value + * @property {string} label + * @property {string} [summary] + * @property {boolean} checked + */ + +/** + * @typedef {Object} MultiselectState + * @property {'multiselect'} kind + * @property {string} title + * @property {string} [hint] + * @property {MultiselectOption[]} options + * @property {number} cursor + * @property {{ min?: number, max?: number }} [bounds] + * @property {'active'|'resolved'|'cancelled'} status + * @property {string} [error] + */ + +/** + * @typedef {Object} SelectOption + * @property {string|number} value + * @property {string} label + * @property {string} [summary] + */ + +/** + * @typedef {Object} SelectState + * @property {'select'} kind + * @property {string} title + * @property {string} [hint] + * @property {SelectOption[]} options + * @property {number} cursor + * @property {'active'|'resolved'|'cancelled'} status + */ + +/** + * @typedef {Object} TextState + * @property {'text'} kind + * @property {string} title + * @property {string} [hint] + * @property {string} [default] + * @property {string} value + * @property {boolean} mask + * @property {((v: string) => string | null)} [validate] + * @property {'active'|'resolved'|'cancelled'} status + * @property {string} [error] + */ + +/** + * @typedef {Object} ConfirmState + * @property {'confirm'} kind + * @property {string} title + * @property {string} [hint] + * @property {boolean} default + * @property {boolean} [value] Set when resolved. + * @property {'active'|'resolved'|'cancelled'} status + */ + +/** @typedef {MultiselectState|SelectState|TextState|ConfirmState} State */ + +/** + * Apply a single key event to a state and return the next state. Pure: + * never mutates `state`, never performs I/O. + * + * @param {State} state + * @param {Key} key + * @returns {State} + */ +export function reduce(state, key) { + if (state.status !== 'active') return state + if (key.ctrl && key.name === 'c') { + return cancelledOf(state) + } + if (key.name === 'escape') { + return cancelledOf(state) + } + switch (state.kind) { + case 'multiselect': return reduceMultiselect(state, key) + case 'select': return reduceSelect(state, key) + case 'text': return reduceText(state, key) + case 'confirm': return reduceConfirm(state, key) + } +} + +/** + * @param {State} state + * @returns {State} + */ +function cancelledOf(state) { + return /** @type {State} */ ({ ...state, status: 'cancelled' }) +} + +/** + * @param {MultiselectState} state + * @param {Key} key + * @returns {MultiselectState} + */ +function reduceMultiselect(state, key) { + const n = state.options.length + if (key.name === 'return') { + const selected = state.options.filter((o) => o.checked).length + const min = state.bounds?.min ?? 0 + const max = state.bounds?.max + if (selected < min) { + return { ...state, error: `select at least ${min}` } + } + if (typeof max === 'number' && selected > max) { + return { ...state, error: `select at most ${max}` } + } + return { ...state, status: 'resolved', error: undefined } + } + if (n === 0) return state + switch (key.name) { + case 'up': + case 'k': + return { ...state, cursor: (state.cursor - 1 + n) % n, error: undefined } + case 'down': + case 'j': + return { ...state, cursor: (state.cursor + 1) % n, error: undefined } + case 'space': { + const opts = state.options.slice() + const cur = opts[state.cursor] + opts[state.cursor] = { ...cur, checked: !cur.checked } + return { ...state, options: opts, error: undefined } + } + case 'a': { + const allChecked = state.options.every((o) => o.checked) + const opts = state.options.map((o) => ({ ...o, checked: !allChecked })) + return { ...state, options: opts, error: undefined } + } + } + if (key.name && /^[1-9]$/.test(key.name)) { + const idx = Number.parseInt(key.name, 10) - 1 + if (idx >= 0 && idx < n) { + return { ...state, cursor: idx, error: undefined } + } + } + return state +} + +/** + * @param {SelectState} state + * @param {Key} key + * @returns {SelectState} + */ +function reduceSelect(state, key) { + const n = state.options.length + if (n === 0) return state + switch (key.name) { + case 'up': + case 'k': + return { ...state, cursor: (state.cursor - 1 + n) % n } + case 'down': + case 'j': + return { ...state, cursor: (state.cursor + 1) % n } + case 'return': + return { ...state, status: 'resolved' } + } + return state +} + +/** + * @param {TextState} state + * @param {Key} key + * @returns {TextState} + */ +function reduceText(state, key) { + if (key.name === 'return') { + const effective = state.value.length > 0 ? state.value : (state.default ?? '') + if (state.validate) { + const err = state.validate(effective) + if (err !== null && err !== undefined && err !== '') { + return { ...state, error: err } + } + } + return { ...state, value: effective, status: 'resolved', error: undefined } + } + if (key.name === 'backspace') { + if (state.value.length === 0) return state + return { ...state, value: state.value.slice(0, -1), error: undefined } + } + if (key.sequence && !key.ctrl && !key.meta) { + const code = key.sequence.charCodeAt(0) + if (code >= 32 && code !== 127) { + return { ...state, value: state.value + key.sequence, error: undefined } + } + } + return state +} + +/** + * @param {ConfirmState} state + * @param {Key} key + * @returns {ConfirmState} + */ +function reduceConfirm(state, key) { + if (key.sequence === 'y' || key.sequence === 'Y') { + return { ...state, status: 'resolved', value: true } + } + if (key.sequence === 'n' || key.sequence === 'N') { + return { ...state, status: 'resolved', value: false } + } + if (key.name === 'return') { + return { ...state, status: 'resolved', value: state.default } + } + return state +} diff --git a/src/core/cli/tui/render.js b/src/core/cli/tui/render.js new file mode 100644 index 0000000..02886e6 --- /dev/null +++ b/src/core/cli/tui/render.js @@ -0,0 +1,150 @@ +// @ts-check + +/** + * Pure frame builder. Given a reducer state, returns the full string + * that should be written to stdout to display the current frame. + * + * The returned string ends with a trailing newline. Lines are joined + * with `\n` (no `\r\n` — runtime uses raw mode where `\n` advances a + * row without resetting the column, and the runtime emits an explicit + * `\r` before redrawing). + * + * No I/O. No reads from `process.*`. + */ + +/** @typedef {import('./keypress.js').State} State */ +/** @typedef {import('./keypress.js').MultiselectState} MultiselectState */ +/** @typedef {import('./keypress.js').SelectState} SelectState */ +/** @typedef {import('./keypress.js').TextState} TextState */ +/** @typedef {import('./keypress.js').ConfirmState} ConfirmState */ + +/** + * @typedef {Object} RenderOpts + * @property {boolean} color + */ + +const ANSI = { + bold: '\x1b[1m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + red: '\x1b[31m', + reset: '\x1b[0m', +} + +/** + * @param {string} text + * @param {string} sgr + * @param {boolean} on + */ +function paint(text, sgr, on) { + return on ? `${sgr}${text}${ANSI.reset}` : text +} + +/** + * @param {State} state + * @param {RenderOpts} opts + * @returns {string} + */ +export function render(state, opts) { + switch (state.kind) { + case 'multiselect': return renderMultiselect(state, opts) + case 'select': return renderSelect(state, opts) + case 'text': return renderText(state, opts) + case 'confirm': return renderConfirm(state, opts) + } +} + +const DEFAULT_HINT = { + multiselect: 'space toggle · a all · enter confirm · esc cancel', + select: 'up/down · enter pick · esc cancel', + text: 'enter confirm · esc cancel', + confirm: 'y/n · enter accepts default · esc cancel', +} + +/** + * @param {MultiselectState} state + * @param {RenderOpts} opts + */ +function renderMultiselect(state, opts) { + const lines = [] + lines.push(paint(state.title, ANSI.bold, opts.color)) + lines.push(paint(state.hint ?? DEFAULT_HINT.multiselect, ANSI.dim, opts.color)) + lines.push('') + state.options.forEach((o, i) => { + const cursor = i === state.cursor + const pointer = cursor ? '>' : ' ' + const box = o.checked ? '[x]' : '[ ]' + const row = `${pointer} ${box} ${o.label}` + if (cursor) { + lines.push(paint(row, ANSI.cyan, opts.color)) + } else if (o.checked) { + lines.push(paint(row, ANSI.green, opts.color)) + } else { + lines.push(row) + } + if (o.summary && o.summary !== o.label) { + lines.push(paint(` ${o.summary}`, ANSI.dim, opts.color)) + } + }) + if (state.error) { + lines.push(paint(state.error, ANSI.red, opts.color)) + } + return lines.join('\n') + '\n' +} + +/** + * @param {SelectState} state + * @param {RenderOpts} opts + */ +function renderSelect(state, opts) { + const lines = [] + lines.push(paint(state.title, ANSI.bold, opts.color)) + lines.push(paint(state.hint ?? DEFAULT_HINT.select, ANSI.dim, opts.color)) + lines.push('') + state.options.forEach((o, i) => { + const cursor = i === state.cursor + const pointer = cursor ? '>' : ' ' + const row = `${pointer} ${o.label}` + lines.push(cursor ? paint(row, ANSI.cyan, opts.color) : row) + if (o.summary && o.summary !== o.label) { + lines.push(paint(` ${o.summary}`, ANSI.dim, opts.color)) + } + }) + return lines.join('\n') + '\n' +} + +/** + * @param {TextState} state + * @param {RenderOpts} opts + */ +function renderText(state, opts) { + const lines = [] + lines.push(paint(state.title, ANSI.bold, opts.color)) + lines.push(paint(state.hint ?? DEFAULT_HINT.text, ANSI.dim, opts.color)) + lines.push('') + const shown = state.mask ? '*'.repeat(state.value.length) : state.value + let body = `> ${shown}` + if (state.value.length === 0 && state.default) { + body += paint(` (default: ${state.default})`, ANSI.dim, opts.color) + } + lines.push(body) + if (state.error) { + lines.push(paint(state.error, ANSI.red, opts.color)) + } + return lines.join('\n') + '\n' +} + +/** + * @param {ConfirmState} state + * @param {RenderOpts} opts + */ +function renderConfirm(state, opts) { + const lines = [] + lines.push(paint(state.title, ANSI.bold, opts.color)) + lines.push(paint(state.hint ?? DEFAULT_HINT.confirm, ANSI.dim, opts.color)) + lines.push('') + const yn = state.default ? '[Y/n]' : '[y/N]' + lines.push(`> ${yn}`) + return lines.join('\n') + '\n' +} diff --git a/src/core/cli/tui/runtime.js b/src/core/cli/tui/runtime.js new file mode 100644 index 0000000..552f999 --- /dev/null +++ b/src/core/cli/tui/runtime.js @@ -0,0 +1,161 @@ +// @ts-check + +import process from 'node:process' +import readline from 'node:readline' + +import { reduce } from './keypress.js' +import { render } from './render.js' + +/** @typedef {import('./keypress.js').State} State */ +/** @typedef {import('./keypress.js').Key} Key */ + +const CURSOR_HIDE = '\x1b[?25l' +const CURSOR_SHOW = '\x1b[?25h' +const CLEAR_TO_END = '\x1b[J' + +/** + * @typedef {Object} RunOpts + * @property {NodeJS.ReadableStream} stdin + * @property {NodeJS.WritableStream} stdout + * @property {NodeJS.ProcessEnv} [env] + */ + +/** + * Drive the reducer loop against a TTY. Resolves with the terminal + * state when the reducer reports `resolved`. Throws a + * {@link PromptCancelledError} when the reducer reports `cancelled`. + * + * @param {State} initialState + * @param {RunOpts} io + * @returns {Promise} + */ +export async function run(initialState, io) { + const env = io.env ?? process.env + ensureTty(io.stdin, io.stdout, env) + + const color = env.NO_COLOR ? false : true + /** @type {NodeJS.ReadStream} */ + const stdin = /** @type {any} */ (io.stdin) + const stdout = io.stdout + + /** @type {State} */ + let state = initialState + let previousLineCount = 0 + /** @type {((s: any, k: any) => void) | null} */ + let onKeypress = null + let cleanedUp = false + + // Snapshot raw mode so we can restore it on exit. + const previousRawMode = typeof stdin.isRaw === 'boolean' ? stdin.isRaw : false + readline.emitKeypressEvents(stdin) + if (typeof stdin.setRawMode === 'function') { + stdin.setRawMode(true) + } + stdout.write(CURSOR_HIDE) + + /** @returns {void} */ + function cleanup() { + if (cleanedUp) return + cleanedUp = true + if (onKeypress) { + stdin.removeListener('keypress', onKeypress) + onKeypress = null + } + try { + if (typeof stdin.setRawMode === 'function') { + stdin.setRawMode(previousRawMode) + } + } catch {} + try { stdout.write(CURSOR_SHOW) } catch {} + } + + function writeFrame() { + let buf = '' + if (previousLineCount > 0) { + buf += `\x1b[${previousLineCount}A\r${CLEAR_TO_END}` + } + const frame = render(state, { color }) + buf += frame + previousLineCount = countTrailingLines(frame) + stdout.write(buf) + } + + writeFrame() + + return await new Promise((resolve, reject) => { + onKeypress = (str, key) => { + const k = normalizeKey(str, key) + state = reduce(state, k) + writeFrame() + if (state.status === 'resolved') { + cleanup() + resolve(state) + } else if (state.status === 'cancelled') { + cleanup() + reject(new PromptCancelledError()) + } + } + stdin.on('keypress', onKeypress) + if (typeof stdin.resume === 'function') stdin.resume() + }).finally(() => cleanup()) +} + +/** + * @param {NodeJS.ReadableStream | undefined} stdin + * @param {NodeJS.WritableStream | undefined} stdout + * @param {NodeJS.ProcessEnv} env + */ +function ensureTty(stdin, stdout, env) { + if (env.HYP_NO_TUI === '1') { + throw new Error('TUI prompt requires a TTY; got non-TTY stdin/stdout') + } + const inTty = stdin && /** @type {any} */ (stdin).isTTY === true + const outTty = stdout && /** @type {any} */ (stdout).isTTY === true + if (!inTty || !outTty) { + throw new Error('TUI prompt requires a TTY; got non-TTY stdin/stdout') + } +} + +/** + * @param {unknown} str + * @param {unknown} key + * @returns {Key} + */ +function normalizeKey(str, key) { + const k = /** @type {any} */ (key) ?? {} + /** @type {Key} */ + const out = { + ctrl: !!k.ctrl, + shift: !!k.shift, + meta: !!k.meta, + } + if (typeof k.name === 'string') out.name = k.name + if (typeof str === 'string') out.sequence = str + else if (typeof k.sequence === 'string') out.sequence = k.sequence + return out +} + +/** + * Count the number of newline characters in `s`. The runtime uses this + * to know how far to move the cursor up before clearing the previous + * frame. Frames always end with `\n`, so the value equals the number of + * rows the frame occupied below the start point. + * + * @param {string} s + */ +function countTrailingLines(s) { + let n = 0 + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++ + return n +} + +/** + * Thrown when the user cancels a TUI prompt (escape, ctrl+c). + * Callers should treat this as a non-fatal cancel signal. + */ +export class PromptCancelledError extends Error { + constructor(message = 'TUI prompt cancelled') { + super(message) + this.name = 'PromptCancelledError' + } +} diff --git a/test/core/cli/tui/keypress.test.js b/test/core/cli/tui/keypress.test.js new file mode 100644 index 0000000..f9ff091 --- /dev/null +++ b/test/core/cli/tui/keypress.test.js @@ -0,0 +1,302 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { reduce } from '../../../../src/core/cli/tui/keypress.js' + +/** @returns {import('../../../../src/core/cli/tui/keypress.js').MultiselectState} */ +function multiselectState(overrides = {}) { + return { + kind: 'multiselect', + title: 'pick', + options: [ + { value: 'a', label: 'A', checked: false }, + { value: 'b', label: 'B', checked: false }, + { value: 'c', label: 'C', checked: false }, + ], + cursor: 0, + status: 'active', + ...overrides, + } +} + +/** @returns {import('../../../../src/core/cli/tui/keypress.js').SelectState} */ +function selectState(overrides = {}) { + return { + kind: 'select', + title: 'pick', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'c', label: 'C' }, + ], + cursor: 0, + status: 'active', + ...overrides, + } +} + +/** @returns {import('../../../../src/core/cli/tui/keypress.js').TextState} */ +function textState(overrides = {}) { + return { + kind: 'text', + title: 'name', + value: '', + mask: false, + status: 'active', + ...overrides, + } +} + +/** @returns {import('../../../../src/core/cli/tui/keypress.js').ConfirmState} */ +function confirmState(overrides = {}) { + return { + kind: 'confirm', + title: 'go?', + default: true, + status: 'active', + ...overrides, + } +} + +test('reduce: ctrl+c cancels any active state', () => { + for (const s of [multiselectState(), selectState(), textState(), confirmState()]) { + const next = reduce(s, { name: 'c', ctrl: true }) + assert.equal(next.status, 'cancelled') + } +}) + +test('reduce: escape cancels any active state', () => { + for (const s of [multiselectState(), selectState(), textState(), confirmState()]) { + const next = reduce(s, { name: 'escape' }) + assert.equal(next.status, 'cancelled') + } +}) + +test('reduce: terminal states ignore further input', () => { + const resolved = { ...multiselectState(), status: /** @type {const} */ ('resolved') } + const cancelled = { ...multiselectState(), status: /** @type {const} */ ('cancelled') } + assert.strictEqual(reduce(resolved, { name: 'down' }), resolved) + assert.strictEqual(reduce(cancelled, { name: 'space' }), cancelled) +}) + +test('multiselect: arrow down moves cursor and wraps', () => { + let s = multiselectState({ cursor: 0 }) + s = /** @type {any} */ (reduce(s, { name: 'down' })) + assert.equal(s.cursor, 1) + s = /** @type {any} */ (reduce(s, { name: 'down' })) + assert.equal(s.cursor, 2) + s = /** @type {any} */ (reduce(s, { name: 'down' })) + assert.equal(s.cursor, 0) +}) + +test('multiselect: arrow up wraps to last option', () => { + const s = multiselectState({ cursor: 0 }) + const next = /** @type {any} */ (reduce(s, { name: 'up' })) + assert.equal(next.cursor, 2) +}) + +test('multiselect: j and k are aliases for down and up', () => { + let s = multiselectState({ cursor: 1 }) + s = /** @type {any} */ (reduce(s, { name: 'k' })) + assert.equal(s.cursor, 0) + s = /** @type {any} */ (reduce(s, { name: 'j' })) + assert.equal(s.cursor, 1) +}) + +test('multiselect: space toggles current option', () => { + let s = multiselectState({ cursor: 1 }) + s = /** @type {any} */ (reduce(s, { name: 'space' })) + assert.equal(s.options[1].checked, true) + assert.equal(s.options[0].checked, false) + assert.equal(s.options[2].checked, false) + s = /** @type {any} */ (reduce(s, { name: 'space' })) + assert.equal(s.options[1].checked, false) +}) + +test('multiselect: a toggles all on then all off', () => { + let s = multiselectState() + s = /** @type {any} */ (reduce(s, { name: 'a' })) + assert.deepEqual(s.options.map((/** @type {any} */ o) => o.checked), [true, true, true]) + s = /** @type {any} */ (reduce(s, { name: 'a' })) + assert.deepEqual(s.options.map((/** @type {any} */ o) => o.checked), [false, false, false]) +}) + +test('multiselect: a with mixed selection checks all', () => { + const s = multiselectState({ + options: [ + { value: 'a', label: 'A', checked: true }, + { value: 'b', label: 'B', checked: false }, + ], + }) + const next = /** @type {any} */ (reduce(s, { name: 'a' })) + assert.deepEqual(next.options.map((/** @type {any} */ o) => o.checked), [true, true]) +}) + +test('multiselect: digit keys 1-3 jump to in-range index', () => { + const s = multiselectState() + for (let i = 1; i <= 3; i++) { + const next = /** @type {any} */ (reduce(s, { name: String(i) })) + assert.equal(next.cursor, i - 1) + } +}) + +test('multiselect: digit out of range is a no-op', () => { + const s = multiselectState() + const next = reduce(s, { name: '9' }) + assert.strictEqual(next, s) +}) + +test('multiselect: enter without bounds resolves', () => { + let s = multiselectState() + s = /** @type {any} */ (reduce(s, { name: 'space' })) + s = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(s.status, 'resolved') +}) + +test('multiselect: enter below bounds.min sets error and stays active', () => { + const s = multiselectState({ bounds: { min: 2 } }) + const next = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(next.status, 'active') + assert.match(next.error, /at least 2/) +}) + +test('multiselect: enter above bounds.max sets error and stays active', () => { + /** @type {any} */ + let s = multiselectState({ + bounds: { max: 1 }, + options: [ + { value: 'a', label: 'A', checked: true }, + { value: 'b', label: 'B', checked: true }, + ], + cursor: 0, + }) + s = reduce(s, { name: 'return' }) + assert.equal(s.status, 'active') + assert.match(s.error, /at most 1/) +}) + +test('multiselect: bounds error clears on next cursor move', () => { + /** @type {any} */ + let s = multiselectState({ bounds: { min: 1 }, cursor: 0 }) + s = reduce(s, { name: 'return' }) + assert.ok(s.error) + s = reduce(s, { name: 'down' }) + assert.equal(s.error, undefined) +}) + +test('multiselect: empty options + enter with bounds.min still rejects', () => { + /** @type {any} */ + const s = multiselectState({ options: [], bounds: { min: 1 } }) + const next = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(next.status, 'active') + assert.match(next.error, /at least 1/) +}) + +test('select: cursor moves and wraps; enter resolves', () => { + let s = selectState({ cursor: 0 }) + s = /** @type {any} */ (reduce(s, { name: 'up' })) + assert.equal(s.cursor, 2) + s = /** @type {any} */ (reduce(s, { name: 'down' })) + assert.equal(s.cursor, 0) + s = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(s.status, 'resolved') +}) + +test('select: space does not toggle (single-select has no toggle)', () => { + const s = selectState() + const next = reduce(s, { name: 'space' }) + assert.strictEqual(next, s) +}) + +test('text: printable characters append to value', () => { + let s = textState() + s = /** @type {any} */ (reduce(s, { sequence: 'p' })) + s = /** @type {any} */ (reduce(s, { sequence: 'h' })) + s = /** @type {any} */ (reduce(s, { sequence: 'i' })) + s = /** @type {any} */ (reduce(s, { sequence: 'l' })) + assert.equal(s.value, 'phil') +}) + +test('text: backspace removes the last char and stops at empty', () => { + let s = textState({ value: 'abc' }) + s = /** @type {any} */ (reduce(s, { name: 'backspace' })) + assert.equal(s.value, 'ab') + s = /** @type {any} */ (reduce(s, { name: 'backspace' })) + s = /** @type {any} */ (reduce(s, { name: 'backspace' })) + assert.equal(s.value, '') + const noop = reduce(s, { name: 'backspace' }) + assert.strictEqual(noop, s) +}) + +test('text: control characters are ignored as input', () => { + const s = textState() + const next = reduce(s, { sequence: '\x1b[A' }) + assert.strictEqual(next, s) + const next2 = reduce(s, { sequence: 'x', ctrl: true }) + assert.strictEqual(next2, s) +}) + +test('text: enter without validate resolves with current value', () => { + const s = textState({ value: 'hi' }) + const next = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(next.status, 'resolved') + assert.equal(next.value, 'hi') +}) + +test('text: enter on empty value applies default', () => { + const s = textState({ default: 'fallback' }) + const next = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(next.status, 'resolved') + assert.equal(next.value, 'fallback') +}) + +test('text: enter when validate rejects sets error and stays active', () => { + const s = textState({ value: '', validate: (v) => (v.length === 0 ? 'required' : null) }) + const next = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(next.status, 'active') + assert.equal(next.error, 'required') +}) + +test('text: mask flag does not change the value field; only render relies on it', () => { + let s = textState({ mask: true }) + s = /** @type {any} */ (reduce(s, { sequence: 's' })) + s = /** @type {any} */ (reduce(s, { sequence: 'e' })) + s = /** @type {any} */ (reduce(s, { sequence: 'c' })) + assert.equal(s.value, 'sec') + assert.equal(s.mask, true) +}) + +test('confirm: y and Y resolve to true', () => { + for (const ch of ['y', 'Y']) { + const s = confirmState({ default: false }) + const next = /** @type {any} */ (reduce(s, { sequence: ch })) + assert.equal(next.status, 'resolved') + assert.equal(next.value, true) + } +}) + +test('confirm: n and N resolve to false', () => { + for (const ch of ['n', 'N']) { + const s = confirmState({ default: true }) + const next = /** @type {any} */ (reduce(s, { sequence: ch })) + assert.equal(next.status, 'resolved') + assert.equal(next.value, false) + } +}) + +test('confirm: enter resolves with default', () => { + for (const def of [true, false]) { + const s = confirmState({ default: def }) + const next = /** @type {any} */ (reduce(s, { name: 'return' })) + assert.equal(next.status, 'resolved') + assert.equal(next.value, def) + } +}) + +test('confirm: other letters are no-ops', () => { + const s = confirmState() + const next = reduce(s, { sequence: 'a' }) + assert.strictEqual(next, s) +}) diff --git a/test/core/cli/tui/non-tty.test.js b/test/core/cli/tui/non-tty.test.js new file mode 100644 index 0000000..2d42b1e --- /dev/null +++ b/test/core/cli/tui/non-tty.test.js @@ -0,0 +1,101 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import { PassThrough } from 'node:stream' + +import { + multiselect, + select, + text, + confirm, +} from '../../../../src/core/cli/tui/index.js' + +const ERROR_RE = /TUI prompt requires a TTY; got non-TTY stdin\/stdout/ + +function makeNonTty() { + const stdin = new PassThrough() + const stdout = new PassThrough() + // isTTY is undefined; runtime should reject before touching the stream. + return { stdin, stdout } +} + +function makeFakeTty() { + const stdin = new PassThrough() + const stdout = new PassThrough() + Object.defineProperty(stdin, 'isTTY', { value: true }) + Object.defineProperty(stdout, 'isTTY', { value: true }) + // @ts-expect-error — PassThrough does not declare setRawMode. + stdin.setRawMode = () => {} + return { stdin, stdout } +} + +test('non-TTY stdin rejects multiselect with the documented error', async () => { + const io = makeNonTty() + await assert.rejects( + multiselect({ + title: 'pick', + options: [{ value: 'a', label: 'A' }], + stdin: io.stdin, + stdout: io.stdout, + }), + (err) => err instanceof Error && ERROR_RE.test(err.message), + ) +}) + +test('non-TTY stdin rejects select with the documented error', async () => { + const io = makeNonTty() + await assert.rejects( + select({ + title: 'choose', + options: [{ value: 'a', label: 'A' }], + stdin: io.stdin, + stdout: io.stdout, + }), + (err) => err instanceof Error && ERROR_RE.test(err.message), + ) +}) + +test('non-TTY stdin rejects text with the documented error', async () => { + const io = makeNonTty() + await assert.rejects( + text({ + title: 'name', + stdin: io.stdin, + stdout: io.stdout, + }), + (err) => err instanceof Error && ERROR_RE.test(err.message), + ) +}) + +test('non-TTY stdin rejects confirm with the documented error', async () => { + const io = makeNonTty() + await assert.rejects( + confirm({ + title: 'go?', + stdin: io.stdin, + stdout: io.stdout, + }), + (err) => err instanceof Error && ERROR_RE.test(err.message), + ) +}) + +test('HYP_NO_TUI=1 forces the same TTY error even for fake-TTY streams', async () => { + const io = makeFakeTty() + const prevFlag = process.env.HYP_NO_TUI + process.env.HYP_NO_TUI = '1' + try { + await assert.rejects( + multiselect({ + title: 'pick', + options: [{ value: 'a', label: 'A' }], + stdin: io.stdin, + stdout: io.stdout, + }), + (err) => err instanceof Error && ERROR_RE.test(err.message), + ) + } finally { + if (prevFlag === undefined) delete process.env.HYP_NO_TUI + else process.env.HYP_NO_TUI = prevFlag + } +}) diff --git a/test/core/cli/tui/package-export.test.js b/test/core/cli/tui/package-export.test.js new file mode 100644 index 0000000..3cc38d1 --- /dev/null +++ b/test/core/cli/tui/package-export.test.js @@ -0,0 +1,20 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import * as direct from '../../../../src/core/cli/tui/index.js' +import * as viaPackage from 'hypaware/tui' + +test('package export "hypaware/tui" resolves to the same module', () => { + assert.equal(typeof viaPackage.multiselect, 'function') + assert.equal(typeof viaPackage.select, 'function') + assert.equal(typeof viaPackage.text, 'function') + assert.equal(typeof viaPackage.confirm, 'function') + assert.equal(typeof viaPackage.PromptCancelledError, 'function') + // The identical function references prove the exports point at the + // very same module instance — a sibling package importing + // 'hypaware/tui' gets the same code. + assert.strictEqual(viaPackage.multiselect, direct.multiselect) + assert.strictEqual(viaPackage.PromptCancelledError, direct.PromptCancelledError) +}) diff --git a/test/core/cli/tui/render.test.js b/test/core/cli/tui/render.test.js new file mode 100644 index 0000000..cd0325d --- /dev/null +++ b/test/core/cli/tui/render.test.js @@ -0,0 +1,226 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { render } from '../../../../src/core/cli/tui/render.js' + +const COLOR_RE = /\x1b\[\d{1,3}(;\d{1,3})*m/ + +test('multiselect: NO_COLOR frame contains no SGR escapes', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + options: [ + { value: 'a', label: 'A', checked: true }, + { value: 'b', label: 'B', checked: false, summary: 'detail b' }, + ], + cursor: 1, + status: 'active', + } + const out = render(state, { color: false }) + assert.doesNotMatch(out, COLOR_RE) +}) + +test('multiselect: colored frame contains at least one SGR escape', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + options: [{ value: 'a', label: 'A', checked: false }], + cursor: 0, + status: 'active', + } + const out = render(state, { color: true }) + assert.match(out, COLOR_RE) +}) + +test('multiselect: cursor row uses pointer ">", others use space', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + options: [ + { value: 'a', label: 'A', checked: false }, + { value: 'b', label: 'B', checked: true }, + { value: 'c', label: 'C', checked: false }, + ], + cursor: 1, + status: 'active', + } + const lines = render(state, { color: false }).split('\n') + assert.ok(lines.some((l) => l.startsWith(' [ ] A'))) + assert.ok(lines.some((l) => l.startsWith('> [x] B'))) + assert.ok(lines.some((l) => l.startsWith(' [ ] C'))) +}) + +test('multiselect: summary lines appear under labels when set', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + options: [ + { value: 'a', label: 'A', checked: false, summary: 'detail of A' }, + { value: 'b', label: 'B', checked: false }, + ], + cursor: 1, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, / \[ \] A\n detail of A\n/) +}) + +test('multiselect: empty options renders title + hint without rows', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'nothing', + options: [], + cursor: 0, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /^nothing\n/) + assert.equal(out.split('\n').filter((l) => l.startsWith('>') || l.startsWith(' ')).length, 0) +}) + +test('multiselect: error line is included when set', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + options: [{ value: 'a', label: 'A', checked: false }], + cursor: 0, + status: 'active', + error: 'select at least 1', + } + const out = render(state, { color: false }) + assert.match(out, /select at least 1/) +}) + +test('multiselect: frame ends with a single trailing newline', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + options: [{ value: 'a', label: 'A', checked: false }], + cursor: 0, + status: 'active', + } + const out = render(state, { color: false }) + assert.ok(out.endsWith('\n')) + assert.ok(!out.endsWith('\n\n')) +}) + +test('select: renders pointer-and-label rows', () => { + /** @type {any} */ + const state = { + kind: 'select', + title: 'choose', + options: [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta' }, + ], + cursor: 1, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /^choose\n/) + assert.match(out, / Alpha\n/) + assert.match(out, /> Beta\n/) +}) + +test('text: render shows "> " followed by the value', () => { + /** @type {any} */ + const state = { + kind: 'text', + title: 'name', + value: 'phil', + mask: false, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /> phil\n/) +}) + +test('text: render masks value when mask is true', () => { + /** @type {any} */ + const state = { + kind: 'text', + title: 'token', + value: 'sek', + mask: true, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /> \*\*\*\n/) + assert.doesNotMatch(out, /sek/) +}) + +test('text: default hint shown when value is empty', () => { + /** @type {any} */ + const state = { + kind: 'text', + title: 'name', + value: '', + default: 'fallback', + mask: false, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /\(default: fallback\)/) +}) + +test('text: default hint disappears once value is typed', () => { + /** @type {any} */ + const state = { + kind: 'text', + title: 'name', + value: 'p', + default: 'fallback', + mask: false, + status: 'active', + } + const out = render(state, { color: false }) + assert.doesNotMatch(out, /\(default: fallback\)/) +}) + +test('confirm: render shows [Y/n] when default is true', () => { + /** @type {any} */ + const state = { + kind: 'confirm', + title: 'go?', + default: true, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /\[Y\/n\]/) +}) + +test('confirm: render shows [y/N] when default is false', () => { + /** @type {any} */ + const state = { + kind: 'confirm', + title: 'go?', + default: false, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /\[y\/N\]/) +}) + +test('render: hint override replaces default hint line', () => { + /** @type {any} */ + const state = { + kind: 'multiselect', + title: 'pick', + hint: 'CUSTOM HINT', + options: [{ value: 'a', label: 'A', checked: false }], + cursor: 0, + status: 'active', + } + const out = render(state, { color: false }) + assert.match(out, /CUSTOM HINT/) + assert.doesNotMatch(out, /space toggle/) +}) diff --git a/test/core/cli/tui/runtime.test.js b/test/core/cli/tui/runtime.test.js new file mode 100644 index 0000000..1fd05df --- /dev/null +++ b/test/core/cli/tui/runtime.test.js @@ -0,0 +1,219 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import { PassThrough } from 'node:stream' + +import { + multiselect, + select, + text, + confirm, + PromptCancelledError, +} from '../../../../src/core/cli/tui/index.js' + +const ENV = { NO_COLOR: '1' } + +/** + * Build a pair of PassThrough streams that look enough like a TTY for + * the runtime to accept them. `stdin.setRawMode` is stubbed so the + * runtime can flip modes without crashing. + */ +function makeTty() { + const stdin = new PassThrough() + const stdout = new PassThrough() + Object.defineProperty(stdin, 'isTTY', { value: true }) + Object.defineProperty(stdout, 'isTTY', { value: true }) + // @ts-expect-error — PassThrough does not declare setRawMode but the runtime probes for it. + stdin.setRawMode = () => {} + // Collect stdout writes so tests can assert on what was rendered. + /** @type {string[]} */ + const writes = [] + stdout.on('data', (chunk) => writes.push(String(chunk))) + return { stdin, stdout, output: () => writes.join('') } +} + +/** + * Run the previously-spawned prompt promise to settlement after + * writing the given sequence of bytes to stdin (one chunk per tick). + * + * @param {PassThrough} stdin + * @param {string[]} chunks + */ +async function feed(stdin, chunks) { + for (const c of chunks) { + stdin.write(c) + // Let the keypress parser flush before sending the next chunk. + await new Promise((r) => setImmediate(r)) + } +} + +test('runtime: multiselect happy path returns selected values in order', async () => { + const io = makeTty() + const promise = multiselect({ + title: 'pick', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'c', label: 'C' }, + ], + stdin: io.stdin, + stdout: io.stdout, + }) + // space (toggle A) → down → down → space (toggle C) → enter + await feed(io.stdin, [' ', '\x1b[B', '\x1b[B', ' ', '\r']) + const result = await promise + assert.deepEqual(result, ['a', 'c']) +}) + +test('runtime: multiselect cancel via ctrl+c throws PromptCancelledError', async () => { + const io = makeTty() + const promise = multiselect({ + title: 'pick', + options: [{ value: 'a', label: 'A' }], + stdin: io.stdin, + stdout: io.stdout, + }) + // Attach the rejection assertion BEFORE feeding the cancel byte so the + // unhandled-rejection detector never fires. + const rejection = assert.rejects(promise, (err) => err instanceof PromptCancelledError) + await feed(io.stdin, ['\x03']) + await rejection +}) + +test('runtime: multiselect bounds rejection retains active state until satisfied', async () => { + const io = makeTty() + const promise = multiselect({ + title: 'pick', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + bounds: { min: 1 }, + stdin: io.stdin, + stdout: io.stdout, + }) + // enter with zero selections → error, stays active + // then toggle A, enter → resolves with ['a'] + await feed(io.stdin, ['\r', ' ', '\r']) + const result = await promise + assert.deepEqual(result, ['a']) + assert.match(io.output(), /select at least 1/) +}) + +test('runtime: select returns the value at the cursor on enter', async () => { + const io = makeTty() + const promise = select({ + title: 'choose', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + stdin: io.stdin, + stdout: io.stdout, + }) + await feed(io.stdin, ['\x1b[B', '\r']) + const result = await promise + assert.equal(result, 'b') +}) + +test('runtime: select cancel throws PromptCancelledError', async () => { + const io = makeTty() + const promise = select({ + title: 'choose', + options: [{ value: 'a', label: 'A' }], + stdin: io.stdin, + stdout: io.stdout, + }) + const rejection = assert.rejects(promise, (err) => err instanceof PromptCancelledError) + await feed(io.stdin, ['\x1b']) + await rejection +}) + +test('runtime: text returns the typed buffer on enter', async () => { + const io = makeTty() + const promise = text({ + title: 'name', + stdin: io.stdin, + stdout: io.stdout, + }) + await feed(io.stdin, ['p', 'h', 'i', 'l', '\r']) + const result = await promise + assert.equal(result, 'phil') +}) + +test('runtime: text empty + default returns default on enter', async () => { + const io = makeTty() + const promise = text({ + title: 'name', + default: 'phil', + stdin: io.stdin, + stdout: io.stdout, + }) + await feed(io.stdin, ['\r']) + const result = await promise + assert.equal(result, 'phil') +}) + +test('runtime: text validate-rejected enter stays active until valid', async () => { + const io = makeTty() + const promise = text({ + title: 'name', + validate: (v) => (v.length < 2 ? 'too short' : null), + stdin: io.stdin, + stdout: io.stdout, + }) + await feed(io.stdin, ['x', '\r', 'y', '\r']) + const result = await promise + assert.equal(result, 'xy') + assert.match(io.output(), /too short/) +}) + +test('runtime: confirm resolves true on y and false on n', async () => { + { + const io = makeTty() + const promise = confirm({ title: 'go?', stdin: io.stdin, stdout: io.stdout }) + await feed(io.stdin, ['y']) + assert.equal(await promise, true) + } + { + const io = makeTty() + const promise = confirm({ title: 'go?', stdin: io.stdin, stdout: io.stdout }) + await feed(io.stdin, ['n']) + assert.equal(await promise, false) + } +}) + +test('runtime: confirm enter resolves with the default value', async () => { + { + const io = makeTty() + const promise = confirm({ title: 'go?', default: true, stdin: io.stdin, stdout: io.stdout }) + await feed(io.stdin, ['\r']) + assert.equal(await promise, true) + } + { + const io = makeTty() + const promise = confirm({ title: 'go?', default: false, stdin: io.stdin, stdout: io.stdout }) + await feed(io.stdin, ['\r']) + assert.equal(await promise, false) + } +}) + +test('runtime: cursor-hide is written on entry and cursor-show on resolve', async () => { + const io = makeTty() + const promise = confirm({ title: 'go?', stdin: io.stdin, stdout: io.stdout }) + await feed(io.stdin, ['y']) + await promise + const out = io.output() + assert.ok(out.includes('\x1b[?25l'), 'cursor-hide escape not emitted') + assert.ok(out.includes('\x1b[?25h'), 'cursor-show escape not emitted on resolve') +}) + +test('runtime: cursor-show is written even on cancel', async () => { + const io = makeTty() + const promise = confirm({ title: 'go?', stdin: io.stdin, stdout: io.stdout }) + const rejection = assert.rejects(promise) + await feed(io.stdin, ['\x03']) + await rejection + assert.ok(io.output().includes('\x1b[?25h'), 'cursor-show escape not emitted on cancel') +})