feat: add AskUserDialog for UI component of AskUser tool (#17344)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Jack Wotherspoon
2026-01-23 15:42:48 -05:00
committed by GitHub
parent 25c0802b52
commit 2c0cc7b9a5
10 changed files with 3009 additions and 1 deletions

View File

@@ -0,0 +1,855 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
// Helper to write to stdin with proper act() wrapping
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('AskUserDialog', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const authQuestion: Question[] = [
{
question: 'Which authentication method should we use?',
header: 'Auth',
options: [
{ label: 'OAuth 2.0', description: 'Industry standard, supports SSO' },
{ label: 'JWT tokens', description: 'Stateless, good for APIs' },
],
multiSelect: false,
},
];
it('renders question and options', () => {
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
describe.each([
{
name: 'Single Select',
questions: authQuestion,
actions: (stdin: { write: (data: string) => void }) => {
writeKey(stdin, '\r');
},
expectedSubmit: { '0': 'OAuth 2.0' },
},
{
name: 'Multi-select',
questions: [
{
question: 'Which features?',
header: 'Features',
options: [
{ label: 'TypeScript', description: '' },
{ label: 'ESLint', description: '' },
],
multiSelect: true,
},
] as Question[],
actions: (stdin: { write: (data: string) => void }) => {
writeKey(stdin, '\r'); // Toggle TS
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\r'); // Toggle ESLint
writeKey(stdin, '\x1b[B'); // Down to Other
writeKey(stdin, '\x1b[B'); // Down to Done
writeKey(stdin, '\r'); // Done
},
expectedSubmit: { '0': 'TypeScript, ESLint' },
},
{
name: 'Text Input',
questions: [
{
question: 'Name?',
header: 'Name',
type: QuestionType.TEXT,
},
] as Question[],
actions: (stdin: { write: (data: string) => void }) => {
for (const char of 'test-app') {
writeKey(stdin, char);
}
writeKey(stdin, '\r');
},
expectedSubmit: { '0': 'test-app' },
},
])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => {
it(`submits correct values for ${name}`, async () => {
const onSubmit = vi.fn();
const { stdin } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
actions(stdin);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expectedSubmit);
});
});
});
it('handles custom option in single select with inline typing', async () => {
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
// Move down to custom option
writeKey(stdin, '\x1b[B');
writeKey(stdin, '\x1b[B');
await waitFor(() => {
expect(lastFrame()).toContain('Enter a custom value');
});
// Type directly (inline)
for (const char of 'API Key') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toContain('API Key');
});
// Press Enter to submit the custom value
writeKey(stdin, '\r');
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' });
});
});
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
// Type a character without navigating down
writeKey(stdin, 'A');
await waitFor(() => {
// Should show the custom input with 'A'
// Placeholder is hidden when text is present
expect(lastFrame()).toContain('A');
expect(lastFrame()).toContain('3. A');
});
// Continue typing
writeKey(stdin, 'P');
writeKey(stdin, 'I');
await waitFor(() => {
expect(lastFrame()).toContain('API');
});
});
it('shows progress header for multiple questions', () => {
const multiQuestions: Question[] = [
{
question: 'Which database should we use?',
header: 'Database',
options: [
{ label: 'PostgreSQL', description: 'Relational database' },
{ label: 'MongoDB', description: 'Document database' },
],
multiSelect: false,
},
{
question: 'Which ORM do you prefer?',
header: 'ORM',
options: [
{ label: 'Prisma', description: 'Type-safe ORM' },
{ label: 'Drizzle', description: 'Lightweight ORM' },
],
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('hides progress header for single question', () => {
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('shows keyboard hints', () => {
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('navigates between questions with arrow keys', async () => {
const multiQuestions: Question[] = [
{
question: 'Which testing framework?',
header: 'Testing',
options: [{ label: 'Vitest', description: 'Fast unit testing' }],
multiSelect: false,
},
{
question: 'Which CI provider?',
header: 'CI',
options: [
{ label: 'GitHub Actions', description: 'Built into GitHub' },
],
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toContain('Which testing framework?');
writeKey(stdin, '\x1b[C'); // Right arrow
await waitFor(() => {
expect(lastFrame()).toContain('Which CI provider?');
});
writeKey(stdin, '\x1b[D'); // Left arrow
await waitFor(() => {
expect(lastFrame()).toContain('Which testing framework?');
});
});
it('preserves answers when navigating back', async () => {
const multiQuestions: Question[] = [
{
question: 'Which package manager?',
header: 'Package',
options: [{ label: 'pnpm', description: 'Fast, disk efficient' }],
multiSelect: false,
},
{
question: 'Which bundler?',
header: 'Bundler',
options: [{ label: 'Vite', description: 'Next generation bundler' }],
multiSelect: false,
},
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
// Answer first question (should auto-advance)
writeKey(stdin, '\r');
await waitFor(() => {
expect(lastFrame()).toContain('Which bundler?');
});
// Navigate back
writeKey(stdin, '\x1b[D');
await waitFor(() => {
expect(lastFrame()).toContain('Which package manager?');
});
// Navigate forward
writeKey(stdin, '\x1b[C');
await waitFor(() => {
expect(lastFrame()).toContain('Which bundler?');
});
// Answer second question
writeKey(stdin, '\r');
await waitFor(() => {
expect(lastFrame()).toContain('Review your answers:');
});
// Submit from Review
writeKey(stdin, '\r');
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' });
});
});
it('shows Review tab in progress header for multiple questions', () => {
const multiQuestions: Question[] = [
{
question: 'Which framework?',
header: 'Framework',
options: [
{ label: 'React', description: 'Component library' },
{ label: 'Vue', description: 'Progressive framework' },
],
multiSelect: false,
},
{
question: 'Which styling?',
header: 'Styling',
options: [
{ label: 'Tailwind', description: 'Utility-first CSS' },
{ label: 'CSS Modules', description: 'Scoped styles' },
],
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('allows navigating to Review tab and back', async () => {
const multiQuestions: Question[] = [
{
question: 'Create tests?',
header: 'Tests',
options: [{ label: 'Yes', description: 'Generate test files' }],
multiSelect: false,
},
{
question: 'Add documentation?',
header: 'Docs',
options: [{ label: 'Yes', description: 'Generate JSDoc comments' }],
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
writeKey(stdin, '\x1b[C'); // Right arrow
await waitFor(() => {
expect(lastFrame()).toContain('Add documentation?');
});
writeKey(stdin, '\x1b[C'); // Right arrow to Review
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
writeKey(stdin, '\x1b[D'); // Left arrow back
await waitFor(() => {
expect(lastFrame()).toContain('Add documentation?');
});
});
it('shows warning for unanswered questions on Review tab', async () => {
const multiQuestions: Question[] = [
{
question: 'Which license?',
header: 'License',
options: [{ label: 'MIT', description: 'Permissive license' }],
multiSelect: false,
},
{
question: 'Include README?',
header: 'README',
options: [{ label: 'Yes', description: 'Generate README.md' }],
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
// Navigate directly to Review tab without answering
writeKey(stdin, '\x1b[C');
writeKey(stdin, '\x1b[C');
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('submits with unanswered questions when user confirms on Review', async () => {
const multiQuestions: Question[] = [
{
question: 'Target Node version?',
header: 'Node',
options: [{ label: 'Node 20', description: 'LTS version' }],
multiSelect: false,
},
{
question: 'Enable strict mode?',
header: 'Strict',
options: [{ label: 'Yes', description: 'Strict TypeScript' }],
multiSelect: false,
},
];
const onSubmit = vi.fn();
const { stdin } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
// Answer only first question
writeKey(stdin, '\r');
// Navigate to Review tab
writeKey(stdin, '\x1b[C');
// Submit
writeKey(stdin, '\r');
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' });
});
});
describe('Text type questions', () => {
it('renders text input for type: "text"', () => {
const textQuestion: Question[] = [
{
question: 'What should we name this component?',
header: 'Name',
type: QuestionType.TEXT,
placeholder: 'e.g., UserProfileCard',
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('shows default placeholder when none provided', () => {
const textQuestion: Question[] = [
{
question: 'Enter the database connection string:',
header: 'Database',
type: QuestionType.TEXT,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('supports backspace in text mode', async () => {
const textQuestion: Question[] = [
{
question: 'Enter the function name:',
header: 'Function',
type: QuestionType.TEXT,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
for (const char of 'abc') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toContain('abc');
});
writeKey(stdin, '\x7f'); // Backspace
await waitFor(() => {
expect(lastFrame()).toContain('ab');
expect(lastFrame()).not.toContain('abc');
});
});
it('shows correct keyboard hints for text type', () => {
const textQuestion: Question[] = [
{
question: 'Enter the variable name:',
header: 'Variable',
type: QuestionType.TEXT,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('preserves text answer when navigating between questions', async () => {
const mixedQuestions: Question[] = [
{
question: 'What should we name this hook?',
header: 'Hook',
type: QuestionType.TEXT,
},
{
question: 'Should it be async?',
header: 'Async',
options: [
{ label: 'Yes', description: 'Use async/await' },
{ label: 'No', description: 'Synchronous hook' },
],
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={mixedQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
for (const char of 'useAuth') {
writeKey(stdin, char);
}
writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active
await waitFor(() => {
expect(lastFrame()).toContain('Should it be async?');
});
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
// Wait, Async question is a CHOICE question, so Left arrow SHOULD work.
// But ChoiceQuestionView also captures editing custom option state?
// No, only if it is FOCUSING the custom option.
await waitFor(() => {
expect(lastFrame()).toContain('useAuth');
});
});
it('handles mixed text and choice questions', async () => {
const mixedQuestions: Question[] = [
{
question: 'What should we name this component?',
header: 'Name',
type: QuestionType.TEXT,
placeholder: 'Enter component name',
},
{
question: 'Which styling approach?',
header: 'Style',
options: [
{ label: 'CSS Modules', description: 'Scoped CSS' },
{ label: 'Tailwind', description: 'Utility classes' },
],
multiSelect: false,
},
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={mixedQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
for (const char of 'DataTable') {
writeKey(stdin, char);
}
writeKey(stdin, '\r');
await waitFor(() => {
expect(lastFrame()).toContain('Which styling approach?');
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(lastFrame()).toContain('Review your answers:');
expect(lastFrame()).toContain('Name');
expect(lastFrame()).toContain('DataTable');
expect(lastFrame()).toContain('Style');
expect(lastFrame()).toContain('CSS Modules');
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
'0': 'DataTable',
'1': 'CSS Modules',
});
});
});
it('does not submit empty text', () => {
const textQuestion: Question[] = [
{
question: 'Enter the class name:',
header: 'Class',
type: QuestionType.TEXT,
},
];
const onSubmit = vi.fn();
const { stdin } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
writeKey(stdin, '\r');
// onSubmit should not be called for empty text
expect(onSubmit).not.toHaveBeenCalled();
});
it('clears text on Ctrl+C', async () => {
const textQuestion: Question[] = [
{
question: 'Enter the class name:',
header: 'Class',
type: QuestionType.TEXT,
},
];
const onCancel = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={onCancel}
/>,
);
for (const char of 'SomeText') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toContain('SomeText');
});
// Send Ctrl+C
writeKey(stdin, '\x03'); // Ctrl+C
await waitFor(() => {
// Text should be cleared
expect(lastFrame()).not.toContain('SomeText');
expect(lastFrame()).toContain('>');
});
// Should NOT call onCancel (dialog should stay open)
expect(onCancel).not.toHaveBeenCalled();
});
it('allows immediate arrow navigation after switching away from text input', async () => {
const multiQuestions: Question[] = [
{
question: 'Choice Q?',
header: 'Choice',
options: [{ label: 'Option 1', description: '' }],
multiSelect: false,
},
{
question: 'Text Q?',
header: 'Text',
type: QuestionType.TEXT,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
// 1. Move to Text Q (Right arrow works for Choice Q)
writeKey(stdin, '\x1b[C');
await waitFor(() => {
expect(lastFrame()).toContain('Text Q?');
});
// 2. Type something in Text Q to make isEditingCustomOption true
writeKey(stdin, 'a');
await waitFor(() => {
expect(lastFrame()).toContain('a');
});
// 3. Move back to Choice Q (Left arrow works because cursor is at left edge)
// When typing 'a', cursor is at index 1.
// We need to move cursor to index 0 first for Left arrow to work for navigation.
writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0
await waitFor(() => {
expect(lastFrame()).toContain('Text Q?');
});
writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation
await waitFor(() => {
expect(lastFrame()).toContain('Choice Q?');
});
// 4. Immediately try Right arrow to go back to Text Q
writeKey(stdin, '\x1b[C');
await waitFor(() => {
expect(lastFrame()).toContain('Text Q?');
});
});
it('handles rapid sequential answers correctly (stale closure protection)', async () => {
const multiQuestions: Question[] = [
{
question: 'Question 1?',
header: 'Q1',
options: [{ label: 'A1', description: '' }],
multiSelect: false,
},
{
question: 'Question 2?',
header: 'Q2',
options: [{ label: 'A2', description: '' }],
multiSelect: false,
},
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
// Answer Q1 and Q2 sequentialy
act(() => {
stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance
});
await waitFor(() => {
expect(lastFrame()).toContain('Question 2?');
});
act(() => {
stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review
});
await waitFor(() => {
expect(lastFrame()).toContain('Review your answers:');
});
act(() => {
stdin.write('\r'); // Submit from Review
});
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
'0': 'A1',
'1': 'A2',
});
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ What should we name this component? │
│ │
│ > e.g., UserProfileCard │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the variable name: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the database connection string: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Tests │ □ Docs │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ Tests → (not answered) │
│ Docs → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > hides progress header for single question 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > renders question and options 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Framework │ □ Styling │ ≡ Review → │
│ │
│ Which framework? │
│ │
│ ● 1. React │
│ Component library │
│ 2. Vue │
│ Progressive framework │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > shows keyboard hints 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
│ 2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Database │ □ ORM │ ≡ Review → │
│ │
│ Which database should we use? │
│ │
│ ● 1. PostgreSQL │
│ Relational database │
│ 2. MongoDB │
│ Document database │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ License │ □ README │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ License → (not answered) │
│ README → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -31,6 +31,7 @@ export interface BaseSelectionListProps<
showScrollArrows?: boolean;
maxItemsToShow?: number;
wrapAround?: boolean;
focusKey?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -61,6 +62,7 @@ export function BaseSelectionList<
showScrollArrows = false,
maxItemsToShow = 10,
wrapAround = true,
focusKey,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -71,6 +73,7 @@ export function BaseSelectionList<
isFocused,
showNumbers,
wrapAround,
focusKey,
});
const [scrollOffset, setScrollOffset] = useState(0);
@@ -143,7 +146,7 @@ export function BaseSelectionList<
</Box>
{/* Item number */}
{showNumbers && (
{showNumbers && !item.hideNumber && (
<Box
marginRight={1}
flexShrink={0}

View File

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { TabHeader, type Tab } from './TabHeader.js';
const MOCK_TABS: Tab[] = [
{ key: '0', header: 'Tab 1' },
{ key: '1', header: 'Tab 2' },
{ key: '2', header: 'Tab 3' },
];
describe('TabHeader', () => {
describe('rendering', () => {
it('renders null for single tab', () => {
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={[{ key: '0', header: 'Only Tab' }]}
currentIndex={0}
/>,
);
expect(lastFrame()).toBe('');
});
it('renders all tab headers', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
expect(frame).toContain('Tab 1');
expect(frame).toContain('Tab 2');
expect(frame).toContain('Tab 3');
});
it('renders separators between tabs', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
// Should have 2 separators for 3 tabs
const separatorCount = (frame?.match(/│/g) || []).length;
expect(separatorCount).toBe(2);
});
});
describe('arrows', () => {
it('shows arrows by default', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
expect(frame).toContain('←');
expect(frame).toContain('→');
});
it('hides arrows when showArrows is false', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} showArrows={false} />,
);
const frame = lastFrame();
expect(frame).not.toContain('←');
expect(frame).not.toContain('→');
});
});
describe('status icons', () => {
it('shows status icons by default', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
const frame = lastFrame();
// Default uncompleted icon is □
expect(frame).toContain('□');
});
it('hides status icons when showStatusIcons is false', () => {
const { lastFrame } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} showStatusIcons={false} />,
);
const frame = lastFrame();
expect(frame).not.toContain('□');
expect(frame).not.toContain('✓');
});
it('shows checkmark for completed tabs', () => {
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
completedIndices={new Set([0, 2])}
/>,
);
const frame = lastFrame();
// Should have 2 checkmarks and 1 box
const checkmarkCount = (frame?.match(/✓/g) || []).length;
const boxCount = (frame?.match(/□/g) || []).length;
expect(checkmarkCount).toBe(2);
expect(boxCount).toBe(1);
});
it('shows special icon for special tabs', () => {
const tabsWithSpecial: Tab[] = [
{ key: '0', header: 'Tab 1' },
{ key: '1', header: 'Review', isSpecial: true },
];
const { lastFrame } = renderWithProviders(
<TabHeader tabs={tabsWithSpecial} currentIndex={0} />,
);
const frame = lastFrame();
// Special tab shows ≡ icon
expect(frame).toContain('≡');
});
it('uses tab statusIcon when provided', () => {
const tabsWithCustomIcon: Tab[] = [
{ key: '0', header: 'Tab 1', statusIcon: '★' },
{ key: '1', header: 'Tab 2' },
];
const { lastFrame } = renderWithProviders(
<TabHeader tabs={tabsWithCustomIcon} currentIndex={0} />,
);
const frame = lastFrame();
expect(frame).toContain('★');
});
it('uses custom renderStatusIcon when provided', () => {
const renderStatusIcon = () => '•';
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
renderStatusIcon={renderStatusIcon}
/>,
);
const frame = lastFrame();
const bulletCount = (frame?.match(/•/g) || []).length;
expect(bulletCount).toBe(3);
});
it('falls back to default when renderStatusIcon returns undefined', () => {
const renderStatusIcon = () => undefined;
const { lastFrame } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
renderStatusIcon={renderStatusIcon}
/>,
);
const frame = lastFrame();
expect(frame).toContain('□');
});
});
});

View File

@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
/**
* Represents a single tab in the TabHeader.
*/
export interface Tab {
/** Unique identifier for this tab */
key: string;
/** Header text displayed in the tab indicator */
header: string;
/** Optional custom status icon for this tab */
statusIcon?: string;
/** Whether this is a special tab (like "Review") - uses different default icon */
isSpecial?: boolean;
}
/**
* Props for the TabHeader component.
*/
export interface TabHeaderProps {
/** Array of tab definitions */
tabs: Tab[];
/** Currently active tab index */
currentIndex: number;
/** Set of indices for tabs that show a completion indicator */
completedIndices?: Set<number>;
/** Show navigation arrow hints on sides (default: true) */
showArrows?: boolean;
/** Show status icons (checkmark/box) before tab headers (default: true) */
showStatusIcons?: boolean;
/**
* Custom status icon renderer. Return undefined to use default icons.
* Default icons: '✓' for completed, '□' for incomplete, '≡' for special tabs
*/
renderStatusIcon?: (
tab: Tab,
index: number,
isCompleted: boolean,
) => string | undefined;
}
/**
* A header component that displays tab indicators for multi-tab interfaces.
*
* Renders in the format: `← Tab1 │ Tab2 │ Tab3 →`
*
* Features:
* - Shows completion status (✓ or □) per tab
* - Highlights current tab with accent color
* - Supports special tabs (like "Review") with different icons
* - Customizable status icons
*/
export function TabHeader({
tabs,
currentIndex,
completedIndices = new Set(),
showArrows = true,
showStatusIcons = true,
renderStatusIcon,
}: TabHeaderProps): React.JSX.Element | null {
if (tabs.length <= 1) return null;
const getStatusIcon = (tab: Tab, index: number): string => {
const isCompleted = completedIndices.has(index);
// Try custom renderer first
if (renderStatusIcon) {
const customIcon = renderStatusIcon(tab, index, isCompleted);
if (customIcon !== undefined) return customIcon;
}
// Use tab's own icon if provided
if (tab.statusIcon) return tab.statusIcon;
// Default icons
if (tab.isSpecial) return '\u2261'; // ≡
return isCompleted ? '\u2713' : '\u25A1'; // ✓ or □
};
return (
<Box flexDirection="row" marginBottom={1}>
{showArrows && <Text color={theme.text.secondary}>{'\u2190 '}</Text>}
{tabs.map((tab, i) => (
<React.Fragment key={tab.key}>
{i > 0 && <Text color={theme.text.secondary}>{' \u2502 '}</Text>}
{showStatusIcons && (
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
)}
<Text
color={
i === currentIndex ? theme.text.accent : theme.text.secondary
}
bold={i === currentIndex}
>
{tab.header}
</Text>
</React.Fragment>
))}
{showArrows && <Text color={theme.text.secondary}>{' \u2192'}</Text>}
</Box>
);
}