Skip to content

Commit 8c4775b

Browse files
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
1 parent f325a53 commit 8c4775b

10 files changed

Lines changed: 389 additions & 25 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@clack/core': minor
3+
'@clack/prompts': minor
4+
---
5+
6+
Add back navigation to `group`
7+
8+
The `group` utility can now return to a previous step. Pressing `Shift+Tab`
9+
during a prompt returns to the previous one. Answers are kept, and each step
10+
receives its previous answer as `previous`, so passing it to the prompt's
11+
`initialValue` restores what the user had entered. It is on by default and can
12+
be turned off with `group(prompts, { navigate: false })`; an
13+
`onBack({ from, to, results })` callback is also available.
14+
15+
Under the hood this adds a `back` action to `@clack/core` (unbound by default, so
16+
standalone prompts are unchanged), a `BACK_SYMBOL`/`isBack()` pair mirroring
17+
`CANCEL_SYMBOL`/`isCancel()`, and a `'back'` prompt state. Closes #39.

‎packages/core/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ export type { TextOptions } from './prompts/text.js';
2222
export { default as TextPrompt } from './prompts/text.js';
2323
export type { ClackState as State } from './types.js';
2424
export {
25+
BACK_SYMBOL,
2526
block,
2627
CANCEL_SYMBOL,
2728
getColumns,
2829
getRows,
30+
isBack,
2931
isCancel,
3032
wrapTextWithPrefix,
3133
} from './utils/index.js';

