From fe671a07f940af901819257aa8c8623adc006d5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:04:06 +0000 Subject: [PATCH 1/3] feat: Navigate prompts with ctrl-p and ctrl-n Re-emit the Emacs-style control keypresses as arrow keys so they move the cursor in every clack prompt kind, including autocomplete, which ignores clack's own key alias table. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FyVFq6gYChW9wtoDq8CHsD --- src/lib/util/prompt.test.ts | 61 ++++++++++++++++++++++++++++++++++++- src/lib/util/prompt.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/lib/util/prompt.test.ts b/src/lib/util/prompt.test.ts index 910867b9..2a73dbcb 100644 --- a/src/lib/util/prompt.test.ts +++ b/src/lib/util/prompt.test.ts @@ -1,6 +1,14 @@ +import { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' + import { expect, test } from 'vitest' -import { type SearchableChoice, searchChoices } from './prompt.js' +import { + arrowKeyFor, + emitArrowKeyAliases, + type SearchableChoice, + searchChoices, +} from './prompt.js' const workspaces = [ { label: 'Sandbox', hint: 'ws_1' }, @@ -33,3 +41,54 @@ test('searchChoices: offers every choice until something is typed', () => { expect(search('', workspaces)).toEqual(workspaces) expect(search(' ', workspaces)).toEqual(workspaces) }) + +const ctrl = (name: string): Key => ({ + name, + ctrl: true, + meta: false, + shift: false, + sequence: String.fromCharCode(name.charCodeAt(0) - 96), +}) + +test('arrowKeyFor: maps ctrl-p and ctrl-n to the arrow keys', () => { + expect(arrowKeyFor(ctrl('p'))?.name).toBe('up') + expect(arrowKeyFor(ctrl('n'))?.name).toBe('down') +}) + +test('arrowKeyFor: leaves every other key alone', () => { + expect(arrowKeyFor(undefined)).toBeUndefined() + expect(arrowKeyFor(ctrl('c'))).toBeUndefined() + expect(arrowKeyFor({ name: 'p', sequence: 'p' })).toBeUndefined() + expect(arrowKeyFor({ name: 'n', sequence: 'n' })).toBeUndefined() + expect(arrowKeyFor({ ...ctrl('p'), meta: true })).toBeUndefined() + expect(arrowKeyFor({ ...ctrl('n'), shift: true })).toBeUndefined() + expect(arrowKeyFor({ name: 'up', sequence: '\x1B[A' })).toBeUndefined() +}) + +test('emitArrowKeyAliases: re-emits control keypresses as arrow keys', () => { + const input = new EventEmitter() + emitArrowKeyAliases(input) + + const keypresses: Array<[string | undefined, Key | undefined]> = [] + input.on('keypress', (char, key) => keypresses.push([char, key])) + + input.emit('keypress', '\x10', ctrl('p')) + input.emit('keypress', 'a', { name: 'a', sequence: 'a' }) + + // The synthetic arrow key arrives first: the re-emit is synchronous, + // and the alias listener runs before any listener attached after it. + expect(keypresses).toEqual([ + [ + undefined, + { + name: 'up', + ctrl: false, + meta: false, + shift: false, + sequence: '\x1B[A', + }, + ], + ['\x10', ctrl('p')], + ['a', { name: 'a', sequence: 'a' }], + ]) +}) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 491ddd26..5eae9953 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -1,3 +1,6 @@ +import type { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' + import { autocomplete, autocompleteMultiselect, @@ -40,6 +43,46 @@ const ensureInteractive = (): void => { 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', ) } + installArrowKeyAliases() +} + +/** + * The arrow keypress an Emacs-style control keypress stands for, or + * undefined for any other key: ctrl-p is up and ctrl-n is down. + */ +export const arrowKeyFor = (key: Key | undefined): Key | undefined => { + if (key?.ctrl !== true || key.meta === true || key.shift === true) { + return undefined + } + const base = { ctrl: false, meta: false, shift: false } + if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' } + if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' } + return undefined +} + +/** + * Re-emit Emacs-style control keypresses as the arrow keys they stand for. + * + * Clack navigates on the readline key name, so a synthetic arrow keypress + * moves the cursor in every prompt kind. Its own alias table cannot express + * this: aliases match bare key names, unaware of ctrl, and are ignored by + * prompts that track typed input, such as autocomplete. + */ +export const emitArrowKeyAliases = (input: EventEmitter): void => { + input.on('keypress', (_char, key: Key | undefined) => { + const arrowKey = arrowKeyFor(key) + if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey) + }) +} + +let arrowKeyAliasesInstalled = false + +// Keypress events only flow while a prompt has stdin in raw mode, so the +// listener is inert the rest of the time and never holds the process open. +const installArrowKeyAliases = (): void => { + if (arrowKeyAliasesInstalled) return + arrowKeyAliasesInstalled = true + emitArrowKeyAliases(process.stdin) } const unwrap = (value: Value | symbol): Value => { From f4e10c3a1f4b4922d8cdebc4bf74b9546706c823 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:08:51 +0000 Subject: [PATCH 2/3] feat: Add ctrl-j/k prompt navigation and arrow key back and forth Replace the keypress alias listener with an input stream that rewrites keys before readline decodes them, which the alias approach could not do: ctrl-j arrives as a line feed that readline submits, wiping the typed autocomplete filter. The translated stream also makes the right arrow submit and the left arrow return to the previous prompt, both only while nothing is typed, so the caret still works while editing a filter or value. Going back is opt in per prompt, so a stray left arrow cannot abandon a command, and the command menu now goes up one level rather than back to the root. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FyVFq6gYChW9wtoDq8CHsD --- src/bin/cli.ts | 5 +- src/lib/interact-for-array.ts | 67 ++-- src/lib/interact-for-blueprint-object.test.ts | 46 ++- src/lib/interact-for-blueprint-object.ts | 317 ++++++++++-------- src/lib/interact-for-command-selection.ts | 31 +- src/lib/interact-for-custom-metadata.ts | 89 +++-- src/lib/interact-for-resource.ts | 3 + src/lib/interact-for-timestamp.ts | 3 + src/lib/util/prompt-input.test.ts | 218 ++++++++++++ src/lib/util/prompt-input.ts | 180 ++++++++++ src/lib/util/prompt.test.ts | 172 +++++++--- src/lib/util/prompt.ts | 213 ++++++------ 12 files changed, 975 insertions(+), 369 deletions(-) create mode 100644 src/lib/util/prompt-input.test.ts create mode 100644 src/lib/util/prompt-input.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index c687d039..389cf34e 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -190,11 +190,12 @@ async function cli(args: ParsedArgs) { const selectedCommand = await interactForCommandSelection(args._, ctx) - // Hit 'back' on a top-level command path, so we start again + // Hit 'back' in the command menu, so go up one level: drop the '[Back]' + // marker and the last real path word. if (selectedCommand.slice(-1)[0] === '[Back]') { return await cli({ ...args, - _: [], + _: selectedCommand.slice(0, -2), }) } diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 6ab01f71..9206f3fe 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact-for-array.ts @@ -1,5 +1,10 @@ import { getOutput } from './output/get-output.js' -import { promptNumber, promptSelect, promptText } from './util/prompt.js' +import { + PromptBackError, + promptNumber, + promptSelect, + promptText, +} from './util/prompt.js' export const interactForArray = async ( array: string[], @@ -23,31 +28,45 @@ export const interactForArray = async ( do { displayList() - action = await promptSelect({ - message: 'Choose an action:', - choices: [ - { label: 'Add an item', value: 'add' }, - { label: 'Remove an item', value: 'remove' }, - { label: 'Finish editing', value: 'done' }, - ], - }) - - if (action === 'add') { - const newItem = await promptText({ - message: 'Enter the new item:', + try { + action = await promptSelect({ + message: 'Choose an action:', + choices: [ + { label: 'Add an item', value: 'add' }, + { label: 'Remove an item', value: 'remove' }, + { label: 'Finish editing', value: 'done' }, + ], + allowBack: true, }) - if (newItem) { - updatedArray.push(newItem) + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + // Going back at the action menu finishes editing, keeping changes. + break + } + + try { + if (action === 'add') { + const newItem = await promptText({ + message: 'Enter the new item:', + allowBack: true, + }) + if (newItem) { + updatedArray.push(newItem) + } + } else if (action === 'remove') { + const index = await promptNumber({ + message: 'Enter the index of the item to remove:', + validate: (value) => + value > 0 && value <= updatedArray.length + ? undefined + : 'Invalid index', + allowBack: true, + }) + updatedArray.splice(index - 1, 1) } - } else if (action === 'remove') { - const index = await promptNumber({ - message: 'Enter the index of the item to remove:', - validate: (value) => - value > 0 && value <= updatedArray.length - ? undefined - : 'Invalid index', - }) - updatedArray.splice(index - 1, 1) + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + // Going back at an inner prompt returns to the action menu. } } while (action !== 'done') diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index dcb308de..75504eb1 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -5,11 +5,16 @@ import { interactForBlueprintObject } from './interact-for-blueprint-object.js' import { createMemoryOutput } from './output/create-memory-output.js' import { setOutput } from './output/get-output.js' import type { ContextHelpers } from './types.js' -import { promptAutocomplete } from './util/prompt.js' +import { + promptAutocomplete, + PromptBackError, + promptText, +} from './util/prompt.js' vi.mock('./util/prompt.js', () => ({ canPrompt: vi.fn(() => true), PromptCancelledError: class extends Error {}, + PromptBackError: class extends Error {}, promptText: vi.fn(), promptNumber: vi.fn(), promptConfirm: vi.fn(), @@ -20,6 +25,8 @@ vi.mock('./util/prompt.js', () => ({ beforeEach(() => { vi.mocked(promptAutocomplete).mockClear() + vi.mocked(promptAutocomplete).mockImplementation(async () => 'done') + vi.mocked(promptText).mockReset() // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) @@ -79,6 +86,43 @@ test('interactForBlueprintObject: submits without prompting when non-interactive expect(promptAutocomplete).not.toHaveBeenCalled() }) +test('interactForBlueprintObject: offers the parameter menu with a way back', async () => { + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + allowBack: true, + }) +}) + +test('interactForBlueprintObject: going back at the menu leaves the command', async () => { + vi.mocked(promptAutocomplete).mockRejectedValueOnce(new PromptBackError()) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toBe('[Back]') +}) + +test('interactForBlueprintObject: going back at a value prompt returns to the menu unset', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockRejectedValueOnce(new PromptBackError()) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) + expect(promptAutocomplete).toHaveBeenCalledTimes(2) +}) + test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { await expect( interactForBlueprintObject( diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 01af4d3d..e2ce180e 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -17,6 +17,7 @@ import { ellipsis } from './util/ellipsis.js' import { promptAutocomplete, promptAutocompleteMultiselect, + PromptBackError, promptConfirm, promptNumber, promptSelect, @@ -91,53 +92,60 @@ export const interactForBlueprintObject = async ( : `[${cmdPath}] Parameters` getOutput().info() - const paramToEdit = await promptAutocomplete({ - message: parameterSelectionMessage, - choices: [ - ...(haveAllRequiredParams && !args.isSubProperty - ? [ - { - value: 'done', - label: `[Make API Call] ${cmdPath}`, - }, - ] - : []), - ...(haveAllRequiredParams && args.isSubProperty - ? [ - { - label: `[Save]`, - value: 'done', - }, - ] - : []), - ...Object.keys(properties) - .map((k) => { - return { - label: k + (required.includes(k) ? '*' : ''), - value: k, - hint: - args.params[k] !== undefined - ? typeof args.params[k] === 'object' - ? ellipsis(JSON.stringify(args.params[k]), 60) - : `[${args.params[k]}]` - : undefined, - } - }) - .sort((a, b) => propSortScore(b.value) - propSortScore(a.value)), - ...(args.isSubProperty - ? [ - { - label: `[Leave Empty]`, - value: 'empty', - }, - ] - : []), - { - label: `[Back]`, - value: 'back', - }, - ], - }) + let paramToEdit: string + try { + paramToEdit = await promptAutocomplete({ + message: parameterSelectionMessage, + allowBack: true, + choices: [ + ...(haveAllRequiredParams && !args.isSubProperty + ? [ + { + value: 'done', + label: `[Make API Call] ${cmdPath}`, + }, + ] + : []), + ...(haveAllRequiredParams && args.isSubProperty + ? [ + { + label: `[Save]`, + value: 'done', + }, + ] + : []), + ...Object.keys(properties) + .map((k) => { + return { + label: k + (required.includes(k) ? '*' : ''), + value: k, + hint: + args.params[k] !== undefined + ? typeof args.params[k] === 'object' + ? ellipsis(JSON.stringify(args.params[k]), 60) + : `[${args.params[k]}]` + : undefined, + } + }) + .sort((a, b) => propSortScore(b.value) - propSortScore(a.value)), + ...(args.isSubProperty + ? [ + { + label: `[Leave Empty]`, + value: 'empty', + }, + ] + : []), + { + label: `[Back]`, + value: 'back', + }, + ], + }) + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + paramToEdit = 'back' + } if (paramToEdit === 'empty') { return undefined @@ -158,118 +166,129 @@ export const interactForBlueprintObject = async ( const prop = properties[paramToEdit] - if (paramToEdit === 'device_id') { - args.params[paramToEdit] = await interactForDevice() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'access_code_id') { - args.params[paramToEdit] = await interactForAccessCode(args.params as any) - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'connected_account_id') { - const connectedAccountId = await interactForConnectedAccount() - args.params[paramToEdit] = connectedAccountId - return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit === 'user_identity_id' || - paramToEdit === 'user_identity_ids' - ) { - const userIdentityId = await interactForUserIdentity() - args.params[paramToEdit] = - paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_system_id')) { - args.params[paramToEdit] = await interactForAcsSystem() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_user_id')) { - args.params[paramToEdit] = await interactForAcsUser() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_entrance_id')) { - args.params['acs_entrance_id'] = await interactForAcsEntrance() - return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit.endsWith('_at') || - paramToEdit === 'since' || - paramToEdit.endsWith('_before') || - paramToEdit.endsWith('_after') - ) { - args.params[paramToEdit] = await interactForTimestamp() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'custom_metadata') { - args.params[paramToEdit] = await interactForCustomMetadata( - args.params[paramToEdit] || {}, - ) - return interactForBlueprintObject(args, ctx) - } - - if (prop) { - if (['string', 'id', 'datetime'].includes(prop.format)) { - let value - if (prop.format === 'datetime') { - value = await interactForTimestamp() - } else { - value = await promptText({ - message: `${paramToEdit}:`, - }) - } - args.params[paramToEdit] = value + // Going back at any value prompt below returns to the parameter menu + // with the parameter unset. + try { + if (paramToEdit === 'device_id') { + args.params[paramToEdit] = await interactForDevice() return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'enum') { - const value = await promptSelect({ - message: `${paramToEdit}:`, - choices: prop.values.map((v) => ({ - label: v.name, - value: v.name, - })), - }) - args.params[paramToEdit] = value + } else if (paramToEdit === 'access_code_id') { + args.params[paramToEdit] = await interactForAccessCode(args.params as any) return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'boolean') { - const value = await promptConfirm({ - message: `${paramToEdit}:`, - initialValue: true, - active: 'true', - inactive: 'false', - }) - - args.params[paramToEdit] = value - + } else if (paramToEdit === 'connected_account_id') { + const connectedAccountId = await interactForConnectedAccount() + args.params[paramToEdit] = connectedAccountId return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'list' && prop.itemFormat === 'enum') { - const value = await promptAutocompleteMultiselect({ - message: `${paramToEdit}:`, - choices: prop.itemEnumValues.map((v) => ({ - label: v.name, - value: v.name, - })), - }) - args.params[paramToEdit] = value + } else if ( + paramToEdit === 'user_identity_id' || + paramToEdit === 'user_identity_ids' + ) { + const userIdentityId = await interactForUserIdentity() + args.params[paramToEdit] = + paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'list') { - args.params[paramToEdit] = await interactForArray( - args.params[paramToEdit] || [], - `Edit the list for ${paramToEdit}`, - ) + } else if (paramToEdit.endsWith('acs_system_id')) { + args.params[paramToEdit] = await interactForAcsSystem() return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'object') { - args.params[paramToEdit] = await interactForBlueprintObject( - { - command: args.command, - params: {}, - parameters: prop.parameters, - isSubProperty: true, - subPropertyPath: paramToEdit, - }, - ctx, + } else if (paramToEdit.endsWith('acs_user_id')) { + args.params[paramToEdit] = await interactForAcsUser() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit.endsWith('acs_entrance_id')) { + args.params['acs_entrance_id'] = await interactForAcsEntrance() + return interactForBlueprintObject(args, ctx) + } else if ( + paramToEdit.endsWith('_at') || + paramToEdit === 'since' || + paramToEdit.endsWith('_before') || + paramToEdit.endsWith('_after') + ) { + args.params[paramToEdit] = await interactForTimestamp() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit === 'custom_metadata') { + args.params[paramToEdit] = await interactForCustomMetadata( + args.params[paramToEdit] || {}, ) return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'number') { - const value = await promptNumber({ - message: `${paramToEdit}:`, - }) + } - args.params[paramToEdit] = value + if (prop) { + if (['string', 'id', 'datetime'].includes(prop.format)) { + let value + if (prop.format === 'datetime') { + value = await interactForTimestamp() + } else { + value = await promptText({ + message: `${paramToEdit}:`, + allowBack: true, + }) + } + args.params[paramToEdit] = value + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'enum') { + const value = await promptSelect({ + message: `${paramToEdit}:`, + choices: prop.values.map((v) => ({ + label: v.name, + value: v.name, + })), + allowBack: true, + }) + args.params[paramToEdit] = value + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'boolean') { + const value = await promptConfirm({ + message: `${paramToEdit}:`, + initialValue: true, + active: 'true', + inactive: 'false', + }) - return interactForBlueprintObject(args, ctx) + args.params[paramToEdit] = value + + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'list' && prop.itemFormat === 'enum') { + const value = await promptAutocompleteMultiselect({ + message: `${paramToEdit}:`, + choices: prop.itemEnumValues.map((v) => ({ + label: v.name, + value: v.name, + })), + allowBack: true, + }) + args.params[paramToEdit] = value + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'list') { + args.params[paramToEdit] = await interactForArray( + args.params[paramToEdit] || [], + `Edit the list for ${paramToEdit}`, + ) + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'object') { + args.params[paramToEdit] = await interactForBlueprintObject( + { + command: args.command, + params: {}, + parameters: prop.parameters, + isSubProperty: true, + subPropertyPath: paramToEdit, + }, + ctx, + ) + return interactForBlueprintObject(args, ctx) + } else if (prop.format === 'number') { + const value = await promptNumber({ + message: `${paramToEdit}:`, + allowBack: true, + }) + + args.params[paramToEdit] = value + + return interactForBlueprintObject(args, ctx) + } } + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + return interactForBlueprintObject(args, ctx) } throw new Error( diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index c9f9cf0c..b79aa914 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { ContextHelpers } from './types.js' import { NonInteractiveError } from './util/cli-args.js' -import { promptAutocomplete } from './util/prompt.js' +import { promptAutocomplete, PromptBackError } from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -92,16 +92,25 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') - const selectedCommand = await promptAutocomplete({ - message: `Select a command: /${commandPathStr}`, - choices: [ - ...possibleCommands.map((cmd) => ({ - label: - cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, - value: cmd?.[commandPath.length] ?? '', - })), - ].sort((a, b) => ergonomicSort(a.value, b.value)), - }) + let selectedCommand: string + try { + selectedCommand = await promptAutocomplete({ + message: `Select a command: /${commandPathStr}`, + choices: [ + ...possibleCommands.map((cmd) => ({ + label: + cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, + value: cmd?.[commandPath.length] ?? '', + })), + ].sort((a, b) => ergonomicSort(a.value, b.value)), + allowBack: commandPath.length > 0, + }) + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + // The left arrow acts like the [Back] entry, which exists whenever + // allowBack is set above. + selectedCommand = '[Back]' + } if (selectedCommand === '') { return commandPath diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact-for-custom-metadata.ts index 41805293..85ba43fe 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,5 +1,5 @@ import { getOutput } from './output/get-output.js' -import { promptSelect, promptText } from './util/prompt.js' +import { PromptBackError, promptSelect, promptText } from './util/prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. @@ -31,45 +31,62 @@ export const interactForCustomMetadata = async ( do { displayCurrentCustomMetadata() - action = await promptSelect({ - message: 'Choose an action:', - choices: [ - { label: 'Add an item to params', value: 'add' }, - { label: 'Remove an item from params', value: 'remove' }, - { label: 'Finish editing params', value: 'done' }, - ], - }) - - if (action === 'add') { - const newKey = await promptText({ - message: 'Enter a key to add or edit:', + try { + action = await promptSelect({ + message: 'Choose an action:', + choices: [ + { label: 'Add an item to params', value: 'add' }, + { label: 'Remove an item from params', value: 'remove' }, + { label: 'Finish editing params', value: 'done' }, + ], + allowBack: true, }) + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + // Going back at the action menu finishes editing, keeping changes. + break + } - let newValue: string | boolean = await promptText({ - message: 'Enter the new value to add or edit (or null to delete):', - }) - if (newKey) { - if (newValue === 'false' || newValue === 'true') { - newValue = Boolean(newValue) - } - if (newValue === 'null') { - updatedCustomMetadata[newKey] = null - } else { - updatedCustomMetadata[newKey] = newValue - } - } - } else if (action === 'remove') { - const customKeyToRemove = await promptSelect({ - message: 'Choose a key-value pair to remove from params:', - choices: Object.keys(updatedCustomMetadata).map((customMetadataKey) => { - return { - label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, - value: customMetadataKey, + try { + if (action === 'add') { + const newKey = await promptText({ + message: 'Enter a key to add or edit:', + allowBack: true, + }) + + let newValue: string | boolean = await promptText({ + message: 'Enter the new value to add or edit (or null to delete):', + allowBack: true, + }) + if (newKey) { + if (newValue === 'false' || newValue === 'true') { + newValue = Boolean(newValue) } - }), - }) + if (newValue === 'null') { + updatedCustomMetadata[newKey] = null + } else { + updatedCustomMetadata[newKey] = newValue + } + } + } else if (action === 'remove') { + const customKeyToRemove = await promptSelect({ + message: 'Choose a key-value pair to remove from params:', + choices: Object.keys(updatedCustomMetadata).map( + (customMetadataKey) => { + return { + label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, + value: customMetadataKey, + } + }, + ), + allowBack: true, + }) - delete customMetadata[customKeyToRemove] + delete customMetadata[customKeyToRemove] + } + } catch (error) { + if (!(error instanceof PromptBackError)) throw error + // Going back at an inner prompt returns to the action menu. } } while (action !== 'done') diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact-for-resource.ts index 08a2af5b..330574d8 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact-for-resource.ts @@ -28,5 +28,8 @@ export const interactForResource = async ({ const { title, value, description } = toChoice(resource) return { label: title, value, hint: description } }), + // Resource pickers are only reached from the parameter editing flow, + // which catches the back error and returns to its menu. + allowBack: true, }) } diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact-for-timestamp.ts index 55e78f65..d4e5c9b5 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact-for-timestamp.ts @@ -6,6 +6,9 @@ export const interactForTimestamp = async () => { message: 'Enter a timestamp:', placeholder: now, defaultValue: now, + // Timestamps are only prompted for from the parameter editing flow, + // which catches the back error and returns to its menu. + allowBack: true, validate: (value) => { if (value == null || value === '') return undefined if (Number.isNaN(new Date(value).getTime())) { diff --git a/src/lib/util/prompt-input.test.ts b/src/lib/util/prompt-input.test.ts new file mode 100644 index 00000000..5cb4d2ab --- /dev/null +++ b/src/lib/util/prompt-input.test.ts @@ -0,0 +1,218 @@ +import { PassThrough } from 'node:stream' + +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { + attachPromptInput, + type PromptInputHandle, + type PromptInputKind, + type PromptInputSource, +} from './prompt-input.js' + +const attached: PromptInputHandle[] = [] + +const attach = ( + options: { kind?: PromptInputKind; allowBack?: boolean } = {}, +): { + source: PassThrough & { isTTY: boolean; setRawMode: (mode: boolean) => void } + handle: PromptInputHandle + received: () => string +} => { + const source = Object.assign(new PassThrough(), { + isTTY: true, + setRawMode: vi.fn(), + }) + const handle = attachPromptInput( + { kind: options.kind ?? 'choice', allowBack: options.allowBack ?? false }, + source as PromptInputSource, + ) + attached.push(handle) + let received = '' + handle.stream.on('data', (chunk: Buffer) => { + received += chunk.toString() + }) + return { source, handle, received: () => received } +} + +const settle = async (): Promise => { + await new Promise((resolve) => setImmediate(resolve)) +} + +beforeEach(() => { + // Only fake setTimeout: the escape hold timer. settle() relies on a real + // setImmediate to let stream data events fire. + vi.useFakeTimers({ toFake: ['setTimeout'] }) +}) + +afterEach(() => { + while (attached.length > 0) attached.pop()?.detach() + vi.useRealTimers() +}) + +test('attachPromptInput: rewrites control keys to arrow keys', async () => { + const { source, received } = attach() + source.write('\x10') // ctrl-p + source.write('\x0B') // ctrl-k + source.write('\x0E') // ctrl-n + source.write('\x0A') // ctrl-j + await settle() + expect(received()).toBe('\x1B[A\x1B[A\x1B[B\x1B[B') +}) + +test('attachPromptInput: leaves pasted text untouched', async () => { + const { source, received } = attach() + source.write('one\ntwo\x10three') + await settle() + expect(received()).toBe('one\ntwo\x10three') +}) + +test('attachPromptInput: right submits while nothing is typed', async () => { + const { source, received } = attach() + source.write('\x1B[C') + source.write('\x1BOC') + await settle() + expect(received()).toBe('\r\r') +}) + +test('attachPromptInput: left goes back when the prompt allows it', async () => { + const { source, handle, received } = attach({ allowBack: true }) + source.write('\x1B[D') + await settle() + expect(received()).toBe('\x03') + expect(handle.wentBack()).toBe(true) +}) + +test('attachPromptInput: left does nothing without back support', async () => { + const { source, handle, received } = attach() + source.write('\x1B[D') + source.write('\x1BOD') + await settle() + expect(received()).toBe('') + expect(handle.wentBack()).toBe(false) +}) + +test('attachPromptInput: typed input restores left and right to the caret', async () => { + const { source, handle, received } = attach({ allowBack: true }) + source.write('a') + source.write('\x1B[D') + source.write('\x1B[C') + await settle() + expect(received()).toBe('a\x1B[D\x1B[C') + expect(handle.wentBack()).toBe(false) +}) + +test('attachPromptInput: erasing typed input restores back and submit', async () => { + const { source, handle, received } = attach({ allowBack: true }) + source.write('ab') + source.write('\x7F') + source.write('\x7F') + source.write('\x1B[D') + await settle() + expect(received()).toBe('ab\x7F\x7F\x03') + expect(handle.wentBack()).toBe(true) +}) + +test('attachPromptInput: ctrl-u clears the typed input count', async () => { + const { source, received } = attach() + source.write('several words') + source.write('\x15') + source.write('\x1B[C') + await settle() + expect(received()).toBe('several words\x15\r') +}) + +test('attachPromptInput: a multi-byte character counts as one erasable character', async () => { + const { source, received } = attach() + source.write('é') + source.write('\x7F') + source.write('\x1B[C') + await settle() + expect(received()).toBe('é\x7F\r') +}) + +test('attachPromptInput: confirm prompts keep their left and right toggle', async () => { + const { source, handle, received } = attach({ + kind: 'confirm', + allowBack: true, + }) + source.write('\x1B[D') + source.write('\x1B[C') + source.write('\x10') + await settle() + expect(received()).toBe('\x1B[D\x1B[C\x1B[A') + expect(handle.wentBack()).toBe(false) +}) + +test('attachPromptInput: reassembles an arrow key split across chunks', async () => { + const { source, handle, received } = attach({ allowBack: true }) + source.write('\x1B') + source.write('[D') + await settle() + expect(received()).toBe('\x03') + expect(handle.wentBack()).toBe(true) +}) + +test('attachPromptInput: a lone escape still reaches the prompt', async () => { + const { source, received } = attach() + source.write('\x1B') + await settle() + expect(received()).toBe('') + vi.advanceTimersByTime(100) + await settle() + expect(received()).toBe('\x1B') +}) + +test('attachPromptInput: forwards raw mode to a terminal source', () => { + const { source, handle } = attach() + handle.stream.setRawMode(true) + expect(source.setRawMode).toHaveBeenCalledWith(true) + + source.isTTY = false + handle.stream.setRawMode(false) + expect(source.setRawMode).not.toHaveBeenCalledWith(false) +}) + +test('attachPromptInput: detach stops reading and allows the next prompt', async () => { + const first = attach() + first.handle.detach() + first.source.write('\x10') + await settle() + expect(first.received()).toBe('') + + const second = attach() + second.source.write('\x10') + await settle() + expect(second.received()).toBe('\x1B[A') +}) + +test('attachPromptInput: keeps reading a source an earlier prompt paused', async () => { + const first = attach() + const source = first.source + first.handle.detach() + + const handle = attachPromptInput( + { kind: 'choice', allowBack: false }, + source as PromptInputSource, + ) + attached.push(handle) + let received = '' + handle.stream.on('data', (chunk: Buffer) => { + received += chunk.toString() + }) + + source.write('\x0E') + await settle() + expect(received).toBe('\x1B[B') +}) + +test('attachPromptInput: ends the prompt when the terminal goes away', async () => { + const { source, handle } = attach() + const ended = new Promise((resolve) => handle.stream.on('end', resolve)) + source.end() + await expect(ended).resolves.toBeUndefined() +}) + +test('attachPromptInput: refuses to attach twice', () => { + attach() + expect(() => attach()).toThrow('already reading') +}) diff --git a/src/lib/util/prompt-input.ts b/src/lib/util/prompt-input.ts new file mode 100644 index 00000000..49e9d395 --- /dev/null +++ b/src/lib/util/prompt-input.ts @@ -0,0 +1,180 @@ +import { Readable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' + +/** + * What the prompt does with typed characters, which decides how much of the + * keymap applies: choice prompts get the full keymap, text prompts keep the + * caret usable, and confirm prompts keep clack's left/right toggle. + */ +export type PromptInputKind = 'choice' | 'text' | 'confirm' + +export interface PromptInputStream extends Readable { + isTTY: true + setRawMode: (mode: boolean) => PromptInputStream +} + +export interface PromptInputHandle { + stream: PromptInputStream + /** Whether the prompt was cancelled by the left arrow to go back. */ + wentBack: () => boolean + detach: () => void +} + +export interface PromptInputSource { + isTTY?: boolean | undefined + setRawMode?: ((mode: boolean) => unknown) | undefined + on: ((event: 'data', listener: (chunk: Buffer) => void) => unknown) & + ((event: 'end', listener: () => void) => unknown) + off: ((event: 'data', listener: (chunk: Buffer) => void) => unknown) & + ((event: 'end', listener: () => void) => unknown) + pause: () => unknown + resume: () => unknown +} + +// Emacs and vim style control keys, rewritten to the arrow keys they stand +// for before readline can decode them: readline reads ctrl-j as a line feed +// and submits its line, which wipes the typed autocomplete filter. +const navigationKeys: Record = { + '\x10': '\x1B[A', // ctrl-p -> up + '\x0B': '\x1B[A', // ctrl-k -> up + '\x0E': '\x1B[B', // ctrl-n -> down + '\x0A': '\x1B[B', // ctrl-j -> down +} + +// Arrow keys arrive as either CSI or SS3 sequences depending on the +// terminal's cursor key mode. +const rightKeys = new Set(['\x1B[C', '\x1BOC']) +const leftKeys = new Set(['\x1B[D', '\x1BOD']) + +// Prefixes of a possibly split arrow key sequence, held briefly before +// flushing so a lone escape keypress still cancels the prompt. +const escapePrefixes = new Set(['\x1B', '\x1B[', '\x1BO']) +const escapeHoldMs = 60 + +// eslint-disable-next-line no-control-regex +const escapeSequences = /\x1B(\[[0-9;]*[A-Za-z~]|O[A-Z])/g + +let active = false + +/** + * Read keys from the source terminal for one prompt, applying the keymap. + * + * The returned stream is handed to clack as its input: ctrl-p/n/k/j become + * arrow keys, and while nothing is typed, right submits and left goes back + * (when the caller supports it) by cancelling the prompt with the back flag + * set. Left is dropped when back is unsupported, because clack would treat + * it as up in select prompts. Once something is typed, left and right pass + * through and move the caret again. + * + * Keys are only rewritten when they arrive alone, as raw mode delivers each + * keypress in its own chunk; pasted text passes through untouched. + */ +export const attachPromptInput = ( + options: { kind: PromptInputKind; allowBack: boolean }, + source: PromptInputSource, +): PromptInputHandle => { + if (active) throw new Error('A prompt is already reading terminal input') + active = true + + const stream = Object.assign(new Readable({ read() {} }), { + isTTY: true as const, + setRawMode(mode: boolean) { + if (source.isTTY === true) source.setRawMode?.(mode) + return stream + }, + }) as PromptInputStream + + const decoder = new StringDecoder('utf8') + const translateArrows = options.kind !== 'confirm' + let typed = 0 + let wentBack = false + let held = '' + let holdTimer: NodeJS.Timeout | undefined + let detached = false + let ended = false + + const end = (): void => { + if (ended) return + ended = true + stream.push(null) + } + + const trackTyped = (data: string): void => { + for (const char of data.replace(escapeSequences, '')) { + if (char === '\x15') { + typed = 0 // ctrl-u clears the line + } else if (char === '\x7F' || char === '\x08') { + typed = Math.max(0, typed - 1) + } else if (char >= ' ') { + typed += 1 + } + } + } + + const emit = (data: string): void => { + if (translateArrows && typed === 0) { + if (rightKeys.has(data)) { + stream.push('\r') + return + } + if (leftKeys.has(data)) { + if (options.allowBack) { + wentBack = true + stream.push('\x03') + } + return + } + } + trackTyped(data) + stream.push(data) + } + + const onData = (chunk: Buffer): void => { + clearTimeout(holdTimer) + const data = held + decoder.write(chunk) + held = '' + const arrowKey = navigationKeys[data] + if (arrowKey !== undefined) { + stream.push(arrowKey) + return + } + if (escapePrefixes.has(data)) { + held = data + holdTimer = setTimeout(() => { + held = '' + emit(data) + }, escapeHoldMs) + return + } + emit(data) + } + + // A terminal that goes away mid-prompt must end the prompt rather than + // leave it waiting on input that can never arrive. + const onEnd = (): void => { + end() + } + + source.on('data', onData) + source.on('end', onEnd) + // Attaching a listener does not resume a source an earlier prompt paused, + // so every prompt after the first would read nothing without this. + source.resume() + + return { + stream, + wentBack: () => wentBack, + detach: () => { + if (detached) return + detached = true + clearTimeout(holdTimer) + source.off('data', onData) + source.off('end', onEnd) + // The CLI exits by emptying the event loop, so the source must not be + // left flowing once the prompt is done with it. + source.pause() + end() + active = false + }, + } +} diff --git a/src/lib/util/prompt.test.ts b/src/lib/util/prompt.test.ts index 2a73dbcb..4c177e0d 100644 --- a/src/lib/util/prompt.test.ts +++ b/src/lib/util/prompt.test.ts @@ -1,13 +1,16 @@ -import { EventEmitter } from 'node:events' -import type { Key } from 'node:readline' +import { PassThrough } from 'node:stream' -import { expect, test } from 'vitest' +import { afterEach, expect, test } from 'vitest' import { - arrowKeyFor, - emitArrowKeyAliases, + promptAutocomplete, + PromptBackError, + PromptCancelledError, + promptSelect, + promptText, type SearchableChoice, searchChoices, + setPromptIoForTesting, } from './prompt.js' const workspaces = [ @@ -42,53 +45,126 @@ test('searchChoices: offers every choice until something is typed', () => { expect(search(' ', workspaces)).toEqual(workspaces) }) -const ctrl = (name: string): Key => ({ - name, - ctrl: true, - meta: false, - shift: false, - sequence: String.fromCharCode(name.charCodeAt(0) - 96), +// The tests below drive real clack prompts over fake terminal streams, +// writing the bytes a terminal in raw mode would send. + +const fakeTerminal = (): PassThrough & { + isTTY: boolean + setRawMode: () => void +} => { + const stdin = Object.assign(new PassThrough(), { + isTTY: true, + setRawMode: () => {}, + }) + const output = Object.assign(new PassThrough(), { isTTY: true }) + output.on('data', () => {}) + setPromptIoForTesting({ stdin, output }) + return stdin +} + +afterEach(() => { + setPromptIoForTesting({ stdin: process.stdin, output: process.stderr }) +}) + +const choices = [ + { label: 'alpha', value: 'alpha' }, + { label: 'beta', value: 'beta' }, + { label: 'gamma', value: 'gamma' }, + { label: 'delta', value: 'delta' }, +] + +test('promptSelect: ctrl-n, ctrl-j, ctrl-p, and ctrl-k move the cursor', async () => { + const stdin = fakeTerminal() + const answer = promptSelect({ message: 'pick', choices }) + stdin.write('\x0E') // ctrl-n: beta + stdin.write('\x0A') // ctrl-j: gamma + stdin.write('\x0E') // ctrl-n: delta + stdin.write('\x10') // ctrl-p: gamma + stdin.write('\x0B') // ctrl-k: beta + stdin.write('\r') + expect(await answer).toBe('beta') +}) + +test('promptSelect: the right arrow submits the focused choice', async () => { + const stdin = fakeTerminal() + const answer = promptSelect({ message: 'pick', choices }) + stdin.write('\x0E') + stdin.write('\x1B[C') + expect(await answer).toBe('beta') +}) + +test('promptSelect: the left arrow goes back when allowed', async () => { + const stdin = fakeTerminal() + const answer = promptSelect({ message: 'pick', choices, allowBack: true }) + stdin.write('\x1B[D') + await expect(answer).rejects.toBeInstanceOf(PromptBackError) }) -test('arrowKeyFor: maps ctrl-p and ctrl-n to the arrow keys', () => { - expect(arrowKeyFor(ctrl('p'))?.name).toBe('up') - expect(arrowKeyFor(ctrl('n'))?.name).toBe('down') +test('promptSelect: the left arrow does nothing without back support', async () => { + const stdin = fakeTerminal() + const answer = promptSelect({ message: 'pick', choices }) + stdin.write('\x1B[D') + stdin.write('\r') + expect(await answer).toBe('alpha') }) -test('arrowKeyFor: leaves every other key alone', () => { - expect(arrowKeyFor(undefined)).toBeUndefined() - expect(arrowKeyFor(ctrl('c'))).toBeUndefined() - expect(arrowKeyFor({ name: 'p', sequence: 'p' })).toBeUndefined() - expect(arrowKeyFor({ name: 'n', sequence: 'n' })).toBeUndefined() - expect(arrowKeyFor({ ...ctrl('p'), meta: true })).toBeUndefined() - expect(arrowKeyFor({ ...ctrl('n'), shift: true })).toBeUndefined() - expect(arrowKeyFor({ name: 'up', sequence: '\x1B[A' })).toBeUndefined() +test('promptSelect: escape still cancels', async () => { + const stdin = fakeTerminal() + const answer = promptSelect({ message: 'pick', choices }) + stdin.write('\x1B') + await expect(answer).rejects.toBeInstanceOf(PromptCancelledError) }) -test('emitArrowKeyAliases: re-emits control keypresses as arrow keys', () => { - const input = new EventEmitter() - emitArrowKeyAliases(input) - - const keypresses: Array<[string | undefined, Key | undefined]> = [] - input.on('keypress', (char, key) => keypresses.push([char, key])) - - input.emit('keypress', '\x10', ctrl('p')) - input.emit('keypress', 'a', { name: 'a', sequence: 'a' }) - - // The synthetic arrow key arrives first: the re-emit is synchronous, - // and the alias listener runs before any listener attached after it. - expect(keypresses).toEqual([ - [ - undefined, - { - name: 'up', - ctrl: false, - meta: false, - shift: false, - sequence: '\x1B[A', - }, - ], - ['\x10', ctrl('p')], - ['a', { name: 'a', sequence: 'a' }], - ]) +test('promptAutocomplete: ctrl-j navigates the matches without clearing the filter', async () => { + const stdin = fakeTerminal() + const answer = promptAutocomplete({ message: 'pick', choices }) + stdin.write('e') // filters to beta, delta + stdin.write('\x0A') // ctrl-j: delta (gamma when the filter is lost) + stdin.write('\r') + expect(await answer).toBe('delta') +}) + +test('promptAutocomplete: the left arrow only goes back until a filter is typed', async () => { + const stdin = fakeTerminal() + const answer = promptAutocomplete({ + message: 'pick', + choices, + allowBack: true, + }) + stdin.write('e') + stdin.write('\x1B[D') // moves the caret instead of going back + stdin.write('\x7F') // erase the filter + stdin.write('\x1B[D') + await expect(answer).rejects.toBeInstanceOf(PromptBackError) +}) + +test('promptText: left and right move the caret in typed text', async () => { + const stdin = fakeTerminal() + const answer = promptText({ message: 'value', allowBack: true }) + stdin.write('ac') + stdin.write('\x1B[D') + stdin.write('b') + stdin.write('\r') + expect(await answer).toBe('abc') +}) + +test('promptText: the left arrow goes back while nothing is typed', async () => { + const stdin = fakeTerminal() + const answer = promptText({ message: 'value', allowBack: true }) + stdin.write('\x1B[D') + await expect(answer).rejects.toBeInstanceOf(PromptBackError) +}) + +test('prompts: sequential prompts each read the terminal in turn', async () => { + const stdin = fakeTerminal() + + const first = promptSelect({ message: 'pick', choices }) + stdin.write('\x0E') + stdin.write('\r') + expect(await first).toBe('beta') + + const second = promptText({ message: 'value' }) + stdin.write('hello') + stdin.write('\r') + expect(await second).toBe('hello') }) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 5eae9953..54ee239d 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -1,5 +1,4 @@ -import type { EventEmitter } from 'node:events' -import type { Key } from 'node:readline' +import type { Writable } from 'node:stream' import { autocomplete, @@ -12,6 +11,26 @@ import { } from '@clack/prompts' import { NonInteractiveError } from './cli-args.js' +import { + attachPromptInput, + type PromptInputKind, + type PromptInputSource, + type PromptInputStream, +} from './prompt-input.js' + +interface PromptIo { + stdin: PromptInputSource + output: Writable & { isTTY?: boolean | undefined } +} + +// Prompts are rendered to stderr: a selection is not a command result, +// so it must not end up in stdout when the CLI is piped. +let io: PromptIo = { stdin: process.stdin, output: process.stderr } + +/** Redirect prompt IO to fake streams so tests can drive real prompts. */ +export const setPromptIoForTesting = (overrides: PromptIo): void => { + io = overrides +} /** * Whether the CLI can ask the user a question. @@ -22,7 +41,7 @@ import { NonInteractiveError } from './cli-args.js' * question. */ export const canPrompt = (): boolean => - process.stdin.isTTY === true && process.stderr.isTTY === true + io.stdin.isTTY === true && io.output.isTTY === true /** The user dismissed a prompt with ctrl-c or escape instead of answering. */ export class PromptCancelledError extends Error { @@ -31,6 +50,17 @@ export class PromptCancelledError extends Error { } } +/** + * The user pressed the left arrow to return to the previous prompt without + * answering. Only prompts called with allowBack throw this, and their + * callers are expected to catch it: one that escapes is a bug. + */ +export class PromptBackError extends Error { + constructor() { + super('Back') + } +} + export interface PromptChoice { label: string value: Value @@ -43,57 +73,30 @@ const ensureInteractive = (): void => { 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', ) } - installArrowKeyAliases() } -/** - * The arrow keypress an Emacs-style control keypress stands for, or - * undefined for any other key: ctrl-p is up and ctrl-n is down. - */ -export const arrowKeyFor = (key: Key | undefined): Key | undefined => { - if (key?.ctrl !== true || key.meta === true || key.shift === true) { - return undefined +const runPrompt = async ( + options: { kind: PromptInputKind; allowBack?: boolean | undefined }, + prompt: (input: PromptInputStream) => Promise, +): Promise => { + ensureInteractive() + const handle = attachPromptInput( + { kind: options.kind, allowBack: options.allowBack ?? false }, + io.stdin, + ) + try { + const value = await prompt(handle.stream) + if (isCancel(value)) { + throw handle.wentBack() + ? new PromptBackError() + : new PromptCancelledError() + } + return value as Value + } finally { + handle.detach() } - const base = { ctrl: false, meta: false, shift: false } - if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' } - if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' } - return undefined -} - -/** - * Re-emit Emacs-style control keypresses as the arrow keys they stand for. - * - * Clack navigates on the readline key name, so a synthetic arrow keypress - * moves the cursor in every prompt kind. Its own alias table cannot express - * this: aliases match bare key names, unaware of ctrl, and are ignored by - * prompts that track typed input, such as autocomplete. - */ -export const emitArrowKeyAliases = (input: EventEmitter): void => { - input.on('keypress', (_char, key: Key | undefined) => { - const arrowKey = arrowKeyFor(key) - if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey) - }) -} - -let arrowKeyAliasesInstalled = false - -// Keypress events only flow while a prompt has stdin in raw mode, so the -// listener is inert the rest of the time and never holds the process open. -const installArrowKeyAliases = (): void => { - if (arrowKeyAliasesInstalled) return - arrowKeyAliasesInstalled = true - emitArrowKeyAliases(process.stdin) -} - -const unwrap = (value: Value | symbol): Value => { - if (isCancel(value)) throw new PromptCancelledError() - return value as Value } -// Prompts are rendered to stderr: a selection is not a command result, -// so it must not end up in stdout when the CLI is piped. -const output = process.stderr - const toOptions = ( choices: Array>, ): Array> => @@ -109,27 +112,34 @@ export const promptText = async (options: { placeholder?: string defaultValue?: string validate?: (value: string | undefined) => string | undefined + allowBack?: boolean }): Promise => { - ensureInteractive() - return unwrap(await text({ ...options, output })) + const { allowBack, ...textOptions } = options + return await runPrompt( + { kind: 'text', allowBack }, + async (input) => await text({ ...textOptions, input, output: io.output }), + ) } export const promptNumber = async (options: { message: string validate?: (value: number) => string | undefined + allowBack?: boolean }): Promise => { - ensureInteractive() - const value = unwrap( - await text({ - message: options.message, - validate: (value) => { - if (value == null || value.trim() === '') return 'Enter a number' - const parsed = Number(value) - if (Number.isNaN(parsed)) return 'Enter a number' - return options.validate?.(parsed) - }, - output, - }), + const value = await runPrompt( + { kind: 'text', allowBack: options.allowBack }, + async (input) => + await text({ + message: options.message, + validate: (value) => { + if (value == null || value.trim() === '') return 'Enter a number' + const parsed = Number(value) + if (Number.isNaN(parsed)) return 'Enter a number' + return options.validate?.(parsed) + }, + input, + output: io.output, + }), ) return Number(value) } @@ -139,56 +149,63 @@ export const promptConfirm = async (options: { initialValue?: boolean active?: string inactive?: string -}): Promise => { - ensureInteractive() - return unwrap(await confirm({ ...options, output })) -} +}): Promise => + await runPrompt( + { kind: 'confirm' }, + async (input) => await confirm({ ...options, input, output: io.output }), + ) export const promptSelect = async (options: { message: string choices: Array> -}): Promise => { - ensureInteractive() - return unwrap( - await select({ - message: options.message, - options: toOptions(options.choices), - output, - }), + allowBack?: boolean +}): Promise => + await runPrompt( + { kind: 'choice', allowBack: options.allowBack }, + async (input) => + await select({ + message: options.message, + options: toOptions(options.choices), + input, + output: io.output, + }), ) -} export const promptAutocomplete = async (options: { message: string choices: Array> -}): Promise => { - ensureInteractive() - return unwrap( - await autocomplete({ - message: options.message, - options: toOptions(options.choices), - // Search a list by any part of a name or hint, rather than only by - // the label, which is all clack matches for itself. - filter: searchChoices, - output, - }), + allowBack?: boolean +}): Promise => + await runPrompt( + { kind: 'choice', allowBack: options.allowBack }, + async (input) => + await autocomplete({ + message: options.message, + options: toOptions(options.choices), + // Search a list by any part of a name or hint, rather than only by + // the label, which is all clack matches for itself. + filter: searchChoices, + input, + output: io.output, + }), ) -} export const promptAutocompleteMultiselect = async (options: { message: string choices: Array> -}): Promise => { - ensureInteractive() - return unwrap( - await autocompleteMultiselect({ - message: options.message, - options: toOptions(options.choices), - filter: searchChoices, - output, - }), + allowBack?: boolean +}): Promise => + await runPrompt( + { kind: 'choice', allowBack: options.allowBack }, + async (input) => + await autocompleteMultiselect({ + message: options.message, + options: toOptions(options.choices), + filter: searchChoices, + input, + output: io.output, + }), ) -} export interface SearchableChoice { label?: string | undefined From 5adf0a441e5aadea9648e327e75947b163ae8ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:27:35 +0000 Subject: [PATCH 3/3] Revert "feat: Add ctrl-j/k prompt navigation and arrow key back and forth" This reverts commit f4e10c3a1f4b4922d8cdebc4bf74b9546706c823. --- src/bin/cli.ts | 5 +- src/lib/interact-for-array.ts | 67 ++-- src/lib/interact-for-blueprint-object.test.ts | 46 +-- src/lib/interact-for-blueprint-object.ts | 317 ++++++++---------- src/lib/interact-for-command-selection.ts | 31 +- src/lib/interact-for-custom-metadata.ts | 89 ++--- src/lib/interact-for-resource.ts | 3 - src/lib/interact-for-timestamp.ts | 3 - src/lib/util/prompt-input.test.ts | 218 ------------ src/lib/util/prompt-input.ts | 180 ---------- src/lib/util/prompt.test.ts | 172 +++------- src/lib/util/prompt.ts | 213 ++++++------ 12 files changed, 369 insertions(+), 975 deletions(-) delete mode 100644 src/lib/util/prompt-input.test.ts delete mode 100644 src/lib/util/prompt-input.ts diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 389cf34e..c687d039 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -190,12 +190,11 @@ async function cli(args: ParsedArgs) { const selectedCommand = await interactForCommandSelection(args._, ctx) - // Hit 'back' in the command menu, so go up one level: drop the '[Back]' - // marker and the last real path word. + // Hit 'back' on a top-level command path, so we start again if (selectedCommand.slice(-1)[0] === '[Back]') { return await cli({ ...args, - _: selectedCommand.slice(0, -2), + _: [], }) } diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 9206f3fe..6ab01f71 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact-for-array.ts @@ -1,10 +1,5 @@ import { getOutput } from './output/get-output.js' -import { - PromptBackError, - promptNumber, - promptSelect, - promptText, -} from './util/prompt.js' +import { promptNumber, promptSelect, promptText } from './util/prompt.js' export const interactForArray = async ( array: string[], @@ -28,45 +23,31 @@ export const interactForArray = async ( do { displayList() - try { - action = await promptSelect({ - message: 'Choose an action:', - choices: [ - { label: 'Add an item', value: 'add' }, - { label: 'Remove an item', value: 'remove' }, - { label: 'Finish editing', value: 'done' }, - ], - allowBack: true, - }) - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - // Going back at the action menu finishes editing, keeping changes. - break - } + action = await promptSelect({ + message: 'Choose an action:', + choices: [ + { label: 'Add an item', value: 'add' }, + { label: 'Remove an item', value: 'remove' }, + { label: 'Finish editing', value: 'done' }, + ], + }) - try { - if (action === 'add') { - const newItem = await promptText({ - message: 'Enter the new item:', - allowBack: true, - }) - if (newItem) { - updatedArray.push(newItem) - } - } else if (action === 'remove') { - const index = await promptNumber({ - message: 'Enter the index of the item to remove:', - validate: (value) => - value > 0 && value <= updatedArray.length - ? undefined - : 'Invalid index', - allowBack: true, - }) - updatedArray.splice(index - 1, 1) + if (action === 'add') { + const newItem = await promptText({ + message: 'Enter the new item:', + }) + if (newItem) { + updatedArray.push(newItem) } - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - // Going back at an inner prompt returns to the action menu. + } else if (action === 'remove') { + const index = await promptNumber({ + message: 'Enter the index of the item to remove:', + validate: (value) => + value > 0 && value <= updatedArray.length + ? undefined + : 'Invalid index', + }) + updatedArray.splice(index - 1, 1) } } while (action !== 'done') diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index 75504eb1..dcb308de 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -5,16 +5,11 @@ import { interactForBlueprintObject } from './interact-for-blueprint-object.js' import { createMemoryOutput } from './output/create-memory-output.js' import { setOutput } from './output/get-output.js' import type { ContextHelpers } from './types.js' -import { - promptAutocomplete, - PromptBackError, - promptText, -} from './util/prompt.js' +import { promptAutocomplete } from './util/prompt.js' vi.mock('./util/prompt.js', () => ({ canPrompt: vi.fn(() => true), PromptCancelledError: class extends Error {}, - PromptBackError: class extends Error {}, promptText: vi.fn(), promptNumber: vi.fn(), promptConfirm: vi.fn(), @@ -25,8 +20,6 @@ vi.mock('./util/prompt.js', () => ({ beforeEach(() => { vi.mocked(promptAutocomplete).mockClear() - vi.mocked(promptAutocomplete).mockImplementation(async () => 'done') - vi.mocked(promptText).mockReset() // Keep the interactive chrome out of the test output. setOutput(createMemoryOutput().output) }) @@ -86,43 +79,6 @@ test('interactForBlueprintObject: submits without prompting when non-interactive expect(promptAutocomplete).not.toHaveBeenCalled() }) -test('interactForBlueprintObject: offers the parameter menu with a way back', async () => { - await interactForBlueprintObject( - args({ device_id: 'device1' }), - ctx('interactive'), - ) - - expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ - allowBack: true, - }) -}) - -test('interactForBlueprintObject: going back at the menu leaves the command', async () => { - vi.mocked(promptAutocomplete).mockRejectedValueOnce(new PromptBackError()) - - await expect( - interactForBlueprintObject( - args({ device_id: 'device1' }), - ctx('interactive'), - ), - ).resolves.toBe('[Back]') -}) - -test('interactForBlueprintObject: going back at a value prompt returns to the menu unset', async () => { - vi.mocked(promptAutocomplete) - .mockImplementationOnce(async () => 'name') - .mockImplementationOnce(async () => 'done') - vi.mocked(promptText).mockRejectedValueOnce(new PromptBackError()) - - await expect( - interactForBlueprintObject( - args({ device_id: 'device1' }), - ctx('interactive'), - ), - ).resolves.toEqual({ device_id: 'device1' }) - expect(promptAutocomplete).toHaveBeenCalledTimes(2) -}) - test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { await expect( interactForBlueprintObject( diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index e2ce180e..01af4d3d 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -17,7 +17,6 @@ import { ellipsis } from './util/ellipsis.js' import { promptAutocomplete, promptAutocompleteMultiselect, - PromptBackError, promptConfirm, promptNumber, promptSelect, @@ -92,60 +91,53 @@ export const interactForBlueprintObject = async ( : `[${cmdPath}] Parameters` getOutput().info() - let paramToEdit: string - try { - paramToEdit = await promptAutocomplete({ - message: parameterSelectionMessage, - allowBack: true, - choices: [ - ...(haveAllRequiredParams && !args.isSubProperty - ? [ - { - value: 'done', - label: `[Make API Call] ${cmdPath}`, - }, - ] - : []), - ...(haveAllRequiredParams && args.isSubProperty - ? [ - { - label: `[Save]`, - value: 'done', - }, - ] - : []), - ...Object.keys(properties) - .map((k) => { - return { - label: k + (required.includes(k) ? '*' : ''), - value: k, - hint: - args.params[k] !== undefined - ? typeof args.params[k] === 'object' - ? ellipsis(JSON.stringify(args.params[k]), 60) - : `[${args.params[k]}]` - : undefined, - } - }) - .sort((a, b) => propSortScore(b.value) - propSortScore(a.value)), - ...(args.isSubProperty - ? [ - { - label: `[Leave Empty]`, - value: 'empty', - }, - ] - : []), - { - label: `[Back]`, - value: 'back', - }, - ], - }) - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - paramToEdit = 'back' - } + const paramToEdit = await promptAutocomplete({ + message: parameterSelectionMessage, + choices: [ + ...(haveAllRequiredParams && !args.isSubProperty + ? [ + { + value: 'done', + label: `[Make API Call] ${cmdPath}`, + }, + ] + : []), + ...(haveAllRequiredParams && args.isSubProperty + ? [ + { + label: `[Save]`, + value: 'done', + }, + ] + : []), + ...Object.keys(properties) + .map((k) => { + return { + label: k + (required.includes(k) ? '*' : ''), + value: k, + hint: + args.params[k] !== undefined + ? typeof args.params[k] === 'object' + ? ellipsis(JSON.stringify(args.params[k]), 60) + : `[${args.params[k]}]` + : undefined, + } + }) + .sort((a, b) => propSortScore(b.value) - propSortScore(a.value)), + ...(args.isSubProperty + ? [ + { + label: `[Leave Empty]`, + value: 'empty', + }, + ] + : []), + { + label: `[Back]`, + value: 'back', + }, + ], + }) if (paramToEdit === 'empty') { return undefined @@ -166,129 +158,118 @@ export const interactForBlueprintObject = async ( const prop = properties[paramToEdit] - // Going back at any value prompt below returns to the parameter menu - // with the parameter unset. - try { - if (paramToEdit === 'device_id') { - args.params[paramToEdit] = await interactForDevice() - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'access_code_id') { - args.params[paramToEdit] = await interactForAccessCode(args.params as any) - return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'connected_account_id') { - const connectedAccountId = await interactForConnectedAccount() - args.params[paramToEdit] = connectedAccountId - return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit === 'user_identity_id' || - paramToEdit === 'user_identity_ids' - ) { - const userIdentityId = await interactForUserIdentity() - args.params[paramToEdit] = - paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId + if (paramToEdit === 'device_id') { + args.params[paramToEdit] = await interactForDevice() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit === 'access_code_id') { + args.params[paramToEdit] = await interactForAccessCode(args.params as any) + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit === 'connected_account_id') { + const connectedAccountId = await interactForConnectedAccount() + args.params[paramToEdit] = connectedAccountId + return interactForBlueprintObject(args, ctx) + } else if ( + paramToEdit === 'user_identity_id' || + paramToEdit === 'user_identity_ids' + ) { + const userIdentityId = await interactForUserIdentity() + args.params[paramToEdit] = + paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit.endsWith('acs_system_id')) { + args.params[paramToEdit] = await interactForAcsSystem() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit.endsWith('acs_user_id')) { + args.params[paramToEdit] = await interactForAcsUser() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit.endsWith('acs_entrance_id')) { + args.params['acs_entrance_id'] = await interactForAcsEntrance() + return interactForBlueprintObject(args, ctx) + } else if ( + paramToEdit.endsWith('_at') || + paramToEdit === 'since' || + paramToEdit.endsWith('_before') || + paramToEdit.endsWith('_after') + ) { + args.params[paramToEdit] = await interactForTimestamp() + return interactForBlueprintObject(args, ctx) + } else if (paramToEdit === 'custom_metadata') { + args.params[paramToEdit] = await interactForCustomMetadata( + args.params[paramToEdit] || {}, + ) + return interactForBlueprintObject(args, ctx) + } + + if (prop) { + if (['string', 'id', 'datetime'].includes(prop.format)) { + let value + if (prop.format === 'datetime') { + value = await interactForTimestamp() + } else { + value = await promptText({ + message: `${paramToEdit}:`, + }) + } + args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_system_id')) { - args.params[paramToEdit] = await interactForAcsSystem() + } else if (prop.format === 'enum') { + const value = await promptSelect({ + message: `${paramToEdit}:`, + choices: prop.values.map((v) => ({ + label: v.name, + value: v.name, + })), + }) + args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_user_id')) { - args.params[paramToEdit] = await interactForAcsUser() + } else if (prop.format === 'boolean') { + const value = await promptConfirm({ + message: `${paramToEdit}:`, + initialValue: true, + active: 'true', + inactive: 'false', + }) + + args.params[paramToEdit] = value + return interactForBlueprintObject(args, ctx) - } else if (paramToEdit.endsWith('acs_entrance_id')) { - args.params['acs_entrance_id'] = await interactForAcsEntrance() + } else if (prop.format === 'list' && prop.itemFormat === 'enum') { + const value = await promptAutocompleteMultiselect({ + message: `${paramToEdit}:`, + choices: prop.itemEnumValues.map((v) => ({ + label: v.name, + value: v.name, + })), + }) + args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) - } else if ( - paramToEdit.endsWith('_at') || - paramToEdit === 'since' || - paramToEdit.endsWith('_before') || - paramToEdit.endsWith('_after') - ) { - args.params[paramToEdit] = await interactForTimestamp() + } else if (prop.format === 'list') { + args.params[paramToEdit] = await interactForArray( + args.params[paramToEdit] || [], + `Edit the list for ${paramToEdit}`, + ) return interactForBlueprintObject(args, ctx) - } else if (paramToEdit === 'custom_metadata') { - args.params[paramToEdit] = await interactForCustomMetadata( - args.params[paramToEdit] || {}, + } else if (prop.format === 'object') { + args.params[paramToEdit] = await interactForBlueprintObject( + { + command: args.command, + params: {}, + parameters: prop.parameters, + isSubProperty: true, + subPropertyPath: paramToEdit, + }, + ctx, ) return interactForBlueprintObject(args, ctx) - } + } else if (prop.format === 'number') { + const value = await promptNumber({ + message: `${paramToEdit}:`, + }) - if (prop) { - if (['string', 'id', 'datetime'].includes(prop.format)) { - let value - if (prop.format === 'datetime') { - value = await interactForTimestamp() - } else { - value = await promptText({ - message: `${paramToEdit}:`, - allowBack: true, - }) - } - args.params[paramToEdit] = value - return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'enum') { - const value = await promptSelect({ - message: `${paramToEdit}:`, - choices: prop.values.map((v) => ({ - label: v.name, - value: v.name, - })), - allowBack: true, - }) - args.params[paramToEdit] = value - return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'boolean') { - const value = await promptConfirm({ - message: `${paramToEdit}:`, - initialValue: true, - active: 'true', - inactive: 'false', - }) + args.params[paramToEdit] = value - args.params[paramToEdit] = value - - return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'list' && prop.itemFormat === 'enum') { - const value = await promptAutocompleteMultiselect({ - message: `${paramToEdit}:`, - choices: prop.itemEnumValues.map((v) => ({ - label: v.name, - value: v.name, - })), - allowBack: true, - }) - args.params[paramToEdit] = value - return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'list') { - args.params[paramToEdit] = await interactForArray( - args.params[paramToEdit] || [], - `Edit the list for ${paramToEdit}`, - ) - return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'object') { - args.params[paramToEdit] = await interactForBlueprintObject( - { - command: args.command, - params: {}, - parameters: prop.parameters, - isSubProperty: true, - subPropertyPath: paramToEdit, - }, - ctx, - ) - return interactForBlueprintObject(args, ctx) - } else if (prop.format === 'number') { - const value = await promptNumber({ - message: `${paramToEdit}:`, - allowBack: true, - }) - - args.params[paramToEdit] = value - - return interactForBlueprintObject(args, ctx) - } + return interactForBlueprintObject(args, ctx) } - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - return interactForBlueprintObject(args, ctx) } throw new Error( diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index b79aa914..c9f9cf0c 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { ContextHelpers } from './types.js' import { NonInteractiveError } from './util/cli-args.js' -import { promptAutocomplete, PromptBackError } from './util/prompt.js' +import { promptAutocomplete } from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -92,25 +92,16 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') - let selectedCommand: string - try { - selectedCommand = await promptAutocomplete({ - message: `Select a command: /${commandPathStr}`, - choices: [ - ...possibleCommands.map((cmd) => ({ - label: - cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, - value: cmd?.[commandPath.length] ?? '', - })), - ].sort((a, b) => ergonomicSort(a.value, b.value)), - allowBack: commandPath.length > 0, - }) - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - // The left arrow acts like the [Back] entry, which exists whenever - // allowBack is set above. - selectedCommand = '[Back]' - } + const selectedCommand = await promptAutocomplete({ + message: `Select a command: /${commandPathStr}`, + choices: [ + ...possibleCommands.map((cmd) => ({ + label: + cmd?.[commandPath.length] ?? `[Call /${commandPathStr} Directly]`, + value: cmd?.[commandPath.length] ?? '', + })), + ].sort((a, b) => ergonomicSort(a.value, b.value)), + }) if (selectedCommand === '') { return commandPath diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact-for-custom-metadata.ts index 85ba43fe..41805293 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,5 +1,5 @@ import { getOutput } from './output/get-output.js' -import { PromptBackError, promptSelect, promptText } from './util/prompt.js' +import { promptSelect, promptText } from './util/prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the // published declarations do not depend on a development-only package. @@ -31,62 +31,45 @@ export const interactForCustomMetadata = async ( do { displayCurrentCustomMetadata() - try { - action = await promptSelect({ - message: 'Choose an action:', - choices: [ - { label: 'Add an item to params', value: 'add' }, - { label: 'Remove an item from params', value: 'remove' }, - { label: 'Finish editing params', value: 'done' }, - ], - allowBack: true, - }) - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - // Going back at the action menu finishes editing, keeping changes. - break - } + action = await promptSelect({ + message: 'Choose an action:', + choices: [ + { label: 'Add an item to params', value: 'add' }, + { label: 'Remove an item from params', value: 'remove' }, + { label: 'Finish editing params', value: 'done' }, + ], + }) - try { - if (action === 'add') { - const newKey = await promptText({ - message: 'Enter a key to add or edit:', - allowBack: true, - }) + if (action === 'add') { + const newKey = await promptText({ + message: 'Enter a key to add or edit:', + }) - let newValue: string | boolean = await promptText({ - message: 'Enter the new value to add or edit (or null to delete):', - allowBack: true, - }) - if (newKey) { - if (newValue === 'false' || newValue === 'true') { - newValue = Boolean(newValue) - } - if (newValue === 'null') { - updatedCustomMetadata[newKey] = null - } else { - updatedCustomMetadata[newKey] = newValue - } + let newValue: string | boolean = await promptText({ + message: 'Enter the new value to add or edit (or null to delete):', + }) + if (newKey) { + if (newValue === 'false' || newValue === 'true') { + newValue = Boolean(newValue) + } + if (newValue === 'null') { + updatedCustomMetadata[newKey] = null + } else { + updatedCustomMetadata[newKey] = newValue } - } else if (action === 'remove') { - const customKeyToRemove = await promptSelect({ - message: 'Choose a key-value pair to remove from params:', - choices: Object.keys(updatedCustomMetadata).map( - (customMetadataKey) => { - return { - label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, - value: customMetadataKey, - } - }, - ), - allowBack: true, - }) - - delete customMetadata[customKeyToRemove] } - } catch (error) { - if (!(error instanceof PromptBackError)) throw error - // Going back at an inner prompt returns to the action menu. + } else if (action === 'remove') { + const customKeyToRemove = await promptSelect({ + message: 'Choose a key-value pair to remove from params:', + choices: Object.keys(updatedCustomMetadata).map((customMetadataKey) => { + return { + label: `${customMetadataKey}: ${updatedCustomMetadata[customMetadataKey]}`, + value: customMetadataKey, + } + }), + }) + + delete customMetadata[customKeyToRemove] } } while (action !== 'done') diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact-for-resource.ts index 330574d8..08a2af5b 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact-for-resource.ts @@ -28,8 +28,5 @@ export const interactForResource = async ({ const { title, value, description } = toChoice(resource) return { label: title, value, hint: description } }), - // Resource pickers are only reached from the parameter editing flow, - // which catches the back error and returns to its menu. - allowBack: true, }) } diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact-for-timestamp.ts index d4e5c9b5..55e78f65 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact-for-timestamp.ts @@ -6,9 +6,6 @@ export const interactForTimestamp = async () => { message: 'Enter a timestamp:', placeholder: now, defaultValue: now, - // Timestamps are only prompted for from the parameter editing flow, - // which catches the back error and returns to its menu. - allowBack: true, validate: (value) => { if (value == null || value === '') return undefined if (Number.isNaN(new Date(value).getTime())) { diff --git a/src/lib/util/prompt-input.test.ts b/src/lib/util/prompt-input.test.ts deleted file mode 100644 index 5cb4d2ab..00000000 --- a/src/lib/util/prompt-input.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { PassThrough } from 'node:stream' - -import { afterEach, beforeEach, expect, test, vi } from 'vitest' - -import { - attachPromptInput, - type PromptInputHandle, - type PromptInputKind, - type PromptInputSource, -} from './prompt-input.js' - -const attached: PromptInputHandle[] = [] - -const attach = ( - options: { kind?: PromptInputKind; allowBack?: boolean } = {}, -): { - source: PassThrough & { isTTY: boolean; setRawMode: (mode: boolean) => void } - handle: PromptInputHandle - received: () => string -} => { - const source = Object.assign(new PassThrough(), { - isTTY: true, - setRawMode: vi.fn(), - }) - const handle = attachPromptInput( - { kind: options.kind ?? 'choice', allowBack: options.allowBack ?? false }, - source as PromptInputSource, - ) - attached.push(handle) - let received = '' - handle.stream.on('data', (chunk: Buffer) => { - received += chunk.toString() - }) - return { source, handle, received: () => received } -} - -const settle = async (): Promise => { - await new Promise((resolve) => setImmediate(resolve)) -} - -beforeEach(() => { - // Only fake setTimeout: the escape hold timer. settle() relies on a real - // setImmediate to let stream data events fire. - vi.useFakeTimers({ toFake: ['setTimeout'] }) -}) - -afterEach(() => { - while (attached.length > 0) attached.pop()?.detach() - vi.useRealTimers() -}) - -test('attachPromptInput: rewrites control keys to arrow keys', async () => { - const { source, received } = attach() - source.write('\x10') // ctrl-p - source.write('\x0B') // ctrl-k - source.write('\x0E') // ctrl-n - source.write('\x0A') // ctrl-j - await settle() - expect(received()).toBe('\x1B[A\x1B[A\x1B[B\x1B[B') -}) - -test('attachPromptInput: leaves pasted text untouched', async () => { - const { source, received } = attach() - source.write('one\ntwo\x10three') - await settle() - expect(received()).toBe('one\ntwo\x10three') -}) - -test('attachPromptInput: right submits while nothing is typed', async () => { - const { source, received } = attach() - source.write('\x1B[C') - source.write('\x1BOC') - await settle() - expect(received()).toBe('\r\r') -}) - -test('attachPromptInput: left goes back when the prompt allows it', async () => { - const { source, handle, received } = attach({ allowBack: true }) - source.write('\x1B[D') - await settle() - expect(received()).toBe('\x03') - expect(handle.wentBack()).toBe(true) -}) - -test('attachPromptInput: left does nothing without back support', async () => { - const { source, handle, received } = attach() - source.write('\x1B[D') - source.write('\x1BOD') - await settle() - expect(received()).toBe('') - expect(handle.wentBack()).toBe(false) -}) - -test('attachPromptInput: typed input restores left and right to the caret', async () => { - const { source, handle, received } = attach({ allowBack: true }) - source.write('a') - source.write('\x1B[D') - source.write('\x1B[C') - await settle() - expect(received()).toBe('a\x1B[D\x1B[C') - expect(handle.wentBack()).toBe(false) -}) - -test('attachPromptInput: erasing typed input restores back and submit', async () => { - const { source, handle, received } = attach({ allowBack: true }) - source.write('ab') - source.write('\x7F') - source.write('\x7F') - source.write('\x1B[D') - await settle() - expect(received()).toBe('ab\x7F\x7F\x03') - expect(handle.wentBack()).toBe(true) -}) - -test('attachPromptInput: ctrl-u clears the typed input count', async () => { - const { source, received } = attach() - source.write('several words') - source.write('\x15') - source.write('\x1B[C') - await settle() - expect(received()).toBe('several words\x15\r') -}) - -test('attachPromptInput: a multi-byte character counts as one erasable character', async () => { - const { source, received } = attach() - source.write('é') - source.write('\x7F') - source.write('\x1B[C') - await settle() - expect(received()).toBe('é\x7F\r') -}) - -test('attachPromptInput: confirm prompts keep their left and right toggle', async () => { - const { source, handle, received } = attach({ - kind: 'confirm', - allowBack: true, - }) - source.write('\x1B[D') - source.write('\x1B[C') - source.write('\x10') - await settle() - expect(received()).toBe('\x1B[D\x1B[C\x1B[A') - expect(handle.wentBack()).toBe(false) -}) - -test('attachPromptInput: reassembles an arrow key split across chunks', async () => { - const { source, handle, received } = attach({ allowBack: true }) - source.write('\x1B') - source.write('[D') - await settle() - expect(received()).toBe('\x03') - expect(handle.wentBack()).toBe(true) -}) - -test('attachPromptInput: a lone escape still reaches the prompt', async () => { - const { source, received } = attach() - source.write('\x1B') - await settle() - expect(received()).toBe('') - vi.advanceTimersByTime(100) - await settle() - expect(received()).toBe('\x1B') -}) - -test('attachPromptInput: forwards raw mode to a terminal source', () => { - const { source, handle } = attach() - handle.stream.setRawMode(true) - expect(source.setRawMode).toHaveBeenCalledWith(true) - - source.isTTY = false - handle.stream.setRawMode(false) - expect(source.setRawMode).not.toHaveBeenCalledWith(false) -}) - -test('attachPromptInput: detach stops reading and allows the next prompt', async () => { - const first = attach() - first.handle.detach() - first.source.write('\x10') - await settle() - expect(first.received()).toBe('') - - const second = attach() - second.source.write('\x10') - await settle() - expect(second.received()).toBe('\x1B[A') -}) - -test('attachPromptInput: keeps reading a source an earlier prompt paused', async () => { - const first = attach() - const source = first.source - first.handle.detach() - - const handle = attachPromptInput( - { kind: 'choice', allowBack: false }, - source as PromptInputSource, - ) - attached.push(handle) - let received = '' - handle.stream.on('data', (chunk: Buffer) => { - received += chunk.toString() - }) - - source.write('\x0E') - await settle() - expect(received).toBe('\x1B[B') -}) - -test('attachPromptInput: ends the prompt when the terminal goes away', async () => { - const { source, handle } = attach() - const ended = new Promise((resolve) => handle.stream.on('end', resolve)) - source.end() - await expect(ended).resolves.toBeUndefined() -}) - -test('attachPromptInput: refuses to attach twice', () => { - attach() - expect(() => attach()).toThrow('already reading') -}) diff --git a/src/lib/util/prompt-input.ts b/src/lib/util/prompt-input.ts deleted file mode 100644 index 49e9d395..00000000 --- a/src/lib/util/prompt-input.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Readable } from 'node:stream' -import { StringDecoder } from 'node:string_decoder' - -/** - * What the prompt does with typed characters, which decides how much of the - * keymap applies: choice prompts get the full keymap, text prompts keep the - * caret usable, and confirm prompts keep clack's left/right toggle. - */ -export type PromptInputKind = 'choice' | 'text' | 'confirm' - -export interface PromptInputStream extends Readable { - isTTY: true - setRawMode: (mode: boolean) => PromptInputStream -} - -export interface PromptInputHandle { - stream: PromptInputStream - /** Whether the prompt was cancelled by the left arrow to go back. */ - wentBack: () => boolean - detach: () => void -} - -export interface PromptInputSource { - isTTY?: boolean | undefined - setRawMode?: ((mode: boolean) => unknown) | undefined - on: ((event: 'data', listener: (chunk: Buffer) => void) => unknown) & - ((event: 'end', listener: () => void) => unknown) - off: ((event: 'data', listener: (chunk: Buffer) => void) => unknown) & - ((event: 'end', listener: () => void) => unknown) - pause: () => unknown - resume: () => unknown -} - -// Emacs and vim style control keys, rewritten to the arrow keys they stand -// for before readline can decode them: readline reads ctrl-j as a line feed -// and submits its line, which wipes the typed autocomplete filter. -const navigationKeys: Record = { - '\x10': '\x1B[A', // ctrl-p -> up - '\x0B': '\x1B[A', // ctrl-k -> up - '\x0E': '\x1B[B', // ctrl-n -> down - '\x0A': '\x1B[B', // ctrl-j -> down -} - -// Arrow keys arrive as either CSI or SS3 sequences depending on the -// terminal's cursor key mode. -const rightKeys = new Set(['\x1B[C', '\x1BOC']) -const leftKeys = new Set(['\x1B[D', '\x1BOD']) - -// Prefixes of a possibly split arrow key sequence, held briefly before -// flushing so a lone escape keypress still cancels the prompt. -const escapePrefixes = new Set(['\x1B', '\x1B[', '\x1BO']) -const escapeHoldMs = 60 - -// eslint-disable-next-line no-control-regex -const escapeSequences = /\x1B(\[[0-9;]*[A-Za-z~]|O[A-Z])/g - -let active = false - -/** - * Read keys from the source terminal for one prompt, applying the keymap. - * - * The returned stream is handed to clack as its input: ctrl-p/n/k/j become - * arrow keys, and while nothing is typed, right submits and left goes back - * (when the caller supports it) by cancelling the prompt with the back flag - * set. Left is dropped when back is unsupported, because clack would treat - * it as up in select prompts. Once something is typed, left and right pass - * through and move the caret again. - * - * Keys are only rewritten when they arrive alone, as raw mode delivers each - * keypress in its own chunk; pasted text passes through untouched. - */ -export const attachPromptInput = ( - options: { kind: PromptInputKind; allowBack: boolean }, - source: PromptInputSource, -): PromptInputHandle => { - if (active) throw new Error('A prompt is already reading terminal input') - active = true - - const stream = Object.assign(new Readable({ read() {} }), { - isTTY: true as const, - setRawMode(mode: boolean) { - if (source.isTTY === true) source.setRawMode?.(mode) - return stream - }, - }) as PromptInputStream - - const decoder = new StringDecoder('utf8') - const translateArrows = options.kind !== 'confirm' - let typed = 0 - let wentBack = false - let held = '' - let holdTimer: NodeJS.Timeout | undefined - let detached = false - let ended = false - - const end = (): void => { - if (ended) return - ended = true - stream.push(null) - } - - const trackTyped = (data: string): void => { - for (const char of data.replace(escapeSequences, '')) { - if (char === '\x15') { - typed = 0 // ctrl-u clears the line - } else if (char === '\x7F' || char === '\x08') { - typed = Math.max(0, typed - 1) - } else if (char >= ' ') { - typed += 1 - } - } - } - - const emit = (data: string): void => { - if (translateArrows && typed === 0) { - if (rightKeys.has(data)) { - stream.push('\r') - return - } - if (leftKeys.has(data)) { - if (options.allowBack) { - wentBack = true - stream.push('\x03') - } - return - } - } - trackTyped(data) - stream.push(data) - } - - const onData = (chunk: Buffer): void => { - clearTimeout(holdTimer) - const data = held + decoder.write(chunk) - held = '' - const arrowKey = navigationKeys[data] - if (arrowKey !== undefined) { - stream.push(arrowKey) - return - } - if (escapePrefixes.has(data)) { - held = data - holdTimer = setTimeout(() => { - held = '' - emit(data) - }, escapeHoldMs) - return - } - emit(data) - } - - // A terminal that goes away mid-prompt must end the prompt rather than - // leave it waiting on input that can never arrive. - const onEnd = (): void => { - end() - } - - source.on('data', onData) - source.on('end', onEnd) - // Attaching a listener does not resume a source an earlier prompt paused, - // so every prompt after the first would read nothing without this. - source.resume() - - return { - stream, - wentBack: () => wentBack, - detach: () => { - if (detached) return - detached = true - clearTimeout(holdTimer) - source.off('data', onData) - source.off('end', onEnd) - // The CLI exits by emptying the event loop, so the source must not be - // left flowing once the prompt is done with it. - source.pause() - end() - active = false - }, - } -} diff --git a/src/lib/util/prompt.test.ts b/src/lib/util/prompt.test.ts index 4c177e0d..2a73dbcb 100644 --- a/src/lib/util/prompt.test.ts +++ b/src/lib/util/prompt.test.ts @@ -1,16 +1,13 @@ -import { PassThrough } from 'node:stream' +import { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' -import { afterEach, expect, test } from 'vitest' +import { expect, test } from 'vitest' import { - promptAutocomplete, - PromptBackError, - PromptCancelledError, - promptSelect, - promptText, + arrowKeyFor, + emitArrowKeyAliases, type SearchableChoice, searchChoices, - setPromptIoForTesting, } from './prompt.js' const workspaces = [ @@ -45,126 +42,53 @@ test('searchChoices: offers every choice until something is typed', () => { expect(search(' ', workspaces)).toEqual(workspaces) }) -// The tests below drive real clack prompts over fake terminal streams, -// writing the bytes a terminal in raw mode would send. - -const fakeTerminal = (): PassThrough & { - isTTY: boolean - setRawMode: () => void -} => { - const stdin = Object.assign(new PassThrough(), { - isTTY: true, - setRawMode: () => {}, - }) - const output = Object.assign(new PassThrough(), { isTTY: true }) - output.on('data', () => {}) - setPromptIoForTesting({ stdin, output }) - return stdin -} - -afterEach(() => { - setPromptIoForTesting({ stdin: process.stdin, output: process.stderr }) -}) - -const choices = [ - { label: 'alpha', value: 'alpha' }, - { label: 'beta', value: 'beta' }, - { label: 'gamma', value: 'gamma' }, - { label: 'delta', value: 'delta' }, -] - -test('promptSelect: ctrl-n, ctrl-j, ctrl-p, and ctrl-k move the cursor', async () => { - const stdin = fakeTerminal() - const answer = promptSelect({ message: 'pick', choices }) - stdin.write('\x0E') // ctrl-n: beta - stdin.write('\x0A') // ctrl-j: gamma - stdin.write('\x0E') // ctrl-n: delta - stdin.write('\x10') // ctrl-p: gamma - stdin.write('\x0B') // ctrl-k: beta - stdin.write('\r') - expect(await answer).toBe('beta') -}) - -test('promptSelect: the right arrow submits the focused choice', async () => { - const stdin = fakeTerminal() - const answer = promptSelect({ message: 'pick', choices }) - stdin.write('\x0E') - stdin.write('\x1B[C') - expect(await answer).toBe('beta') -}) - -test('promptSelect: the left arrow goes back when allowed', async () => { - const stdin = fakeTerminal() - const answer = promptSelect({ message: 'pick', choices, allowBack: true }) - stdin.write('\x1B[D') - await expect(answer).rejects.toBeInstanceOf(PromptBackError) +const ctrl = (name: string): Key => ({ + name, + ctrl: true, + meta: false, + shift: false, + sequence: String.fromCharCode(name.charCodeAt(0) - 96), }) -test('promptSelect: the left arrow does nothing without back support', async () => { - const stdin = fakeTerminal() - const answer = promptSelect({ message: 'pick', choices }) - stdin.write('\x1B[D') - stdin.write('\r') - expect(await answer).toBe('alpha') +test('arrowKeyFor: maps ctrl-p and ctrl-n to the arrow keys', () => { + expect(arrowKeyFor(ctrl('p'))?.name).toBe('up') + expect(arrowKeyFor(ctrl('n'))?.name).toBe('down') }) -test('promptSelect: escape still cancels', async () => { - const stdin = fakeTerminal() - const answer = promptSelect({ message: 'pick', choices }) - stdin.write('\x1B') - await expect(answer).rejects.toBeInstanceOf(PromptCancelledError) +test('arrowKeyFor: leaves every other key alone', () => { + expect(arrowKeyFor(undefined)).toBeUndefined() + expect(arrowKeyFor(ctrl('c'))).toBeUndefined() + expect(arrowKeyFor({ name: 'p', sequence: 'p' })).toBeUndefined() + expect(arrowKeyFor({ name: 'n', sequence: 'n' })).toBeUndefined() + expect(arrowKeyFor({ ...ctrl('p'), meta: true })).toBeUndefined() + expect(arrowKeyFor({ ...ctrl('n'), shift: true })).toBeUndefined() + expect(arrowKeyFor({ name: 'up', sequence: '\x1B[A' })).toBeUndefined() }) -test('promptAutocomplete: ctrl-j navigates the matches without clearing the filter', async () => { - const stdin = fakeTerminal() - const answer = promptAutocomplete({ message: 'pick', choices }) - stdin.write('e') // filters to beta, delta - stdin.write('\x0A') // ctrl-j: delta (gamma when the filter is lost) - stdin.write('\r') - expect(await answer).toBe('delta') -}) - -test('promptAutocomplete: the left arrow only goes back until a filter is typed', async () => { - const stdin = fakeTerminal() - const answer = promptAutocomplete({ - message: 'pick', - choices, - allowBack: true, - }) - stdin.write('e') - stdin.write('\x1B[D') // moves the caret instead of going back - stdin.write('\x7F') // erase the filter - stdin.write('\x1B[D') - await expect(answer).rejects.toBeInstanceOf(PromptBackError) -}) - -test('promptText: left and right move the caret in typed text', async () => { - const stdin = fakeTerminal() - const answer = promptText({ message: 'value', allowBack: true }) - stdin.write('ac') - stdin.write('\x1B[D') - stdin.write('b') - stdin.write('\r') - expect(await answer).toBe('abc') -}) - -test('promptText: the left arrow goes back while nothing is typed', async () => { - const stdin = fakeTerminal() - const answer = promptText({ message: 'value', allowBack: true }) - stdin.write('\x1B[D') - await expect(answer).rejects.toBeInstanceOf(PromptBackError) -}) - -test('prompts: sequential prompts each read the terminal in turn', async () => { - const stdin = fakeTerminal() - - const first = promptSelect({ message: 'pick', choices }) - stdin.write('\x0E') - stdin.write('\r') - expect(await first).toBe('beta') - - const second = promptText({ message: 'value' }) - stdin.write('hello') - stdin.write('\r') - expect(await second).toBe('hello') +test('emitArrowKeyAliases: re-emits control keypresses as arrow keys', () => { + const input = new EventEmitter() + emitArrowKeyAliases(input) + + const keypresses: Array<[string | undefined, Key | undefined]> = [] + input.on('keypress', (char, key) => keypresses.push([char, key])) + + input.emit('keypress', '\x10', ctrl('p')) + input.emit('keypress', 'a', { name: 'a', sequence: 'a' }) + + // The synthetic arrow key arrives first: the re-emit is synchronous, + // and the alias listener runs before any listener attached after it. + expect(keypresses).toEqual([ + [ + undefined, + { + name: 'up', + ctrl: false, + meta: false, + shift: false, + sequence: '\x1B[A', + }, + ], + ['\x10', ctrl('p')], + ['a', { name: 'a', sequence: 'a' }], + ]) }) diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 54ee239d..5eae9953 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -1,4 +1,5 @@ -import type { Writable } from 'node:stream' +import type { EventEmitter } from 'node:events' +import type { Key } from 'node:readline' import { autocomplete, @@ -11,26 +12,6 @@ import { } from '@clack/prompts' import { NonInteractiveError } from './cli-args.js' -import { - attachPromptInput, - type PromptInputKind, - type PromptInputSource, - type PromptInputStream, -} from './prompt-input.js' - -interface PromptIo { - stdin: PromptInputSource - output: Writable & { isTTY?: boolean | undefined } -} - -// Prompts are rendered to stderr: a selection is not a command result, -// so it must not end up in stdout when the CLI is piped. -let io: PromptIo = { stdin: process.stdin, output: process.stderr } - -/** Redirect prompt IO to fake streams so tests can drive real prompts. */ -export const setPromptIoForTesting = (overrides: PromptIo): void => { - io = overrides -} /** * Whether the CLI can ask the user a question. @@ -41,7 +22,7 @@ export const setPromptIoForTesting = (overrides: PromptIo): void => { * question. */ export const canPrompt = (): boolean => - io.stdin.isTTY === true && io.output.isTTY === true + process.stdin.isTTY === true && process.stderr.isTTY === true /** The user dismissed a prompt with ctrl-c or escape instead of answering. */ export class PromptCancelledError extends Error { @@ -50,17 +31,6 @@ export class PromptCancelledError extends Error { } } -/** - * The user pressed the left arrow to return to the previous prompt without - * answering. Only prompts called with allowBack throw this, and their - * callers are expected to catch it: one that escapes is a bug. - */ -export class PromptBackError extends Error { - constructor() { - super('Back') - } -} - export interface PromptChoice { label: string value: Value @@ -73,30 +43,57 @@ const ensureInteractive = (): void => { 'Cannot prompt without a terminal: pass the missing arguments, or pipe them in as JSON', ) } + installArrowKeyAliases() } -const runPrompt = async ( - options: { kind: PromptInputKind; allowBack?: boolean | undefined }, - prompt: (input: PromptInputStream) => Promise, -): Promise => { - ensureInteractive() - const handle = attachPromptInput( - { kind: options.kind, allowBack: options.allowBack ?? false }, - io.stdin, - ) - try { - const value = await prompt(handle.stream) - if (isCancel(value)) { - throw handle.wentBack() - ? new PromptBackError() - : new PromptCancelledError() - } - return value as Value - } finally { - handle.detach() +/** + * The arrow keypress an Emacs-style control keypress stands for, or + * undefined for any other key: ctrl-p is up and ctrl-n is down. + */ +export const arrowKeyFor = (key: Key | undefined): Key | undefined => { + if (key?.ctrl !== true || key.meta === true || key.shift === true) { + return undefined } + const base = { ctrl: false, meta: false, shift: false } + if (key.name === 'p') return { ...base, name: 'up', sequence: '\x1B[A' } + if (key.name === 'n') return { ...base, name: 'down', sequence: '\x1B[B' } + return undefined +} + +/** + * Re-emit Emacs-style control keypresses as the arrow keys they stand for. + * + * Clack navigates on the readline key name, so a synthetic arrow keypress + * moves the cursor in every prompt kind. Its own alias table cannot express + * this: aliases match bare key names, unaware of ctrl, and are ignored by + * prompts that track typed input, such as autocomplete. + */ +export const emitArrowKeyAliases = (input: EventEmitter): void => { + input.on('keypress', (_char, key: Key | undefined) => { + const arrowKey = arrowKeyFor(key) + if (arrowKey !== undefined) input.emit('keypress', undefined, arrowKey) + }) +} + +let arrowKeyAliasesInstalled = false + +// Keypress events only flow while a prompt has stdin in raw mode, so the +// listener is inert the rest of the time and never holds the process open. +const installArrowKeyAliases = (): void => { + if (arrowKeyAliasesInstalled) return + arrowKeyAliasesInstalled = true + emitArrowKeyAliases(process.stdin) +} + +const unwrap = (value: Value | symbol): Value => { + if (isCancel(value)) throw new PromptCancelledError() + return value as Value } +// Prompts are rendered to stderr: a selection is not a command result, +// so it must not end up in stdout when the CLI is piped. +const output = process.stderr + const toOptions = ( choices: Array>, ): Array> => @@ -112,34 +109,27 @@ export const promptText = async (options: { placeholder?: string defaultValue?: string validate?: (value: string | undefined) => string | undefined - allowBack?: boolean }): Promise => { - const { allowBack, ...textOptions } = options - return await runPrompt( - { kind: 'text', allowBack }, - async (input) => await text({ ...textOptions, input, output: io.output }), - ) + ensureInteractive() + return unwrap(await text({ ...options, output })) } export const promptNumber = async (options: { message: string validate?: (value: number) => string | undefined - allowBack?: boolean }): Promise => { - const value = await runPrompt( - { kind: 'text', allowBack: options.allowBack }, - async (input) => - await text({ - message: options.message, - validate: (value) => { - if (value == null || value.trim() === '') return 'Enter a number' - const parsed = Number(value) - if (Number.isNaN(parsed)) return 'Enter a number' - return options.validate?.(parsed) - }, - input, - output: io.output, - }), + ensureInteractive() + const value = unwrap( + await text({ + message: options.message, + validate: (value) => { + if (value == null || value.trim() === '') return 'Enter a number' + const parsed = Number(value) + if (Number.isNaN(parsed)) return 'Enter a number' + return options.validate?.(parsed) + }, + output, + }), ) return Number(value) } @@ -149,63 +139,56 @@ export const promptConfirm = async (options: { initialValue?: boolean active?: string inactive?: string -}): Promise => - await runPrompt( - { kind: 'confirm' }, - async (input) => await confirm({ ...options, input, output: io.output }), - ) +}): Promise => { + ensureInteractive() + return unwrap(await confirm({ ...options, output })) +} export const promptSelect = async (options: { message: string choices: Array> - allowBack?: boolean -}): Promise => - await runPrompt( - { kind: 'choice', allowBack: options.allowBack }, - async (input) => - await select({ - message: options.message, - options: toOptions(options.choices), - input, - output: io.output, - }), +}): Promise => { + ensureInteractive() + return unwrap( + await select({ + message: options.message, + options: toOptions(options.choices), + output, + }), ) +} export const promptAutocomplete = async (options: { message: string choices: Array> - allowBack?: boolean -}): Promise => - await runPrompt( - { kind: 'choice', allowBack: options.allowBack }, - async (input) => - await autocomplete({ - message: options.message, - options: toOptions(options.choices), - // Search a list by any part of a name or hint, rather than only by - // the label, which is all clack matches for itself. - filter: searchChoices, - input, - output: io.output, - }), +}): Promise => { + ensureInteractive() + return unwrap( + await autocomplete({ + message: options.message, + options: toOptions(options.choices), + // Search a list by any part of a name or hint, rather than only by + // the label, which is all clack matches for itself. + filter: searchChoices, + output, + }), ) +} export const promptAutocompleteMultiselect = async (options: { message: string choices: Array> - allowBack?: boolean -}): Promise => - await runPrompt( - { kind: 'choice', allowBack: options.allowBack }, - async (input) => - await autocompleteMultiselect({ - message: options.message, - options: toOptions(options.choices), - filter: searchChoices, - input, - output: io.output, - }), +}): Promise => { + ensureInteractive() + return unwrap( + await autocompleteMultiselect({ + message: options.message, + options: toOptions(options.choices), + filter: searchChoices, + output, + }), ) +} export interface SearchableChoice { label?: string | undefined