From b4bddbd368c3b3bad0d33fd7f51a510814fc5c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 23:56:04 +0000 Subject: [PATCH 1/2] fix: Return to the previous prompt when one is dismissed Dismissing a prompt ended the whole command, so escaping out of a single mistyped or misread answer, such as picking the wrong connected account, threw away every parameter gathered so far. The prompts already report a dismissal; only the flows around them treated it as fatal. Each step of a flow now catches it and returns to the step before, so a dismissal abandons an answer rather than the command. Dismissing the top level command menu still stops the CLI, since there is no step to go back to. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FyVFq6gYChW9wtoDq8CHsD --- src/lib/interact-for-array.ts | 64 ++-- src/lib/interact-for-blueprint-object.test.ts | 53 ++- src/lib/interact-for-blueprint-object.ts | 319 +++++++++--------- src/lib/interact-for-command-selection.ts | 32 +- src/lib/interact-for-custom-metadata.ts | 89 +++-- 5 files changed, 333 insertions(+), 224 deletions(-) diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 6ab01f71..30f84dea 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 { + PromptCancelledError, + promptNumber, + promptSelect, + promptText, +} from './util/prompt.js' export const interactForArray = async ( array: string[], @@ -23,31 +28,42 @@ 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' }, + ], }) - if (newItem) { - updatedArray.push(newItem) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing the action menu finishes editing, keeping the changes. + break + } + + try { + if (action === 'add') { + const newItem = await promptText({ + message: 'Enter the new item:', + }) + 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', + }) + 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 PromptCancelledError)) throw error + // Dismissing 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 63498ff2..03993f36 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -5,7 +5,12 @@ 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, promptSelect } from './util/prompt.js' +import { + promptAutocomplete, + PromptCancelledError, + promptSelect, + promptText, +} from './util/prompt.js' vi.mock('./util/prompt.js', () => ({ canPrompt: vi.fn(() => true), @@ -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) }) @@ -174,3 +181,47 @@ test.for(['custom_metadata', 'custom_metadata_has'] as const)( ).resolves.toEqual({ [name]: {} }) }, ) + +test('interactForBlueprintObject: dismissing the parameter menu leaves the command', async () => { + vi.mocked(promptAutocomplete).mockRejectedValueOnce( + new PromptCancelledError(), + ) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toBe('[Back]') +}) + +test('interactForBlueprintObject: dismissing a value prompt returns to the menu', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + + // The parameter is left unset and the command still runs, rather than the + // dismissal ending the whole command. + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) + expect(promptAutocomplete).toHaveBeenCalledTimes(2) +}) + +test('interactForBlueprintObject: dismissing a value prompt keeps an earlier value', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockRejectedValueOnce(new PromptCancelledError()) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1', name: 'Front Door' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1', name: 'Front Door' }) +}) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 3a268c1a..319b90e0 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, + PromptCancelledError, promptConfirm, promptNumber, promptSelect, @@ -93,53 +94,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, + 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) { + // Dismissing the menu means the same as choosing to go back. + if (!(error instanceof PromptCancelledError)) throw error + paramToEdit = 'back' + } if (paramToEdit === 'empty') { return undefined @@ -160,121 +168,128 @@ 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' || - paramToEdit === 'custom_metadata_has' - ) { - 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 + // Dismissing any prompt below returns to the parameter menu with the + // parameter left as it was, rather than ending the whole command. + 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' || + paramToEdit === 'custom_metadata_has' + ) { + 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}:`, + }) + } + 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, + })), + }) + 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, + })), + }) + 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}:`, + }) + + args.params[paramToEdit] = value + + return interactForBlueprintObject(args, ctx) + } } + } catch (error) { + if (!(error instanceof PromptCancelledError)) 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..53fc0601 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, PromptCancelledError } from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -92,16 +92,26 @@ 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)), + }) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing the menu means the same as its [Back] entry, which is only + // offered when there is a level to go back to. At the top there is none, + // so dismissing it stops the CLI as it always has. + if (commandPath.length === 0) throw error + 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 be9a1944..d6b65ca8 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -1,5 +1,9 @@ import { getOutput } from './output/get-output.js' -import { promptSelect, promptText } from './util/prompt.js' +import { + PromptCancelledError, + 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 +35,58 @@ 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' }, + ], }) + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing the action menu finishes editing, keeping the 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 = newValue === 'true' - } - 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:', + }) + + 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 = newValue === 'true' } - }), - }) + 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, + } + }, + ), + }) - delete updatedCustomMetadata[customKeyToRemove] + delete updatedCustomMetadata[customKeyToRemove] + } + } catch (error) { + if (!(error instanceof PromptCancelledError)) throw error + // Dismissing an inner prompt returns to the action menu. } } while (action !== 'done') From 8362e13b4473788fd5bfe5161cf0269019345d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 00:21:26 +0000 Subject: [PATCH 2/2] feat: Tell the user that a prompt can be left The new behaviour was invisible: prompts still only advertised the keys clack knows about. Note it on the message of every prompt whose flow returns to the step before, and only those, so it never claims a way back that does not exist. Clack renders its keyboard hints from a hardcoded list that a caller cannot add to, so the note goes on the message rather than that line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FyVFq6gYChW9wtoDq8CHsD --- src/lib/interact-for-array.ts | 7 ++-- src/lib/interact-for-blueprint-object.test.ts | 37 +++++++++++++++-- src/lib/interact-for-blueprint-object.ts | 19 +++++---- .../interact-for-command-selection.test.ts | 41 ++++++++++++++++++- src/lib/interact-for-command-selection.ts | 12 +++++- src/lib/interact-for-custom-metadata.test.ts | 8 ++-- src/lib/interact-for-custom-metadata.ts | 13 ++++-- src/lib/interact-for-resource.ts | 6 ++- src/lib/interact-for-timestamp.ts | 4 +- src/lib/util/prompt.ts | 12 ++++++ 10 files changed, 131 insertions(+), 28 deletions(-) diff --git a/src/lib/interact-for-array.ts b/src/lib/interact-for-array.ts index 30f84dea..34a5b8f8 100644 --- a/src/lib/interact-for-array.ts +++ b/src/lib/interact-for-array.ts @@ -4,6 +4,7 @@ import { promptNumber, promptSelect, promptText, + withBackHint, } from './util/prompt.js' export const interactForArray = async ( @@ -30,7 +31,7 @@ export const interactForArray = async ( try { action = await promptSelect({ - message: 'Choose an action:', + message: withBackHint('Choose an action:'), choices: [ { label: 'Add an item', value: 'add' }, { label: 'Remove an item', value: 'remove' }, @@ -46,14 +47,14 @@ export const interactForArray = async ( try { if (action === 'add') { const newItem = await promptText({ - message: 'Enter the new item:', + message: withBackHint('Enter the new item:'), }) if (newItem) { updatedArray.push(newItem) } } else if (action === 'remove') { const index = await promptNumber({ - message: 'Enter the index of the item to remove:', + message: withBackHint('Enter the index of the item to remove:'), validate: (value) => value > 0 && value <= updatedArray.length ? undefined diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index 03993f36..baa949d7 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -5,16 +5,19 @@ 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 type * as PromptModule from './util/prompt.js' import { promptAutocomplete, PromptCancelledError, promptSelect, promptText, + withBackHint, } from './util/prompt.js' -vi.mock('./util/prompt.js', () => ({ - canPrompt: vi.fn(() => true), - PromptCancelledError: class extends Error {}, +// Only the prompts themselves are replaced, so the real PromptCancelledError +// and withBackHint are used, as they are in production. +vi.mock('./util/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), promptConfirm: vi.fn(), @@ -225,3 +228,31 @@ test('interactForBlueprintObject: dismissing a value prompt keeps an earlier val ), ).resolves.toEqual({ device_id: 'device1', name: 'Front Door' }) }) + +test('interactForBlueprintObject: tells the user the parameter menu can be left', async () => { + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + const { message } = vi.mocked(promptAutocomplete).mock.calls[0]?.[0] as { + message: string + } + expect(message).toBe(withBackHint('[/devices/get] Parameters')) +}) + +test('interactForBlueprintObject: tells the user a value prompt can be left', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'name') + .mockImplementationOnce(async () => 'done') + vi.mocked(promptText).mockImplementationOnce(async () => 'Front Door') + + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + expect(vi.mocked(promptText).mock.calls[0]?.[0]).toMatchObject({ + message: withBackHint('name:'), + }) +}) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 319b90e0..5f4e173f 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -22,6 +22,7 @@ import { promptNumber, promptSelect, promptText, + withBackHint, } from './util/prompt.js' const ergonomicPropOrder = [ @@ -89,9 +90,11 @@ export const interactForBlueprintObject = async ( return ergonomicPropOrder.indexOf(prop) } - const parameterSelectionMessage = args.isSubProperty - ? `Editing "${args.subPropertyPath}"` - : `[${cmdPath}] Parameters` + const parameterSelectionMessage = withBackHint( + args.isSubProperty + ? `Editing "${args.subPropertyPath}"` + : `[${cmdPath}] Parameters`, + ) getOutput().info() let paramToEdit: string @@ -223,14 +226,14 @@ export const interactForBlueprintObject = async ( value = await interactForTimestamp() } else { value = await promptText({ - message: `${paramToEdit}:`, + message: withBackHint(`${paramToEdit}:`), }) } args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'enum') { const value = await promptSelect({ - message: `${paramToEdit}:`, + message: withBackHint(`${paramToEdit}:`), choices: prop.values.map((v) => ({ label: v.name, value: v.name, @@ -240,7 +243,7 @@ export const interactForBlueprintObject = async ( return interactForBlueprintObject(args, ctx) } else if (prop.format === 'boolean') { const value = await promptConfirm({ - message: `${paramToEdit}:`, + message: withBackHint(`${paramToEdit}:`), initialValue: true, active: 'true', inactive: 'false', @@ -251,7 +254,7 @@ export const interactForBlueprintObject = async ( return interactForBlueprintObject(args, ctx) } else if (prop.format === 'list' && prop.itemFormat === 'enum') { const value = await promptAutocompleteMultiselect({ - message: `${paramToEdit}:`, + message: withBackHint(`${paramToEdit}:`), choices: prop.itemEnumValues.map((v) => ({ label: v.name, value: v.name, @@ -279,7 +282,7 @@ export const interactForBlueprintObject = async ( return interactForBlueprintObject(args, ctx) } else if (prop.format === 'number') { const value = await promptNumber({ - message: `${paramToEdit}:`, + message: withBackHint(`${paramToEdit}:`), }) args.params[paramToEdit] = value diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts index 479339c1..c6c40de9 100644 --- a/src/lib/interact-for-command-selection.test.ts +++ b/src/lib/interact-for-command-selection.test.ts @@ -1,7 +1,18 @@ -import { expect, test } from 'vitest' +import { beforeEach, expect, test, vi } from 'vitest' import { interactForCommandSelection } from './interact-for-command-selection.js' import type { ContextHelpers } from './types.js' +import type * as PromptModule from './util/prompt.js' +import { promptAutocomplete, withBackHint } from './util/prompt.js' + +vi.mock('./util/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), + promptAutocomplete: vi.fn(), +})) + +beforeEach(() => { + vi.mocked(promptAutocomplete).mockReset() +}) const ctx = { interactivity: 'non-interactive', @@ -37,3 +48,31 @@ test('interactForCommandSelection: rejects a missing command when non-interactiv /^Missing command: expected one of /, ) }) + +const interactiveCtx = { + ...ctx, + interactivity: 'interactive', +} as unknown as ContextHelpers + +test('interactForCommandSelection: tells the user a sub-command menu can be left', async () => { + vi.mocked(promptAutocomplete).mockImplementationOnce(async () => 'list') + + await interactForCommandSelection(['devices'], interactiveCtx) + + expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + message: withBackHint('Select a command: /devices'), + }) +}) + +// Escape stops the CLI at the top level, so promising a way back would lie. +test('interactForCommandSelection: says nothing about going back at the top level', async () => { + vi.mocked(promptAutocomplete) + .mockImplementationOnce(async () => 'devices') + .mockImplementationOnce(async () => 'list') + + await interactForCommandSelection([], interactiveCtx) + + expect(vi.mocked(promptAutocomplete).mock.calls[0]?.[0]).toMatchObject({ + message: 'Select a command: /', + }) +}) diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index 53fc0601..ed030b12 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -2,7 +2,11 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import type { ContextHelpers } from './types.js' import { NonInteractiveError } from './util/cli-args.js' -import { promptAutocomplete, PromptCancelledError } from './util/prompt.js' +import { + promptAutocomplete, + PromptCancelledError, + withBackHint, +} from './util/prompt.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -92,10 +96,14 @@ export async function interactForCommandSelection( const commandPathStr = commandPath.join('/').replace(/-/g, '_') + // Only a sub-command menu has a level to go back to, so only it says so. + const selectMessage = `Select a command: /${commandPathStr}` + let selectedCommand: string try { selectedCommand = await promptAutocomplete({ - message: `Select a command: /${commandPathStr}`, + message: + commandPath.length > 0 ? withBackHint(selectMessage) : selectMessage, choices: [ ...possibleCommands.map((cmd) => ({ label: diff --git a/src/lib/interact-for-custom-metadata.test.ts b/src/lib/interact-for-custom-metadata.test.ts index 9244f0b1..ec8cbd52 100644 --- a/src/lib/interact-for-custom-metadata.test.ts +++ b/src/lib/interact-for-custom-metadata.test.ts @@ -3,11 +3,13 @@ import { beforeEach, expect, test, vi } from 'vitest' import { interactForCustomMetadata } from './interact-for-custom-metadata.js' import { createMemoryOutput } from './output/create-memory-output.js' import { setOutput } from './output/get-output.js' +import type * as PromptModule from './util/prompt.js' import { promptSelect, promptText } from './util/prompt.js' -vi.mock('./util/prompt.js', () => ({ - canPrompt: vi.fn(() => true), - PromptCancelledError: class extends Error {}, +// Only the prompts themselves are replaced, so the real PromptCancelledError +// and withBackHint are used, as they are in production. +vi.mock('./util/prompt.js', async (importOriginal) => ({ + ...(await importOriginal()), promptText: vi.fn(), promptNumber: vi.fn(), promptConfirm: vi.fn(), diff --git a/src/lib/interact-for-custom-metadata.ts b/src/lib/interact-for-custom-metadata.ts index d6b65ca8..dde3181c 100644 --- a/src/lib/interact-for-custom-metadata.ts +++ b/src/lib/interact-for-custom-metadata.ts @@ -3,6 +3,7 @@ import { PromptCancelledError, promptSelect, promptText, + withBackHint, } from './util/prompt.js' // Structurally the CustomMetadata of @seamapi/types, spelled out here so the @@ -37,7 +38,7 @@ export const interactForCustomMetadata = async ( try { action = await promptSelect({ - message: 'Choose an action:', + message: withBackHint('Choose an action:'), choices: [ { label: 'Add an item to params', value: 'add' }, { label: 'Remove an item from params', value: 'remove' }, @@ -53,11 +54,13 @@ export const interactForCustomMetadata = async ( try { if (action === 'add') { const newKey = await promptText({ - message: 'Enter a key to add or edit:', + message: withBackHint('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):', + message: withBackHint( + 'Enter the new value to add or edit (or null to delete):', + ), }) if (newKey) { if (newValue === 'false' || newValue === 'true') { @@ -71,7 +74,9 @@ export const interactForCustomMetadata = async ( } } else if (action === 'remove') { const customKeyToRemove = await promptSelect({ - message: 'Choose a key-value pair to remove from params:', + message: withBackHint( + 'Choose a key-value pair to remove from params:', + ), choices: Object.keys(updatedCustomMetadata).map( (customMetadataKey) => { return { diff --git a/src/lib/interact-for-resource.ts b/src/lib/interact-for-resource.ts index 08a2af5b..7438c11e 100644 --- a/src/lib/interact-for-resource.ts +++ b/src/lib/interact-for-resource.ts @@ -1,4 +1,4 @@ -import { promptAutocomplete } from './util/prompt.js' +import { promptAutocomplete, withBackHint } from './util/prompt.js' import { withLoading } from './util/with-loading.js' export interface ResourceChoice { @@ -23,7 +23,9 @@ export const interactForResource = async ({ fetchResources, ) return await promptAutocomplete({ - message, + // Resource pickers are only reached from the parameter flow, which + // returns to its menu when one is dismissed. + message: withBackHint(message), choices: resources.map((resource) => { const { title, value, description } = toChoice(resource) return { label: title, value, hint: description } diff --git a/src/lib/interact-for-timestamp.ts b/src/lib/interact-for-timestamp.ts index 55e78f65..6ecf1164 100644 --- a/src/lib/interact-for-timestamp.ts +++ b/src/lib/interact-for-timestamp.ts @@ -1,9 +1,9 @@ -import { promptText } from './util/prompt.js' +import { promptText, withBackHint } from './util/prompt.js' export const interactForTimestamp = async () => { const now = new Date().toISOString() const timestamp = await promptText({ - message: 'Enter a timestamp:', + message: withBackHint('Enter a timestamp:'), placeholder: now, defaultValue: now, validate: (value) => { diff --git a/src/lib/util/prompt.ts b/src/lib/util/prompt.ts index 5eae9953..1c84691e 100644 --- a/src/lib/util/prompt.ts +++ b/src/lib/util/prompt.ts @@ -10,6 +10,7 @@ import { select, text, } from '@clack/prompts' +import chalk from 'chalk' import { NonInteractiveError } from './cli-args.js' @@ -37,6 +38,17 @@ export interface PromptChoice { hint?: string | undefined } +/** + * Note on a prompt message that dismissing it returns to the previous step. + * + * Only for prompts whose caller catches the dismissal: elsewhere it still + * stops the CLI, and saying otherwise would mislead. The note goes in the + * message because clack renders its own keyboard hints from a hardcoded list + * that a caller cannot add to. + */ +export const withBackHint = (message: string): string => + `${message} ${chalk.dim('ยท Esc: go back')}` + const ensureInteractive = (): void => { if (!canPrompt()) { throw new NonInteractiveError(