‎packages/core/src/prompts/prompt.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cursor, erase } from 'sisteransi';
66
import type { ClackEvents, ClackState } from '../types.js';
77
import type { Action } from '../utils/index.js';
88
import {
9+
BACK_SYMBOL,
910
CANCEL_SYMBOL,
1011
diffLines,
1112
getRows,
@@ -144,7 +145,8 @@ export default class Prompt<TValue> {
144145
}
145146

146147
public prompt() {
147-
return new Promise<TValue | typeof CANCEL_SYMBOL | undefined>((resolve) => {
148+
type Resolution = TValue | typeof CANCEL_SYMBOL | typeof BACK_SYMBOL | undefined;
149+
return new Promise<Resolution>((resolve) => {
148150
if (this._abortSignal) {
149151
if (this._abortSignal.aborted) {
150152
this.state = 'cancel';
@@ -194,6 +196,12 @@ export default class Prompt<TValue> {
194196
setRawMode(this.input, false);
195197
resolve(CANCEL_SYMBOL);
196198
});
199+
this.once('back', () => {
200+
this.output.write(cursor.show);
201+
this.output.off('resize', this.render);
202+
setRawMode(this.input, false);
203+
resolve(BACK_SYMBOL);
204+
});
197205
});
198206
}
199207

@@ -280,20 +288,35 @@ export default class Prompt<TValue> {
280288
if (isActionKey([char, key?.name, key?.sequence], 'cancel')) {
281289
this.state = 'cancel';
282290
}
291+
// `back` lets a caller (e.g. the `group` utility) return to a previous
292+
// step. It is unbound by default, so standalone prompts are unaffected.
293+
if (this.state !== 'cancel' && isActionKey([char, key?.name, key?.sequence], 'back')) {
294+
this.state = 'back';
295+
}
283296

284-
if (this.state === 'submit' || this.state === 'cancel') {
297+
if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') {
285298
this.emit('finalize');
286299
}
287-
this.render();
288-
if (this.state === 'submit' || this.state === 'cancel') {
300+
// `back` leaves no frame behind (see close()), so skip its render.
301+
if (this.state !== 'back') {
302+
this.render();
303+
}
304+
if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') {
289305
this.close();
290306
}
291307
}
292308

293309
protected close() {
294310
this.input.unpipe();
295311
this.input.removeListener('keypress', this.onKeypress);
296-
this.output.write('\n');
312+
if (this.state === 'back') {
313+
// Going back: erase this step's output so navigation stays clean,
314+
// rather than leaving a finalized frame behind like submit/cancel do.
315+
this.restoreCursor();
316+
this.output.write(erase.down());
317+
} else {
318+
this.output.write('\n');
319+
}
297320
setRawMode(this.input, false);
298321
this.rl?.close();
299322
this.rl = undefined;

‎packages/core/src/types.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import type { Action } from './utils/settings.js';
44
/**
55
* The state of the prompt
66
*/
7-
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating';
7+
export type ClackState =
8+
| 'initial'
9+
| 'active'
10+
| 'cancel'
11+
| 'back'
12+
| 'submit'
13+
| 'error'
14+
| 'validating';
815

916
/**
1017
* Typed event emitter for clack
@@ -13,6 +20,7 @@ export interface ClackEvents<TValue> {
1320
initial: (value?: any) => void;
1421
active: (value?: any) => void;
1522
cancel: (value?: any) => void;
23+
back: (value?: any) => void;
1624
submit: (value?: any) => void;
1725
error: (value?: any) => void;
1826
validating: (value?: any) => void;

‎packages/core/src/utils/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export function isCancel(value: unknown): value is typeof CANCEL_SYMBOL {
1818
return value === CANCEL_SYMBOL;
1919
}
2020

21+
export const BACK_SYMBOL = Symbol('clack:back');
22+
23+
export function isBack(value: unknown): value is typeof BACK_SYMBOL {
24+
return value === BACK_SYMBOL;
25+
}
26+
2127
export function setRawMode(input: Readable, value: boolean) {
2228
const i = input as typeof stdin;
2329

‎packages/core/src/utils/settings.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
1+
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel', 'back'] as const;
22
export type Action = (typeof actions)[number];
33

44
const DEFAULT_MONTH_NAMES = [

‎packages/prompts/src/common.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export const symbol = (state: State) => {
5252
return styleText('green', S_STEP_SUBMIT);
5353
case 'validating':
5454
return styleText('dim', S_STEP_ACTIVE);
55+
case 'back':
56+
return styleText('dim', S_STEP_ACTIVE);
5557
}
5658
};
5759

@@ -66,6 +68,8 @@ export const symbolBar = (state: State) => {
6668
return styleText('yellow', S_BAR);
6769
case 'submit':
6870
return styleText('green', S_BAR);
71+
case 'back':
72+
return styleText('dim', S_BAR);
6973
}
7074
};
7175

‎packages/prompts/src/group.ts‎

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { isCancel } from '@clack/core';
1+
import { isBack, isCancel, settings } from '@clack/core';
2+
3+
/** Shift+Tab. Bound to the `back` action while a navigable group is running. */
4+
const SHIFT_TAB_SEQUENCE = '\x1b[Z';
25

36
type Prettify<T> = {
47
[P in keyof T]: T[P];
@@ -20,6 +23,26 @@ export interface PromptGroupOptions<T> {
2023
* Called when any one of the prompts is canceled.
2124
*/
2225
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
26+
27+
/**
28+
* Enable back navigation. When `true` (the default), pressing `Shift+Tab`
29+
* during a prompt returns to the previous step. Answers are kept, and each
30+
* step receives its previous answer as `previous` so it can be restored. Set
31+
* to `false` to keep the group strictly forward-only.
32+
*
33+
* @default true
34+
*/
35+
navigate?: boolean;
36+
37+
/**
38+
* Called when the user returns to a previous step, with the name of the step
39+
* being left and the step being returned to.
40+
*/
41+
onBack?: (opts: {
42+
from: keyof T;
43+
to: keyof T;
44+
results: Prettify<Partial<PromptGroupAwaitedReturn<T>>>;
45+
}) => void;
2346
}
2447

2548
/**
@@ -29,6 +52,12 @@ export interface PromptGroupOptions<T> {
2952
export type PromptGroup<T> = {
3053
[P in keyof T]: (opts: {
3154
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
55+
/**
56+
* This step's previously-submitted answer, if the user has visited it
57+
* before (e.g. after going back). Pass it to the prompt's `initialValue`
58+
* to restore what they had entered.
59+
*/
60+
previous?: PromptGroupAwaitedReturn<T>[P];
3261
}) => undefined | Promise<T[P] | undefined>;
3362
};
3463

@@ -44,11 +73,14 @@ export type PromptGroup<T> = {
4473
* import { group, text, password } from '@clack/prompts';
4574
*
4675
* const account = await group({
47-
* email: () => text({
76+
* // `Shift+Tab` goes back; pass `previous` to `initialValue` to restore it.
77+
* email: ({ previous }) => text({
4878
* message: 'What is your email address?',
79+
* initialValue: previous,
4980
* }),
50-
* username: ({ results }) => text({
81+
* username: ({ results, previous }) => text({
5182
* message: 'What is your username?',
83+
* initialValue: previous,
5284
* placeholder: results.email?.replace(/@.+$/, '').toLowerCase() ?? '',
5385
* }),
5486
* password: () => password({
@@ -63,23 +95,61 @@ export const group = async <T>(
6395
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
6496
const results = {} as any;
6597
const promptNames = Object.keys(prompts);
98+
const navigate = opts?.navigate !== false;
6699

67-
for (const name of promptNames) {
68-
const prompt = prompts[name as keyof T];
69-
const result = await prompt({ results })?.catch((e) => {
70-
throw e;
71-
});
100+
// We bind Shift+Tab -> `back` only while a step that *can* go back is showing,
101+
// and restore the previous alias state afterwards so we never clobber a user's
102+
// own binding. We only manage the alias when the user hasn't bound it already.
103+
const manageAlias = navigate && !settings.aliases.has(SHIFT_TAB_SEQUENCE);
104+
const setBackEnabled = (enabled: boolean) => {
105+
if (!manageAlias) return;
106+
if (enabled) settings.aliases.set(SHIFT_TAB_SEQUENCE, 'back');
107+
else settings.aliases.delete(SHIFT_TAB_SEQUENCE);
108+
};
72109

73-
// Pass the results to the onCancel function
74-
// so the user can decide what to do with the results
75-
// TODO: Switch to callback within core to avoid isCancel Fn
76-
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
77-
results[name] = 'canceled';
78-
opts.onCancel({ results });
79-
continue;
80-
}
110+
try {
111+
let index = 0;
112+
while (index < promptNames.length) {
113+
// There is nowhere to go back to from the first step, so `back` is
114+
// disabled there (Shift+Tab does nothing); enabled from the second on.
115+
setBackEnabled(index > 0);
116+
117+
// Safe: the loop condition guarantees `index` is in range.
118+
const name = promptNames[index] as string;
119+
const prompt = prompts[name as keyof T];
120+
// `previous` lets a revisited step restore what the user had entered.
121+
const result = await prompt({ results, previous: results[name] })?.catch((e) => {
122+
throw e;
123+
});
81124

82-
results[name] = result;
125+
// `back`: return to the previous step. Answers are kept (not discarded),
126+
// so each step can offer them back via `previous`. Guarded against the
127+
// first step in case a caller forces `back` there programmatically.
128+
if (navigate && isBack(result)) {
129+
if (index > 0) {
130+
const from = name as keyof T;
131+
index--;
132+
const to = promptNames[index] as keyof T;
133+
opts?.onBack?.({ from, to, results });
134+
}
135+
continue;
136+
}
137+
138+
// Pass the results to the onCancel function
139+
// so the user can decide what to do with the results
140+
// TODO: Switch to callback within core to avoid isCancel Fn
141+
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
142+
results[name] = 'canceled';
143+
opts.onCancel({ results });
144+
index++;
145+
continue;
146+
}
147+
148+
results[name] = result;
149+
index++;
150+
}
151+
} finally {
152+
setBackEnabled(false);
83153
}
84154

85155
return results;

‎packages/prompts/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
export type { ClackSettings } from '@clack/core';
2-
export { CANCEL_SYMBOL, isCancel, settings, updateSettings } from '@clack/core';
2+
export {
3+
BACK_SYMBOL,
4+
CANCEL_SYMBOL,
5+
isBack,
6+
isCancel,
7+
settings,
8+
updateSettings,
9+
} from '@clack/core';
310

411
export * from './autocomplete.js';
512
export * from './box.js';

0 commit comments

Comments
 (0)