From 8c4775b2dddb6432ced47c95348c8c4e76661df8 Mon Sep 17 00:00:00 2001 From: "@StringerBell69" Date: Thu, 15 Oct 2026 15:28:22 +0200 Subject: [PATCH] feat(prompts): add back navigation to `group` (#39) Pressing Shift+Tab during a grouped prompt returns to the previous step, discarding its answer and re-running the previous prompt. - @clack/core: add a `back` action (unbound by default, so standalone prompts are unchanged), a `BACK_SYMBOL`/`isBack()` pair mirroring `CANCEL_SYMBOL`/`isCancel()`, and a `'back'` prompt state. - @clack/prompts: rework `group()` into a navigable loop. Back is on by default (disable with `navigate: false`) and exposes `onBack`. Closes #39 --- .changeset/group-back-navigation.md | 17 +++ packages/core/src/index.ts | 2 + packages/core/src/prompts/prompt.ts | 33 +++- packages/core/src/types.ts | 10 +- packages/core/src/utils/index.ts | 6 + packages/core/src/utils/settings.ts | 2 +- packages/prompts/src/common.ts | 4 + packages/prompts/src/group.ts | 104 ++++++++++--- packages/prompts/src/index.ts | 9 +- packages/prompts/test/group.test.ts | 227 ++++++++++++++++++++++++++++ 10 files changed, 389 insertions(+), 25 deletions(-) create mode 100644 .changeset/group-back-navigation.md create mode 100644 packages/prompts/test/group.test.ts diff --git a/.changeset/group-back-navigation.md b/.changeset/group-back-navigation.md new file mode 100644 index 00000000..a79ec43d --- /dev/null +++ b/.changeset/group-back-navigation.md @@ -0,0 +1,17 @@ +--- +'@clack/core': minor +'@clack/prompts': minor +--- + +Add back navigation to `group` + +The `group` utility can now return to a previous step. Pressing `Shift+Tab` +during a prompt returns to the previous one. Answers are kept, and each step +receives its previous answer as `previous`, so passing it to the prompt's +`initialValue` restores what the user had entered. It is on by default and can +be turned off with `group(prompts, { navigate: false })`; an +`onBack({ from, to, results })` callback is also available. + +Under the hood this adds a `back` action to `@clack/core` (unbound by default, so +standalone prompts are unchanged), a `BACK_SYMBOL`/`isBack()` pair mirroring +`CANCEL_SYMBOL`/`isCancel()`, and a `'back'` prompt state. Closes #39. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ce83777..705b1b68 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,10 +22,12 @@ export type { TextOptions } from './prompts/text.js'; export { default as TextPrompt } from './prompts/text.js'; export type { ClackState as State } from './types.js'; export { + BACK_SYMBOL, block, CANCEL_SYMBOL, getColumns, getRows, + isBack, isCancel, wrapTextWithPrefix, } from './utils/index.js'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index d1c61961..1bf6f470 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -6,6 +6,7 @@ import { cursor, erase } from 'sisteransi'; import type { ClackEvents, ClackState } from '../types.js'; import type { Action } from '../utils/index.js'; import { + BACK_SYMBOL, CANCEL_SYMBOL, diffLines, getRows, @@ -144,7 +145,8 @@ export default class Prompt { } public prompt() { - return new Promise((resolve) => { + type Resolution = TValue | typeof CANCEL_SYMBOL | typeof BACK_SYMBOL | undefined; + return new Promise((resolve) => { if (this._abortSignal) { if (this._abortSignal.aborted) { this.state = 'cancel'; @@ -194,6 +196,12 @@ export default class Prompt { setRawMode(this.input, false); resolve(CANCEL_SYMBOL); }); + this.once('back', () => { + this.output.write(cursor.show); + this.output.off('resize', this.render); + setRawMode(this.input, false); + resolve(BACK_SYMBOL); + }); }); } @@ -280,12 +288,20 @@ export default class Prompt { if (isActionKey([char, key?.name, key?.sequence], 'cancel')) { this.state = 'cancel'; } + // `back` lets a caller (e.g. the `group` utility) return to a previous + // step. It is unbound by default, so standalone prompts are unaffected. + if (this.state !== 'cancel' && isActionKey([char, key?.name, key?.sequence], 'back')) { + this.state = 'back'; + } - if (this.state === 'submit' || this.state === 'cancel') { + if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') { this.emit('finalize'); } - this.render(); - if (this.state === 'submit' || this.state === 'cancel') { + // `back` leaves no frame behind (see close()), so skip its render. + if (this.state !== 'back') { + this.render(); + } + if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') { this.close(); } } @@ -293,7 +309,14 @@ export default class Prompt { protected close() { this.input.unpipe(); this.input.removeListener('keypress', this.onKeypress); - this.output.write('\n'); + if (this.state === 'back') { + // Going back: erase this step's output so navigation stays clean, + // rather than leaving a finalized frame behind like submit/cancel do. + this.restoreCursor(); + this.output.write(erase.down()); + } else { + this.output.write('\n'); + } setRawMode(this.input, false); this.rl?.close(); this.rl = undefined; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 14c34567..ea824fba 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4,7 +4,14 @@ import type { Action } from './utils/settings.js'; /** * The state of the prompt */ -export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating'; +export type ClackState = + | 'initial' + | 'active' + | 'cancel' + | 'back' + | 'submit' + | 'error' + | 'validating'; /** * Typed event emitter for clack @@ -13,6 +20,7 @@ export interface ClackEvents { initial: (value?: any) => void; active: (value?: any) => void; cancel: (value?: any) => void; + back: (value?: any) => void; submit: (value?: any) => void; error: (value?: any) => void; validating: (value?: any) => void; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 96199a8b..de1a1668 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -18,6 +18,12 @@ export function isCancel(value: unknown): value is typeof CANCEL_SYMBOL { return value === CANCEL_SYMBOL; } +export const BACK_SYMBOL = Symbol('clack:back'); + +export function isBack(value: unknown): value is typeof BACK_SYMBOL { + return value === BACK_SYMBOL; +} + export function setRawMode(input: Readable, value: boolean) { const i = input as typeof stdin; diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index b3d008cf..51bb5dae 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -1,4 +1,4 @@ -const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; +const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel', 'back'] as const; export type Action = (typeof actions)[number]; const DEFAULT_MONTH_NAMES = [ diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 88037f0b..9e8094ba 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -52,6 +52,8 @@ export const symbol = (state: State) => { return styleText('green', S_STEP_SUBMIT); case 'validating': return styleText('dim', S_STEP_ACTIVE); + case 'back': + return styleText('dim', S_STEP_ACTIVE); } }; @@ -66,6 +68,8 @@ export const symbolBar = (state: State) => { return styleText('yellow', S_BAR); case 'submit': return styleText('green', S_BAR); + case 'back': + return styleText('dim', S_BAR); } }; diff --git a/packages/prompts/src/group.ts b/packages/prompts/src/group.ts index 0e4b9246..9b33de28 100644 --- a/packages/prompts/src/group.ts +++ b/packages/prompts/src/group.ts @@ -1,4 +1,7 @@ -import { isCancel } from '@clack/core'; +import { isBack, isCancel, settings } from '@clack/core'; + +/** Shift+Tab. Bound to the `back` action while a navigable group is running. */ +const SHIFT_TAB_SEQUENCE = '\x1b[Z'; type Prettify = { [P in keyof T]: T[P]; @@ -20,6 +23,26 @@ export interface PromptGroupOptions { * Called when any one of the prompts is canceled. */ onCancel?: (opts: { results: Prettify>> }) => void; + + /** + * Enable back navigation. When `true` (the default), pressing `Shift+Tab` + * during a prompt returns to the previous step. Answers are kept, and each + * step receives its previous answer as `previous` so it can be restored. Set + * to `false` to keep the group strictly forward-only. + * + * @default true + */ + navigate?: boolean; + + /** + * Called when the user returns to a previous step, with the name of the step + * being left and the step being returned to. + */ + onBack?: (opts: { + from: keyof T; + to: keyof T; + results: Prettify>>; + }) => void; } /** @@ -29,6 +52,12 @@ export interface PromptGroupOptions { export type PromptGroup = { [P in keyof T]: (opts: { results: Prettify>>>; + /** + * This step's previously-submitted answer, if the user has visited it + * before (e.g. after going back). Pass it to the prompt's `initialValue` + * to restore what they had entered. + */ + previous?: PromptGroupAwaitedReturn[P]; }) => undefined | Promise; }; @@ -44,11 +73,14 @@ export type PromptGroup = { * import { group, text, password } from '@clack/prompts'; * * const account = await group({ - * email: () => text({ + * // `Shift+Tab` goes back; pass `previous` to `initialValue` to restore it. + * email: ({ previous }) => text({ * message: 'What is your email address?', + * initialValue: previous, * }), - * username: ({ results }) => text({ + * username: ({ results, previous }) => text({ * message: 'What is your username?', + * initialValue: previous, * placeholder: results.email?.replace(/@.+$/, '').toLowerCase() ?? '', * }), * password: () => password({ @@ -63,23 +95,61 @@ export const group = async ( ): Promise>> => { const results = {} as any; const promptNames = Object.keys(prompts); + const navigate = opts?.navigate !== false; - for (const name of promptNames) { - const prompt = prompts[name as keyof T]; - const result = await prompt({ results })?.catch((e) => { - throw e; - }); + // We bind Shift+Tab -> `back` only while a step that *can* go back is showing, + // and restore the previous alias state afterwards so we never clobber a user's + // own binding. We only manage the alias when the user hasn't bound it already. + const manageAlias = navigate && !settings.aliases.has(SHIFT_TAB_SEQUENCE); + const setBackEnabled = (enabled: boolean) => { + if (!manageAlias) return; + if (enabled) settings.aliases.set(SHIFT_TAB_SEQUENCE, 'back'); + else settings.aliases.delete(SHIFT_TAB_SEQUENCE); + }; - // Pass the results to the onCancel function - // so the user can decide what to do with the results - // TODO: Switch to callback within core to avoid isCancel Fn - if (typeof opts?.onCancel === 'function' && isCancel(result)) { - results[name] = 'canceled'; - opts.onCancel({ results }); - continue; - } + try { + let index = 0; + while (index < promptNames.length) { + // There is nowhere to go back to from the first step, so `back` is + // disabled there (Shift+Tab does nothing); enabled from the second on. + setBackEnabled(index > 0); + + // Safe: the loop condition guarantees `index` is in range. + const name = promptNames[index] as string; + const prompt = prompts[name as keyof T]; + // `previous` lets a revisited step restore what the user had entered. + const result = await prompt({ results, previous: results[name] })?.catch((e) => { + throw e; + }); - results[name] = result; + // `back`: return to the previous step. Answers are kept (not discarded), + // so each step can offer them back via `previous`. Guarded against the + // first step in case a caller forces `back` there programmatically. + if (navigate && isBack(result)) { + if (index > 0) { + const from = name as keyof T; + index--; + const to = promptNames[index] as keyof T; + opts?.onBack?.({ from, to, results }); + } + continue; + } + + // Pass the results to the onCancel function + // so the user can decide what to do with the results + // TODO: Switch to callback within core to avoid isCancel Fn + if (typeof opts?.onCancel === 'function' && isCancel(result)) { + results[name] = 'canceled'; + opts.onCancel({ results }); + index++; + continue; + } + + results[name] = result; + index++; + } + } finally { + setBackEnabled(false); } return results; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 766b0897..07151f93 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1,5 +1,12 @@ export type { ClackSettings } from '@clack/core'; -export { CANCEL_SYMBOL, isCancel, settings, updateSettings } from '@clack/core'; +export { + BACK_SYMBOL, + CANCEL_SYMBOL, + isBack, + isCancel, + settings, + updateSettings, +} from '@clack/core'; export * from './autocomplete.js'; export * from './box.js'; diff --git a/packages/prompts/test/group.test.ts b/packages/prompts/test/group.test.ts new file mode 100644 index 00000000..ea29891a --- /dev/null +++ b/packages/prompts/test/group.test.ts @@ -0,0 +1,227 @@ +import { BACK_SYMBOL, CANCEL_SYMBOL, isBack, settings } from '@clack/core'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +const SHIFT_TAB = '\x1b[Z'; + +describe('group', () => { + afterEach(() => { + settings.aliases.delete(SHIFT_TAB); + vi.restoreAllMocks(); + }); + + test('collects each prompt result into one object', async () => { + const result = await prompts.group({ + a: () => Promise.resolve('a1'), + b: () => Promise.resolve('b1'), + }); + expect(result).toEqual({ a: 'a1', b: 'b1' }); + }); + + test('a step returning BACK_SYMBOL returns to the previous step', async () => { + const visits: string[] = []; + let bWentBack = false; + const result = await prompts.group({ + a: () => { + visits.push('a'); + return Promise.resolve('a1'); + }, + b: () => { + visits.push('b'); + if (!bWentBack) { + bWentBack = true; + return Promise.resolve(BACK_SYMBOL); + } + return Promise.resolve('b1'); + }, + }); + expect(visits).toEqual(['a', 'b', 'a', 'b']); + expect(result).toEqual({ a: 'a1', b: 'b1' }); + }); + + test('offers the previous answer back to a revisited step', async () => { + const seenPrev: unknown[] = []; + let bWentBack = false; + await prompts.group({ + a: ({ previous }) => { + seenPrev.push(previous); + return Promise.resolve('a-val'); + }, + b: () => { + if (!bWentBack) { + bWentBack = true; + return Promise.resolve(BACK_SYMBOL); + } + return Promise.resolve('b-val'); + }, + }); + // `a` runs twice: first with no previous, then (after going back) with its kept answer. + expect(seenPrev).toEqual([undefined, 'a-val']); + }); + + test('preserves a submitted answer when returning to its step from further ahead', async () => { + const bPrev: unknown[] = []; + let cWentBack = false; + const result = await prompts.group({ + a: () => Promise.resolve('a'), + b: ({ previous }) => { + bPrev.push(previous); + return Promise.resolve('b-val'); + }, + c: () => { + if (!cWentBack) { + cWentBack = true; + return Promise.resolve(BACK_SYMBOL); + } + return Promise.resolve('c'); + }, + }); + // c goes back to b; b is re-offered its earlier answer. + expect(bPrev).toEqual([undefined, 'b-val']); + expect(result).toEqual({ a: 'a', b: 'b-val', c: 'c' }); + }); + + test('calls onBack with the step being left and the step returned to', async () => { + const onBack = vi.fn(); + let bWentBack = false; + await prompts.group( + { + a: () => Promise.resolve('a1'), + b: () => { + if (!bWentBack) { + bWentBack = true; + return Promise.resolve(BACK_SYMBOL); + } + return Promise.resolve('b1'); + }, + }, + { onBack } + ); + expect(onBack).toHaveBeenCalledTimes(1); + expect(onBack.mock.calls[0]?.[0]).toMatchObject({ from: 'b', to: 'a' }); + }); + + test('disables back on the first step and enables it afterwards', async () => { + const aliasDuring: boolean[] = []; + let bWentBack = false; + await prompts.group({ + a: () => { + aliasDuring.push(settings.aliases.has(SHIFT_TAB)); + return Promise.resolve('a'); + }, + b: () => { + aliasDuring.push(settings.aliases.has(SHIFT_TAB)); + if (!bWentBack) { + bWentBack = true; + return Promise.resolve(BACK_SYMBOL); + } + return Promise.resolve('b'); + }, + }); + // a (root) -> off, b -> on, back to a -> off, b -> on. + expect(aliasDuring).toEqual([false, true, false, true]); + }); + + test('a forced BACK on the first step is ignored (re-runs, never goes out of range)', async () => { + let count = 0; + const result = await prompts.group({ + a: () => { + count++; + return Promise.resolve(count === 1 ? BACK_SYMBOL : 'a-final'); + }, + }); + expect(count).toBe(2); + expect(result).toEqual({ a: 'a-final' }); + }); + + test('navigate:false disables back and never binds Shift+Tab', async () => { + const result = await prompts.group( + { a: () => Promise.resolve(BACK_SYMBOL) }, + { navigate: false } + ); + expect(isBack(result.a)).toBe(true); + expect(settings.aliases.has(SHIFT_TAB)).toBe(false); + }); + + test('restores the Shift+Tab alias state after running', async () => { + expect(settings.aliases.has(SHIFT_TAB)).toBe(false); + await prompts.group({ a: () => Promise.resolve('a1') }); + expect(settings.aliases.has(SHIFT_TAB)).toBe(false); + }); + + test('onCancel still fires and collects results', async () => { + const onCancel = vi.fn(); + const result = await prompts.group( + { + a: () => Promise.resolve('a1'), + b: () => Promise.resolve(CANCEL_SYMBOL), + }, + { onCancel } + ); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ a: 'a1', b: 'canceled' }); + }); +}); + +describe('back navigation (core wiring)', () => { + afterEach(() => { + settings.aliases.delete(SHIFT_TAB); + }); + + test('Shift+Tab resolves a text prompt to BACK_SYMBOL when the back action is bound', async () => { + settings.aliases.set(SHIFT_TAB, 'back'); + const input = new MockReadable(); + const output = new MockWritable(); + const result = prompts.text({ message: 'foo', input, output }); + input.emit('keypress', '', { name: 'tab', shift: true, sequence: SHIFT_TAB }); + const value = await result; + expect(isBack(value)).toBe(true); + }); + + test('going back leaves no "undefined" in the rendered output', async () => { + settings.aliases.set(SHIFT_TAB, 'back'); + const input = new MockReadable(); + const output = new MockWritable(); + const result = prompts.select({ + message: 'pick one', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + input.emit('keypress', '', { name: 'tab', shift: true, sequence: SHIFT_TAB }); + await result; + expect(output.buffer.join('')).not.toContain('undefined'); + }); + + test('Shift+Tab resolves a select prompt to BACK_SYMBOL', async () => { + settings.aliases.set(SHIFT_TAB, 'back'); + const input = new MockReadable(); + const output = new MockWritable(); + const result = prompts.select({ + message: 'pick one', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + input.emit('keypress', '', { name: 'tab', shift: true, sequence: SHIFT_TAB }); + const value = await result; + expect(isBack(value)).toBe(true); + }); + + test('a select prompt without the back action is unaffected by Shift+Tab', async () => { + const input = new MockReadable(); + const output = new MockWritable(); + const result = prompts.select({ + message: 'pick one', + options: [{ value: 'opt0' }, { value: 'opt1' }], + input, + output, + }); + // No `back` alias bound: Shift+Tab does nothing, Enter still submits. + input.emit('keypress', '', { name: 'tab', shift: true, sequence: SHIFT_TAB }); + input.emit('keypress', '', { name: 'return' }); + const value = await result; + expect(value).toBe('opt0'); + }); +});