diff --git a/cli/unstable_prompt_confirm.ts b/cli/unstable_prompt_confirm.ts new file mode 100644 index 000000000000..57166c1dc2f4 --- /dev/null +++ b/cli/unstable_prompt_confirm.ts @@ -0,0 +1,174 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const LF = "\n".charCodeAt(0); +const CR = "\r".charCodeAt(0); +const BS = "\b".charCodeAt(0); +const DEL = 0x7f; + +/** + * Represents a possible value for the confirm prompt. + * + * @typeParam T The type of the value returned when this option is selected. + */ +export interface PromptConfirmValue { + /** The key the user types to select this option (e.g., "y", "n", "m"). */ + key: string; + /** The display label shown in the prompt (e.g., "yes", "no", "maybe"). */ + label?: string; + /** The value returned when this option is selected. */ + value: T; +} + +/** Options for {@linkcode promptConfirm}. */ +export interface PromptConfirmOptions { + /** + * The key of the default value when the user presses Enter without typing. + * + * @default {"n"} + */ + default?: string; + + /** Clear the current line after the user's input. */ + clear?: boolean; +} + +/** Default values for the confirm prompt. */ +export const YES_NO_VALUES: PromptConfirmValue[] = [ + { key: "y", value: true }, + { key: "n", value: false }, +]; + +/** + * Shows the given message and waits for the user's input. Returns the value + * associated with the user's selection. + * + * The prompt shows the available options with the default option's key capitalized. + * For example: `Continue? [y/N]` where `N` is capitalized to indicate the default. + * If a label is provided, it's shown in parentheses: `[y (yes)/N (no)]`. + * + * The user can type either the key or the full label to select an option. + * + * @typeParam T The type of values that can be returned. + * @param message The prompt message to show to the user. + * @param values The possible values for the prompt. + * @param options The options for the prompt. + * @returns The value of the selected option, or `null` if stdin is not a TTY. + * + * @example Basic usage with YES_NO_VALUES + * ```ts ignore + * import { promptConfirm, YES_NO_VALUES } from "@std/cli/unstable-confirm"; + * + * const shouldProceed = promptConfirm("Continue?", YES_NO_VALUES); + * if (shouldProceed) { + * console.log("Continuing..."); + * } + * // Displays: Continue? [y/N] + * ``` + * + * @example With default set to yes + * ```ts ignore + * import { promptConfirm, YES_NO_VALUES } from "@std/cli/unstable-confirm"; + * + * const shouldProceed = promptConfirm("Continue?", YES_NO_VALUES, { default: "y" }); + * // Displays: Continue? [Y/n] + * ``` + * + * @example Custom values with labels + * ```ts ignore + * import { promptConfirm } from "@std/cli/unstable-confirm"; + * + * const result = promptConfirm("Save changes?", [ + * { key: "y", label: "yes", value: "save" }, + * { key: "n", label: "no", value: "discard" }, + * { key: "c", label: "cancel", value: "cancel" }, + * ], { default: "c" }); + * // Displays: Save changes? [y (yes)/n (no)/C (cancel)] + * ``` + * + * @example With clear option + * ```ts ignore + * import { promptConfirm, YES_NO_VALUES } from "@std/cli/unstable-confirm"; + * + * const shouldProceed = promptConfirm("Delete file?", YES_NO_VALUES, { clear: true }); + * ``` + */ +export function promptConfirm( + message: string, + values: PromptConfirmValue[], + options: PromptConfirmOptions = {}, +): T | null { + const input = Deno.stdin; + const output = Deno.stdout; + + if (!input.isTerminal()) { + return null; + } + + const defaultKey = options.default ?? "n"; + const { clear } = options; + + const defaultOption = values.find((v) => + v.key.toLowerCase() === defaultKey.toLowerCase() + ); + + const optionsDisplay = values.map((v) => { + const isDefault = v.key.toLowerCase() === defaultKey.toLowerCase(); + const key = isDefault ? v.key.toUpperCase() : v.key.toLowerCase(); + return `${key}${v.label ? ` (${v.label})` : ""}`; + }).join("/"); + + const prompt = `${message} [${optionsDisplay}] `; + output.writeSync(encoder.encode(prompt)); + + input.setRaw(true); + try { + const answer = readLineFromStdinSync(); + const trimmedAnswer = answer.trim().toLowerCase(); + + if (trimmedAnswer === "") { + return defaultOption?.value ?? values[0]!.value; + } + + const selectedOption = values.find((v) => + v.key.toLowerCase() === trimmedAnswer || + v.label?.toLowerCase() === trimmedAnswer + ); + + if (selectedOption) { + return selectedOption.value; + } + + return defaultOption?.value ?? values[0]!.value; + } finally { + if (clear) { + output.writeSync(encoder.encode("\r\x1b[K")); + } else { + output.writeSync(encoder.encode("\n")); + } + input.setRaw(false); + } +} + +function readLineFromStdinSync(): string { + const c = new Uint8Array(1); + const buf: number[] = []; + + while (true) { + const n = Deno.stdin.readSync(c); + if (n === null || n === 0) { + break; + } + if (c[0] === CR || c[0] === LF) { + break; + } + if (c[0] === BS || c[0] === DEL) { + buf.pop(); + } else { + buf.push(c[0]!); + } + } + return decoder.decode(new Uint8Array(buf)); +} diff --git a/cli/unstable_prompt_confirm_test.ts b/cli/unstable_prompt_confirm_test.ts new file mode 100644 index 000000000000..22dbbd782047 --- /dev/null +++ b/cli/unstable_prompt_confirm_test.ts @@ -0,0 +1,596 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals } from "@std/assert/equals"; +import { promptConfirm, YES_NO_VALUES } from "./unstable_prompt_confirm.ts"; +import { restore, stub } from "@std/testing/mock"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +Deno.test("promptConfirm() returns true when user enters 'y'", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["y", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns false when user enters 'n'", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["n", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, false); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns default false when user presses Enter", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, false); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns default true when default is set to 'y'", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [Y/n] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES, { default: "y" }); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() handles custom values with labels", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y (yes)/N (no)] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["y", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", [ + { key: "y", label: "yes", value: true }, + { key: "n", label: "no", value: false }, + ]); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() handles three options", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Save changes? [y (yes)/n (no)/C (cancel)] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["y", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Save changes?", [ + { key: "y", label: "yes", value: "save" }, + { key: "n", label: "no", value: "discard" }, + { key: "c", label: "cancel", value: "cancel" }, + ], { default: "c" }); + + assertEquals(result, "save"); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns third option value", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y (yes)/n (no)/M (maybe)] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["m", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", [ + { key: "y", label: "yes", value: "yes" }, + { key: "n", label: "no", value: "no" }, + { key: "m", label: "maybe", value: "maybe" }, + ], { default: "m" }); + + assertEquals(result, "maybe"); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() is case insensitive for key", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["Y", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() accepts label as input", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y (yes)/N (no)] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["y", "e", "s", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", [ + { key: "y", label: "yes", value: true }, + { key: "n", label: "no", value: false }, + ]); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() handles clear option", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\r\x1b[K", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["y", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES, { clear: true }); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns null if stdin is not a TTY", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => false); + + const expectedOutput: string[] = []; + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, null); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns default on invalid input", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["x", "\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, false); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() handles backspace", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y/N] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["n", "\x7f", "y", "\r"]; // n, backspace, y, enter + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", YES_NO_VALUES); + + assertEquals(result, true); + assertEquals(actualOutput, expectedOutput); + restore(); +}); + +Deno.test("promptConfirm() returns default for third option on Enter", () => { + stub(Deno.stdin, "setRaw"); + stub(Deno.stdin, "isTerminal", () => true); + + const expectedOutput = [ + "Continue? [y (yes)/n (no)/M (maybe)] ", + "\n", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + return data.length; + }, + ); + + let readIndex = 0; + const inputs = ["\r"]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const result = promptConfirm("Continue?", [ + { key: "y", label: "yes", value: "yes" }, + { key: "n", label: "no", value: "no" }, + { key: "m", label: "maybe", value: "maybe" }, + ], { default: "m" }); + + assertEquals(result, "maybe"); + assertEquals(actualOutput, expectedOutput); + restore(); +});