Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
},
Expand Down
180 changes: 180 additions & 0 deletions src/core/cli/tui/index.js
Original file line number Diff line number Diff line change
@@ -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<Array<string|number>>}
*/
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<string|number>}
*/
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<string>}
*/
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<boolean>}
*/
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,
}
}
Loading