diff --git a/src/ui/tui/__tests__/WizardAskScreen.test.ts b/src/ui/tui/__tests__/WizardAskScreen.test.ts index f210f388..b751c571 100644 --- a/src/ui/tui/__tests__/WizardAskScreen.test.ts +++ b/src/ui/tui/__tests__/WizardAskScreen.test.ts @@ -21,6 +21,7 @@ vi.mock('../../../utils/analytics.js', () => ({ import { WizardStore } from '@ui/tui/store'; import { + askEscapeHint, handleAskKey, isRequiredButEmpty, } from '@ui/tui/screens/WizardAskScreen'; @@ -62,6 +63,22 @@ describe('handleAskKey', () => { }); }); +describe('askEscapeHint', () => { + it('says plain "skip" for a single-question request', () => { + expect(askEscapeHint(1, 0)).toBe('skip'); + }); + + it('names the scope on a multi-question request', () => { + expect(askEscapeHint(5, 0)).toBe('skip all 5 questions'); + }); + + it('warns that answers already given are discarded', () => { + expect(askEscapeHint(5, 4)).toBe( + 'skip all 5 questions, discarding the 4 you answered', + ); + }); +}); + describe('isRequiredButEmpty', () => { it('blocks an empty required text field (required defaults to true)', () => { expect(isRequiredButEmpty({}, '')).toBe(true); diff --git a/src/ui/tui/screens/WizardAskScreen.tsx b/src/ui/tui/screens/WizardAskScreen.tsx index e47ba643..80931841 100644 --- a/src/ui/tui/screens/WizardAskScreen.tsx +++ b/src/ui/tui/screens/WizardAskScreen.tsx @@ -37,6 +37,24 @@ export function handleAskKey( if (key.escape) store.cancelPendingQuestion(); } +/** + * What pressing Esc actually does, phrased for the footer hint. + * + * Esc declines the *whole* request — {@link WizardStore.cancelPendingQuestion} + * builds a cancelled answer for every question, so the ones already typed are + * discarded too. The footer used to label that "skip", which on a multi-question + * request reads as "skip this field": the warehouse task walks a source's + * credentials one field at a time, several of them optional, and a user who + * pressed Esc to pass on an optional field instead threw away the whole source + * and dropped the agent onto its browser-handoff fallback. Naming the scope + * costs a few characters and makes the destructive key read as destructive. + */ +export function askEscapeHint(total: number, answered: number): string { + if (total <= 1) return 'skip'; + if (answered <= 0) return `skip all ${total} questions`; + return `skip all ${total} questions, discarding the ${answered} you answered`; +} + /** * Whether an answer would leave a required question effectively unanswered. * @@ -170,6 +188,11 @@ export const WizardAskScreen = ({ store }: WizardAskScreenProps) => { const total = pending.questions.length; const progress = total > 1 ? `Question ${index + 1} of ${total}` : null; + const escapeHint = askEscapeHint(total, index); + // An optional text field already accepts an empty Enter (see + // `isRequiredButEmpty`) — it just never said so, leaving Esc as the only + // visible exit from a question the user did not want to answer. + const canSkipOne = question.kind === 'text' && question.required === false; const submit = (value: string | string[]) => { // Don't let a required field go through empty — it would reach the agent as @@ -246,13 +269,21 @@ export const WizardAskScreen = ({ store }: WizardAskScreenProps) => { {Icons.warning} This field is required — type an answer, or press - ESC to skip. + ESC to {escapeHint}. + + + )} + {canSkipOne && ( + + + Optional — press ENTER on an + empty answer to skip just this one. )} - ESC skip + ESC {escapeHint}