From 2c0cc7b9a534239a4cf897e50941bb46c4d7079a Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 23 Jan 2026 15:42:48 -0500 Subject: [PATCH] feat: add AskUserDialog for UI component of AskUser tool (#17344) Co-authored-by: jacob314 --- .../cli/examples/ask-user-dialog-demo.tsx | 102 ++ .../src/ui/components/AskUserDialog.test.tsx | 855 +++++++++++++ .../cli/src/ui/components/AskUserDialog.tsx | 1105 +++++++++++++++++ .../__snapshots__/AskUserDialog.test.tsx.snap | 138 ++ .../components/shared/BaseSelectionList.tsx | 5 +- .../ui/components/shared/TabHeader.test.tsx | 157 +++ .../src/ui/components/shared/TabHeader.tsx | 110 ++ packages/cli/src/ui/hooks/useSelectionList.ts | 22 + .../src/ui/hooks/useTabbedNavigation.test.ts | 276 ++++ .../cli/src/ui/hooks/useTabbedNavigation.ts | 240 ++++ 10 files changed, 3009 insertions(+), 1 deletion(-) create mode 100644 packages/cli/examples/ask-user-dialog-demo.tsx create mode 100644 packages/cli/src/ui/components/AskUserDialog.test.tsx create mode 100644 packages/cli/src/ui/components/AskUserDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/shared/TabHeader.test.tsx create mode 100644 packages/cli/src/ui/components/shared/TabHeader.tsx create mode 100644 packages/cli/src/ui/hooks/useTabbedNavigation.test.ts create mode 100644 packages/cli/src/ui/hooks/useTabbedNavigation.ts diff --git a/packages/cli/examples/ask-user-dialog-demo.tsx b/packages/cli/examples/ask-user-dialog-demo.tsx new file mode 100644 index 0000000000..aeb22b30f0 --- /dev/null +++ b/packages/cli/examples/ask-user-dialog-demo.tsx @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { render, Box, Text } from 'ink'; +import { AskUserDialog } from '../src/ui/components/AskUserDialog.js'; +import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; +import { QuestionType, type Question } from '@google/gemini-cli-core'; + +const DEMO_QUESTIONS: Question[] = [ + { + question: 'What type of project are you building?', + header: 'Project Type', + options: [ + { label: 'Web Application', description: 'React, Next.js, or similar' }, + { label: 'CLI Tool', description: 'Command-line interface with Node.js' }, + { label: 'Library', description: 'NPM package or shared utility' }, + ], + multiSelect: false, + }, + { + question: 'Which features should be enabled?', + header: 'Features', + options: [ + { label: 'TypeScript', description: 'Add static typing' }, + { label: 'ESLint', description: 'Add linting and formatting' }, + { label: 'Unit Tests', description: 'Add Vitest setup' }, + { label: 'CI/CD', description: 'Add GitHub Actions' }, + ], + multiSelect: true, + }, + { + question: 'What is the project name?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'my-awesome-project', + }, + { + question: 'Initialize git repository?', + header: 'Git', + type: QuestionType.YESNO, + }, +]; + +const Demo = () => { + const [result, setResult] = useState(null); + const [cancelled, setCancelled] = useState(false); + + if (cancelled) { + return ( + + + Dialog was cancelled. Project initialization aborted. + + + ); + } + + if (result) { + return ( + + + Success! Project Configuration: + + {DEMO_QUESTIONS.map((q, i) => ( + + {q.header}: + {result[i] || '(not answered)'} + + ))} + + Press Ctrl+C to exit + + + ); + } + + return ( + + + + AskUserDialog Demo + + setCancelled(true)} + /> + + + ); +}; + +render(); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx new file mode 100644 index 0000000000..bf9838b777 --- /dev/null +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('hides progress header for single question', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('shows keyboard hints', () => { + const { lastFrame } = renderWithProviders( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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', + }); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx new file mode 100644 index 0000000000..924d869604 --- /dev/null +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -0,0 +1,1105 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + useCallback, + useMemo, + useRef, + useEffect, + useReducer, + useContext, +} from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { theme } from '../semantic-colors.js'; +import type { Question } from '@google/gemini-cli-core'; +import { BaseSelectionList } from './shared/BaseSelectionList.js'; +import type { SelectionListItem } from '../hooks/useSelectionList.js'; +import { TabHeader, type Tab } from './shared/TabHeader.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { checkExhaustive } from '../../utils/checks.js'; +import { TextInput } from './shared/TextInput.js'; +import { useTextBuffer } from './shared/text-buffer.js'; +import { UIStateContext } from '../contexts/UIStateContext.js'; +import { cpLen } from '../utils/textUtils.js'; + +interface AskUserDialogState { + currentQuestionIndex: number; + answers: { [key: string]: string }; + isEditingCustomOption: boolean; + cursorEdge: { left: boolean; right: boolean }; + submitted: boolean; +} + +type AskUserDialogAction = + | { + type: 'NEXT_QUESTION'; + payload: { maxIndex: number }; + } + | { type: 'PREV_QUESTION' } + | { + type: 'SET_ANSWER'; + payload: { + index?: number; + answer: string; + autoAdvance?: boolean; + maxIndex?: number; + }; + } + | { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } } + | { type: 'SET_CURSOR_EDGE'; payload: { left: boolean; right: boolean } } + | { type: 'SUBMIT' }; + +const initialState: AskUserDialogState = { + currentQuestionIndex: 0, + answers: {}, + isEditingCustomOption: false, + cursorEdge: { left: true, right: true }, + submitted: false, +}; + +function askUserDialogReducerLogic( + state: AskUserDialogState, + action: AskUserDialogAction, +): AskUserDialogState { + if (state.submitted) { + return state; + } + + switch (action.type) { + case 'NEXT_QUESTION': { + const { maxIndex } = action.payload; + if (state.currentQuestionIndex < maxIndex) { + return { + ...state, + currentQuestionIndex: state.currentQuestionIndex + 1, + isEditingCustomOption: false, + cursorEdge: { left: true, right: true }, + }; + } + return state; + } + case 'PREV_QUESTION': { + if (state.currentQuestionIndex > 0) { + return { + ...state, + currentQuestionIndex: state.currentQuestionIndex - 1, + isEditingCustomOption: false, + cursorEdge: { left: true, right: true }, + }; + } + return state; + } + case 'SET_ANSWER': { + const { index, answer, autoAdvance, maxIndex } = action.payload; + const targetIndex = index ?? state.currentQuestionIndex; + const hasAnswer = + answer !== undefined && answer !== null && answer.trim() !== ''; + const newAnswers = { ...state.answers }; + + if (hasAnswer) { + newAnswers[targetIndex] = answer; + } else { + delete newAnswers[targetIndex]; + } + + const newState = { + ...state, + answers: newAnswers, + }; + + if (autoAdvance && typeof maxIndex === 'number') { + if (newState.currentQuestionIndex < maxIndex) { + newState.currentQuestionIndex += 1; + newState.isEditingCustomOption = false; + newState.cursorEdge = { left: true, right: true }; + } + } + + return newState; + } + case 'SET_EDITING_CUSTOM': { + if (state.isEditingCustomOption === action.payload.isEditing) { + return state; + } + return { + ...state, + isEditingCustomOption: action.payload.isEditing, + }; + } + case 'SET_CURSOR_EDGE': { + const { left, right } = action.payload; + if (state.cursorEdge.left === left && state.cursorEdge.right === right) { + return state; + } + return { + ...state, + cursorEdge: { left, right }, + }; + } + case 'SUBMIT': { + return { + ...state, + submitted: true, + }; + } + default: + checkExhaustive(action); + return state; + } +} + +/** + * Props for the AskUserDialog component. + */ +interface AskUserDialogProps { + /** + * The list of questions to ask the user. + */ + questions: Question[]; + /** + * Callback fired when the user submits their answers. + * Returns a map of question index to answer string. + */ + onSubmit: (answers: { [questionIndex: string]: string }) => void; + /** + * Callback fired when the user cancels the dialog (e.g. via Escape). + */ + onCancel: () => void; + /** + * Optional callback to notify parent when text input is active. + * Useful for managing global keypress handlers. + */ + onActiveTextInputChange?: (active: boolean) => void; +} + +interface ReviewViewProps { + questions: Question[]; + answers: { [key: string]: string }; + onSubmit: () => void; + progressHeader?: React.ReactNode; +} + +const ReviewView: React.FC = ({ + questions, + answers, + onSubmit, + progressHeader, +}) => { + const unansweredCount = questions.length - Object.keys(answers).length; + const hasUnanswered = unansweredCount > 0; + + // Handle Enter to submit + useKeypress( + (key: Key) => { + if (keyMatchers[Command.RETURN](key)) { + onSubmit(); + } + }, + { isActive: true }, + ); + + return ( + + {progressHeader} + + + Review your answers: + + + + {hasUnanswered && ( + + + ⚠ You have {unansweredCount} unanswered question + {unansweredCount > 1 ? 's' : ''} + + + )} + + {questions.map((q, i) => ( + + {q.header} + + + {answers[i] || '(not answered)'} + + + ))} + + + Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel + + + + ); +}; + +// ============== Text Question View ============== + +interface TextQuestionViewProps { + question: Question; + onAnswer: (answer: string) => void; + onSelectionChange?: (answer: string) => void; + onEditingCustomOption?: (editing: boolean) => void; + onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void; + initialAnswer?: string; + progressHeader?: React.ReactNode; + keyboardHints?: React.ReactNode; +} + +const TextQuestionView: React.FC = ({ + question, + onAnswer, + onSelectionChange, + onEditingCustomOption, + onCursorEdgeChange, + initialAnswer, + progressHeader, + keyboardHints, +}) => { + const uiState = useContext(UIStateContext); + const { stdout } = useStdout(); + const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80; + + const buffer = useTextBuffer({ + initialText: initialAnswer, + viewport: { width: terminalWidth - 10, height: 1 }, + singleLine: true, + isValidPath: () => false, + }); + + const { text: textValue } = buffer; + + // Sync state change with parent - only when it actually changes + const lastTextValueRef = useRef(textValue); + useEffect(() => { + if (textValue !== lastTextValueRef.current) { + onSelectionChange?.(textValue); + lastTextValueRef.current = textValue; + } + }, [textValue, onSelectionChange]); + + // Sync cursor edge state with parent - only when it actually changes + const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null); + useEffect(() => { + const isLeft = buffer.cursor[1] === 0; + const isRight = buffer.cursor[1] === cpLen(buffer.lines[0] || ''); + if ( + !lastEdgeRef.current || + isLeft !== lastEdgeRef.current.left || + isRight !== lastEdgeRef.current.right + ) { + onCursorEdgeChange?.({ left: isLeft, right: isRight }); + lastEdgeRef.current = { left: isLeft, right: isRight }; + } + }, [buffer.cursor, buffer.lines, onCursorEdgeChange]); + + // Handle Ctrl+C to clear all text + const handleExtraKeys = useCallback( + (key: Key) => { + if (keyMatchers[Command.QUIT](key)) { + buffer.setText(''); + } + }, + [buffer], + ); + + useKeypress(handleExtraKeys, { isActive: true }); + + const handleSubmit = useCallback( + (val: string) => { + if (val.trim()) { + onAnswer(val.trim()); + } + }, + [onAnswer], + ); + + // Notify parent that we're in text input mode (for Ctrl+C handling) + useEffect(() => { + onEditingCustomOption?.(true); + return () => { + onEditingCustomOption?.(false); + }; + }, [onEditingCustomOption]); + + const placeholder = question.placeholder || 'Enter your response'; + + return ( + + {progressHeader} + + + {question.question} + + + + + {'> '} + + + + {keyboardHints} + + ); +}; + +// ============== Choice Question View ============== + +interface OptionItem { + key: string; + label: string; + description: string; + type: 'option' | 'other' | 'done'; + index: number; +} + +interface ChoiceQuestionState { + selectedIndices: Set; + isCustomOptionSelected: boolean; + isCustomOptionFocused: boolean; +} + +type ChoiceQuestionAction = + | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } + | { + type: 'SET_CUSTOM_SELECTED'; + payload: { selected: boolean; multiSelect: boolean }; + } + | { type: 'TOGGLE_CUSTOM_SELECTED'; payload: { multiSelect: boolean } } + | { type: 'SET_CUSTOM_FOCUSED'; payload: { focused: boolean } }; + +function choiceQuestionReducer( + state: ChoiceQuestionState, + action: ChoiceQuestionAction, +): ChoiceQuestionState { + switch (action.type) { + case 'TOGGLE_INDEX': { + const { index, multiSelect } = action.payload; + const newIndices = new Set(multiSelect ? state.selectedIndices : []); + if (newIndices.has(index)) { + newIndices.delete(index); + } else { + newIndices.add(index); + } + return { + ...state, + selectedIndices: newIndices, + // In single select, selecting an option deselects custom + isCustomOptionSelected: multiSelect + ? state.isCustomOptionSelected + : false, + }; + } + case 'SET_CUSTOM_SELECTED': { + const { selected, multiSelect } = action.payload; + return { + ...state, + isCustomOptionSelected: selected, + // In single-select, selecting custom deselects others + selectedIndices: multiSelect ? state.selectedIndices : new Set(), + }; + } + case 'TOGGLE_CUSTOM_SELECTED': { + const { multiSelect } = action.payload; + if (!multiSelect) return state; + + return { + ...state, + isCustomOptionSelected: !state.isCustomOptionSelected, + }; + } + case 'SET_CUSTOM_FOCUSED': { + return { + ...state, + isCustomOptionFocused: action.payload.focused, + }; + } + default: + checkExhaustive(action); + return state; + } +} + +interface ChoiceQuestionViewProps { + question: Question; + onAnswer: (answer: string) => void; + onSelectionChange?: (answer: string) => void; + onEditingCustomOption?: (editing: boolean) => void; + onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void; + initialAnswer?: string; + progressHeader?: React.ReactNode; + keyboardHints?: React.ReactNode; +} + +const ChoiceQuestionView: React.FC = ({ + question, + onAnswer, + onSelectionChange, + onEditingCustomOption, + onCursorEdgeChange, + initialAnswer, + progressHeader, + keyboardHints, +}) => { + const uiState = useContext(UIStateContext); + const { stdout } = useStdout(); + const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80; + + const questionOptions = useMemo( + () => question.options ?? [], + [question.options], + ); + + // Initialize state from initialAnswer if returning to a previously answered question + const initialReducerState = useMemo((): ChoiceQuestionState => { + if (!initialAnswer) { + return { + selectedIndices: new Set(), + isCustomOptionSelected: false, + isCustomOptionFocused: false, + }; + } + + // Check if initialAnswer matches any option labels + const selectedIndices = new Set(); + let isCustomOptionSelected = false; + + if (question.multiSelect) { + const answers = initialAnswer.split(', '); + answers.forEach((answer) => { + const index = questionOptions.findIndex((opt) => opt.label === answer); + if (index !== -1) { + selectedIndices.add(index); + } else { + isCustomOptionSelected = true; + } + }); + } else { + const index = questionOptions.findIndex( + (opt) => opt.label === initialAnswer, + ); + if (index !== -1) { + selectedIndices.add(index); + } else { + isCustomOptionSelected = true; + } + } + + return { + selectedIndices, + isCustomOptionSelected, + isCustomOptionFocused: false, + }; + }, [initialAnswer, questionOptions, question.multiSelect]); + + const [state, dispatch] = useReducer( + choiceQuestionReducer, + initialReducerState, + ); + const { selectedIndices, isCustomOptionSelected, isCustomOptionFocused } = + state; + + const initialCustomText = useMemo(() => { + if (!initialAnswer) return ''; + if (question.multiSelect) { + const answers = initialAnswer.split(', '); + const custom = answers.find( + (a) => !questionOptions.some((opt) => opt.label === a), + ); + return custom || ''; + } else { + const isPredefined = questionOptions.some( + (opt) => opt.label === initialAnswer, + ); + return isPredefined ? '' : initialAnswer; + } + }, [initialAnswer, questionOptions, question.multiSelect]); + + const customBuffer = useTextBuffer({ + initialText: initialCustomText, + viewport: { width: terminalWidth - 20, height: 1 }, + singleLine: true, + isValidPath: () => false, + }); + + const customOptionText = customBuffer.text; + + // Sync cursor edge state with parent - only when it actually changes + const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null); + useEffect(() => { + const isLeft = customBuffer.cursor[1] === 0; + const isRight = + customBuffer.cursor[1] === cpLen(customBuffer.lines[0] || ''); + if ( + !lastEdgeRef.current || + isLeft !== lastEdgeRef.current.left || + isRight !== lastEdgeRef.current.right + ) { + onCursorEdgeChange?.({ left: isLeft, right: isRight }); + lastEdgeRef.current = { left: isLeft, right: isRight }; + } + }, [customBuffer.cursor, customBuffer.lines, onCursorEdgeChange]); + + // Helper to build answer string from selections + const buildAnswerString = useCallback( + ( + indices: Set, + includeCustomOption: boolean, + customOption: string, + ) => { + const answers: string[] = []; + questionOptions.forEach((opt, i) => { + if (indices.has(i)) { + answers.push(opt.label); + } + }); + if (includeCustomOption && customOption.trim()) { + answers.push(customOption.trim()); + } + return answers.join(', '); + }, + [questionOptions], + ); + + // Synchronize selection changes with parent - only when it actually changes + const lastBuiltAnswerRef = useRef(''); + useEffect(() => { + const newAnswer = buildAnswerString( + selectedIndices, + isCustomOptionSelected, + customOptionText, + ); + if (newAnswer !== lastBuiltAnswerRef.current) { + onSelectionChange?.(newAnswer); + lastBuiltAnswerRef.current = newAnswer; + } + }, [ + selectedIndices, + isCustomOptionSelected, + customOptionText, + buildAnswerString, + onSelectionChange, + ]); + + // Handle "Type-to-Jump" and Ctrl+C for custom buffer + const handleExtraKeys = useCallback( + (key: Key) => { + // If focusing custom option, handle Ctrl+C + if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) { + customBuffer.setText(''); + return; + } + + // Type-to-jump: if a printable character is typed and not focused, jump to custom + const isPrintable = + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.alt && + key.sequence.charCodeAt(0) >= 32; + + const isNumber = /^[0-9]$/.test(key.sequence); + + if (isPrintable && !isCustomOptionFocused && !isNumber) { + dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } }); + onEditingCustomOption?.(true); + // We can't easily inject the first key into useTextBuffer's internal state + // but TextInput will handle subsequent keys once it's focused. + customBuffer.setText(key.sequence); + } + }, + [isCustomOptionFocused, customBuffer, onEditingCustomOption], + ); + + useKeypress(handleExtraKeys, { isActive: true }); + + const selectionItems = useMemo((): Array> => { + const list: Array> = questionOptions.map( + (opt, i) => { + const item: OptionItem = { + key: `opt-${i}`, + label: opt.label, + description: opt.description, + type: 'option', + index: i, + }; + return { key: item.key, value: item }; + }, + ); + + // Only add custom option for choice type, not yesno + if (question.type !== 'yesno') { + const otherItem: OptionItem = { + key: 'other', + label: customOptionText || '', + description: '', + type: 'other', + index: list.length, + }; + list.push({ key: 'other', value: otherItem }); + } + + if (question.multiSelect) { + const doneItem: OptionItem = { + key: 'done', + label: 'Done', + description: 'Finish selection', + type: 'done', + index: list.length, + }; + list.push({ key: doneItem.key, value: doneItem, hideNumber: true }); + } + + return list; + }, [questionOptions, question.multiSelect, question.type, customOptionText]); + + const handleHighlight = useCallback( + (itemValue: OptionItem) => { + const nowFocusingCustomOption = itemValue.type === 'other'; + dispatch({ + type: 'SET_CUSTOM_FOCUSED', + payload: { focused: nowFocusingCustomOption }, + }); + // Notify parent when we start/stop focusing custom option (so navigation can resume) + onEditingCustomOption?.(nowFocusingCustomOption); + }, + [onEditingCustomOption], + ); + + const handleSelect = useCallback( + (itemValue: OptionItem) => { + if (question.multiSelect) { + if (itemValue.type === 'option') { + dispatch({ + type: 'TOGGLE_INDEX', + payload: { index: itemValue.index, multiSelect: true }, + }); + } else if (itemValue.type === 'other') { + dispatch({ + type: 'TOGGLE_CUSTOM_SELECTED', + payload: { multiSelect: true }, + }); + } else if (itemValue.type === 'done') { + // Done just triggers navigation, selections already saved via useEffect + onAnswer( + buildAnswerString( + selectedIndices, + isCustomOptionSelected, + customOptionText, + ), + ); + } + } else { + if (itemValue.type === 'option') { + onAnswer(itemValue.label); + } else if (itemValue.type === 'other') { + // In single select, selecting other submits it if it has text + if (customOptionText.trim()) { + onAnswer(customOptionText.trim()); + } + } + } + }, + [ + question.multiSelect, + selectedIndices, + isCustomOptionSelected, + customOptionText, + onAnswer, + buildAnswerString, + ], + ); + + // Auto-select custom option when typing in it + useEffect(() => { + if (customOptionText.trim() && !isCustomOptionSelected) { + dispatch({ + type: 'SET_CUSTOM_SELECTED', + payload: { selected: true, multiSelect: !!question.multiSelect }, + }); + } + }, [customOptionText, isCustomOptionSelected, question.multiSelect]); + + return ( + + {progressHeader} + + + {question.question} + + + {question.multiSelect && ( + + {' '} + (Select all that apply) + + )} + + + items={selectionItems} + onSelect={handleSelect} + onHighlight={handleHighlight} + focusKey={isCustomOptionFocused ? 'other' : undefined} + renderItem={(item, context) => { + const optionItem = item.value; + const isChecked = + selectedIndices.has(optionItem.index) || + (optionItem.type === 'other' && isCustomOptionSelected); + const showCheck = + question.multiSelect && + (optionItem.type === 'option' || optionItem.type === 'other'); + + // Render inline text input for custom option + if (optionItem.type === 'other') { + const placeholder = 'Enter a custom value'; + return ( + + {showCheck && ( + + [{isChecked ? 'x' : ' '}] + + )} + + handleSelect(optionItem)} + /> + {isChecked && !question.multiSelect && ( + + )} + + ); + } + + // Determine label color: checked (previously answered) uses success, selected uses accent, else primary + const labelColor = + isChecked && !question.multiSelect + ? theme.status.success + : context.isSelected + ? context.titleColor + : theme.text.primary; + + return ( + + + {showCheck && ( + + [{isChecked ? 'x' : ' '}] + + )} + + {' '} + {optionItem.label} + + {isChecked && !question.multiSelect && ( + + )} + + {optionItem.description && ( + + {' '} + {optionItem.description} + + )} + + ); + }} + /> + {keyboardHints} + + ); +}; + +/** + * A dialog component for asking the user a series of questions. + * Supports multiple question types (text, choice, yes/no, multi-select), + * navigation between questions, and a final review step. + */ +export const AskUserDialog: React.FC = ({ + questions, + onSubmit, + onCancel, + onActiveTextInputChange, +}) => { + const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); + const { + currentQuestionIndex, + answers, + isEditingCustomOption, + cursorEdge, + submitted, + } = state; + + // Use refs for synchronous checks to prevent race conditions in handleCancel + const isEditingCustomOptionRef = useRef(false); + isEditingCustomOptionRef.current = isEditingCustomOption; + + const handleEditingCustomOption = useCallback((isEditing: boolean) => { + dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } }); + }, []); + + const handleCursorEdgeChange = useCallback( + (edge: { left: boolean; right: boolean }) => { + dispatch({ type: 'SET_CURSOR_EDGE', payload: edge }); + }, + [], + ); + + // Sync isEditingCustomOption state with parent for global keypress handling + useEffect(() => { + onActiveTextInputChange?.(isEditingCustomOption); + return () => { + onActiveTextInputChange?.(false); + }; + }, [isEditingCustomOption, onActiveTextInputChange]); + + // Handle Escape or Ctrl+C to cancel (but not Ctrl+C when editing custom option) + const handleCancel = useCallback( + (key: Key) => { + if (submitted) return; + if (keyMatchers[Command.ESCAPE](key)) { + onCancel(); + } else if ( + keyMatchers[Command.QUIT](key) && + !isEditingCustomOptionRef.current + ) { + onCancel(); + } + }, + [onCancel, submitted], + ); + + useKeypress(handleCancel, { + isActive: !submitted, + }); + + // Review tab is at index questions.length (after all questions) + const reviewTabIndex = questions.length; + const isOnReviewTab = currentQuestionIndex === reviewTabIndex; + + // Bidirectional navigation between questions using custom useKeypress for consistency + const handleNavigation = useCallback( + (key: Key) => { + if (submitted) return; + + const isTab = key.name === 'tab'; + const isShiftTab = isTab && key.shift; + const isPlainTab = isTab && !key.shift; + + const isRight = key.name === 'right' && !key.ctrl && !key.alt; + const isLeft = key.name === 'left' && !key.ctrl && !key.alt; + + // Tab always works. Arrows work if NOT editing OR if at the corresponding edge. + const shouldGoNext = + isPlainTab || (isRight && (!isEditingCustomOption || cursorEdge.right)); + const shouldGoPrev = + isShiftTab || (isLeft && (!isEditingCustomOption || cursorEdge.left)); + + if (shouldGoNext) { + // Allow navigation up to Review tab for multi-question flows + const maxIndex = + questions.length > 1 ? reviewTabIndex : questions.length - 1; + dispatch({ + type: 'NEXT_QUESTION', + payload: { maxIndex }, + }); + } else if (shouldGoPrev) { + dispatch({ + type: 'PREV_QUESTION', + }); + } + }, + [isEditingCustomOption, cursorEdge, questions, reviewTabIndex, submitted], + ); + + useKeypress(handleNavigation, { + isActive: questions.length > 1 && !submitted, + }); + + // Effect to trigger submission when state.submitted becomes true + useEffect(() => { + if (submitted) { + onSubmit(answers); + } + }, [submitted, answers, onSubmit]); + + const handleAnswer = useCallback( + (answer: string) => { + if (submitted) return; + + const reviewTabIndex = questions.length; + dispatch({ + type: 'SET_ANSWER', + payload: { + answer, + autoAdvance: questions.length > 1, + maxIndex: reviewTabIndex, + }, + }); + + if (questions.length === 1) { + dispatch({ type: 'SUBMIT' }); + } + }, + [questions.length, submitted], + ); + + // Submit from Review tab + const handleReviewSubmit = useCallback(() => { + if (submitted) return; + dispatch({ type: 'SUBMIT' }); + }, [submitted]); + + const handleSelectionChange = useCallback( + (answer: string) => { + if (submitted) return; + dispatch({ + type: 'SET_ANSWER', + payload: { + answer, + autoAdvance: false, + }, + }); + }, + [submitted], + ); + + const answeredIndices = useMemo( + () => new Set(Object.keys(answers).map(Number)), + [answers], + ); + + const currentQuestion = questions[currentQuestionIndex]; + + // For yesno type, generate Yes/No options and force single-select + const effectiveQuestion = useMemo(() => { + if (currentQuestion?.type === 'yesno') { + return { + ...currentQuestion, + options: [ + { label: 'Yes', description: '' }, + { label: 'No', description: '' }, + ], + multiSelect: false, + }; + } + return currentQuestion; + }, [currentQuestion]); + + // Build tabs array for TabHeader + const tabs = useMemo((): Tab[] => { + const questionTabs: Tab[] = questions.map((q, i) => ({ + key: String(i), + header: q.header, + })); + // Add review tab when there are multiple questions + if (questions.length > 1) { + questionTabs.push({ + key: 'review', + header: 'Review', + isSpecial: true, + }); + } + return questionTabs; + }, [questions]); + + const progressHeader = + questions.length > 1 ? ( + + ) : null; + + // Render Review tab when on it + if (isOnReviewTab) { + return ( + + ); + } + + // Safeguard for invalid question index + if (!currentQuestion) return null; + + const keyboardHints = ( + + + {currentQuestion.type === 'text' || isEditingCustomOption + ? questions.length > 1 + ? 'Enter to submit · Tab/Shift+Tab to switch questions · Esc to cancel' + : 'Enter to submit · Esc to cancel' + : questions.length > 1 + ? 'Enter to select · ←/→ to switch questions · Esc to cancel' + : 'Enter to select · ↑/↓ to navigate · Esc to cancel'} + + + ); + + // Render text-type or choice-type question view + if (currentQuestion.type === 'text') { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap new file mode 100644 index 0000000000..84f2c8676f --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -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 │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx index 2f2e36457a..dbe6d7b075 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx @@ -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): 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< {/* Item number */} - {showNumbers && ( + {showNumbers && !item.hideNumber && ( { + describe('rendering', () => { + it('renders null for single tab', () => { + const { lastFrame } = renderWithProviders( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('renders all tab headers', () => { + const { lastFrame } = renderWithProviders( + , + ); + 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( + , + ); + 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( + , + ); + const frame = lastFrame(); + expect(frame).toContain('←'); + expect(frame).toContain('→'); + }); + + it('hides arrows when showArrows is false', () => { + const { lastFrame } = renderWithProviders( + , + ); + const frame = lastFrame(); + expect(frame).not.toContain('←'); + expect(frame).not.toContain('→'); + }); + }); + + describe('status icons', () => { + it('shows status icons by default', () => { + const { lastFrame } = renderWithProviders( + , + ); + const frame = lastFrame(); + // Default uncompleted icon is □ + expect(frame).toContain('□'); + }); + + it('hides status icons when showStatusIcons is false', () => { + const { lastFrame } = renderWithProviders( + , + ); + const frame = lastFrame(); + expect(frame).not.toContain('□'); + expect(frame).not.toContain('✓'); + }); + + it('shows checkmark for completed tabs', () => { + const { lastFrame } = renderWithProviders( + , + ); + 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( + , + ); + 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( + , + ); + const frame = lastFrame(); + expect(frame).toContain('★'); + }); + + it('uses custom renderStatusIcon when provided', () => { + const renderStatusIcon = () => '•'; + const { lastFrame } = renderWithProviders( + , + ); + 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( + , + ); + const frame = lastFrame(); + expect(frame).toContain('□'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/shared/TabHeader.tsx b/packages/cli/src/ui/components/shared/TabHeader.tsx new file mode 100644 index 0000000000..c7fcbd7d81 --- /dev/null +++ b/packages/cli/src/ui/components/shared/TabHeader.tsx @@ -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; + /** 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 ( + + {showArrows && {'\u2190 '}} + {tabs.map((tab, i) => ( + + {i > 0 && {' \u2502 '}} + {showStatusIcons && ( + {getStatusIcon(tab, i)} + )} + + {tab.header} + + + ))} + {showArrows && {' \u2192'}} + + ); +} diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts index dea4015969..8e9f1ce357 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.ts @@ -13,6 +13,7 @@ export interface SelectionListItem { key: string; value: T; disabled?: boolean; + hideNumber?: boolean; } interface BaseSelectionItem { @@ -28,6 +29,7 @@ export interface UseSelectionListOptions { isFocused?: boolean; showNumbers?: boolean; wrapAround?: boolean; + focusKey?: string; } export interface UseSelectionListResult { @@ -285,6 +287,7 @@ export function useSelectionList({ isFocused = true, showNumbers = false, wrapAround = true, + focusKey, }: UseSelectionListOptions): UseSelectionListResult { const baseItems = toBaseItems(items); @@ -302,6 +305,25 @@ export function useSelectionList({ const prevBaseItemsRef = useRef(baseItems); const prevInitialIndexRef = useRef(initialIndex); const prevWrapAroundRef = useRef(wrapAround); + const lastProcessedFocusKeyRef = useRef(undefined); + + // Handle programmatic focus changes via focusKey + useEffect(() => { + if (focusKey === undefined) { + lastProcessedFocusKeyRef.current = undefined; + return; + } + + if (focusKey === lastProcessedFocusKeyRef.current) return; + + const index = items.findIndex( + (item) => item.key === focusKey && !item.disabled, + ); + if (index !== -1) { + lastProcessedFocusKeyRef.current = focusKey; + dispatch({ type: 'SET_ACTIVE_INDEX', payload: { index } }); + } + }, [focusKey, items]); // Initialize/synchronize state when initialIndex or items change useEffect(() => { diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts new file mode 100644 index 0000000000..351a4c08ae --- /dev/null +++ b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts @@ -0,0 +1,276 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useTabbedNavigation } from './useTabbedNavigation.js'; + +vi.mock('./useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../keyMatchers.js', () => ({ + keyMatchers: { + 'cursor.left': vi.fn((key) => key.name === 'left'), + 'cursor.right': vi.fn((key) => key.name === 'right'), + }, + Command: { + MOVE_LEFT: 'cursor.left', + MOVE_RIGHT: 'cursor.right', + }, +})); + +describe('useTabbedNavigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('returns initial index of 0 by default', () => { + const { result } = renderHook(() => useTabbedNavigation({ tabCount: 3 })); + expect(result.current.currentIndex).toBe(0); + }); + + it('returns specified initial index', () => { + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 2 }), + ); + expect(result.current.currentIndex).toBe(2); + }); + + it('clamps initial index to valid range', () => { + const { result: high } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 10 }), + ); + expect(high.current.currentIndex).toBe(2); + + const { result: negative } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: -1 }), + ); + expect(negative.current.currentIndex).toBe(0); + }); + }); + + describe('goToNextTab', () => { + it('advances to next tab', () => { + const { result } = renderHook(() => useTabbedNavigation({ tabCount: 3 })); + + act(() => { + result.current.goToNextTab(); + }); + + expect(result.current.currentIndex).toBe(1); + }); + + it('stops at last tab when wrapAround is false', () => { + const { result } = renderHook(() => + useTabbedNavigation({ + tabCount: 3, + initialIndex: 2, + wrapAround: false, + }), + ); + + act(() => { + result.current.goToNextTab(); + }); + + expect(result.current.currentIndex).toBe(2); + }); + + it('wraps to first tab when wrapAround is true', () => { + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 2, wrapAround: true }), + ); + + act(() => { + result.current.goToNextTab(); + }); + + expect(result.current.currentIndex).toBe(0); + }); + }); + + describe('goToPrevTab', () => { + it('moves to previous tab', () => { + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 2 }), + ); + + act(() => { + result.current.goToPrevTab(); + }); + + expect(result.current.currentIndex).toBe(1); + }); + + it('stops at first tab when wrapAround is false', () => { + const { result } = renderHook(() => + useTabbedNavigation({ + tabCount: 3, + initialIndex: 0, + wrapAround: false, + }), + ); + + act(() => { + result.current.goToPrevTab(); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it('wraps to last tab when wrapAround is true', () => { + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 0, wrapAround: true }), + ); + + act(() => { + result.current.goToPrevTab(); + }); + + expect(result.current.currentIndex).toBe(2); + }); + }); + + describe('setCurrentIndex', () => { + it('sets index directly', () => { + const { result } = renderHook(() => useTabbedNavigation({ tabCount: 3 })); + + act(() => { + result.current.setCurrentIndex(2); + }); + + expect(result.current.currentIndex).toBe(2); + }); + + it('ignores out-of-bounds index', () => { + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 1 }), + ); + + act(() => { + result.current.setCurrentIndex(10); + }); + expect(result.current.currentIndex).toBe(1); + + act(() => { + result.current.setCurrentIndex(-1); + }); + expect(result.current.currentIndex).toBe(1); + }); + }); + + describe('isNavigationBlocked', () => { + it('blocks navigation when callback returns true', () => { + const isNavigationBlocked = vi.fn(() => true); + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, isNavigationBlocked }), + ); + + act(() => { + result.current.goToNextTab(); + }); + + expect(result.current.currentIndex).toBe(0); + expect(isNavigationBlocked).toHaveBeenCalled(); + }); + + it('allows navigation when callback returns false', () => { + const isNavigationBlocked = vi.fn(() => false); + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, isNavigationBlocked }), + ); + + act(() => { + result.current.goToNextTab(); + }); + + expect(result.current.currentIndex).toBe(1); + }); + }); + + describe('onTabChange callback', () => { + it('calls onTabChange when tab changes via goToNextTab', () => { + const onTabChange = vi.fn(); + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, onTabChange }), + ); + + act(() => { + result.current.goToNextTab(); + }); + + expect(onTabChange).toHaveBeenCalledWith(1); + }); + + it('calls onTabChange when tab changes via setCurrentIndex', () => { + const onTabChange = vi.fn(); + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, onTabChange }), + ); + + act(() => { + result.current.setCurrentIndex(2); + }); + + expect(onTabChange).toHaveBeenCalledWith(2); + }); + + it('does not call onTabChange when tab does not change', () => { + const onTabChange = vi.fn(); + const { result } = renderHook(() => + useTabbedNavigation({ tabCount: 3, onTabChange }), + ); + + act(() => { + result.current.setCurrentIndex(0); + }); + + expect(onTabChange).not.toHaveBeenCalled(); + }); + }); + + describe('isFirstTab and isLastTab', () => { + it('returns correct boundary flags based on position', () => { + const { result: first } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 0 }), + ); + expect(first.current.isFirstTab).toBe(true); + expect(first.current.isLastTab).toBe(false); + + const { result: last } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 2 }), + ); + expect(last.current.isFirstTab).toBe(false); + expect(last.current.isLastTab).toBe(true); + + const { result: middle } = renderHook(() => + useTabbedNavigation({ tabCount: 3, initialIndex: 1 }), + ); + expect(middle.current.isFirstTab).toBe(false); + expect(middle.current.isLastTab).toBe(false); + }); + }); + + describe('tabCount changes', () => { + it('reinitializes when tabCount changes', () => { + let tabCount = 5; + const { result, rerender } = renderHook(() => + useTabbedNavigation({ tabCount, initialIndex: 4 }), + ); + + expect(result.current.currentIndex).toBe(4); + + tabCount = 3; + rerender(); + + // Should clamp to valid range + expect(result.current.currentIndex).toBe(2); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.ts new file mode 100644 index 0000000000..cb128b5861 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTabbedNavigation.ts @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useReducer, useCallback, useEffect, useRef } from 'react'; +import { useKeypress, type Key } from './useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; + +/** + * Options for the useTabbedNavigation hook. + */ +export interface UseTabbedNavigationOptions { + /** Total number of tabs */ + tabCount: number; + /** Initial tab index (default: 0) */ + initialIndex?: number; + /** Allow wrapping from last to first and vice versa (default: false) */ + wrapAround?: boolean; + /** Whether left/right arrows navigate tabs (default: true) */ + enableArrowNavigation?: boolean; + /** Whether Tab key advances to next tab (default: true) */ + enableTabKey?: boolean; + /** Callback to determine if navigation is blocked (e.g., during text input) */ + isNavigationBlocked?: () => boolean; + /** Whether the hook is active and should respond to keyboard input */ + isActive?: boolean; + /** Callback when the active tab changes */ + onTabChange?: (index: number) => void; +} + +/** + * Result of the useTabbedNavigation hook. + */ +export interface UseTabbedNavigationResult { + /** Current tab index */ + currentIndex: number; + /** Set the current tab index directly */ + setCurrentIndex: (index: number) => void; + /** Move to the next tab (respecting bounds) */ + goToNextTab: () => void; + /** Move to the previous tab (respecting bounds) */ + goToPrevTab: () => void; + /** Whether currently at first tab */ + isFirstTab: boolean; + /** Whether currently at last tab */ + isLastTab: boolean; +} + +interface TabbedNavigationState { + currentIndex: number; + tabCount: number; + wrapAround: boolean; + pendingTabChange: boolean; +} + +type TabbedNavigationAction = + | { type: 'NEXT_TAB' } + | { type: 'PREV_TAB' } + | { type: 'SET_INDEX'; payload: { index: number } } + | { + type: 'INITIALIZE'; + payload: { tabCount: number; initialIndex: number; wrapAround: boolean }; + } + | { type: 'CLEAR_PENDING' }; + +function tabbedNavigationReducer( + state: TabbedNavigationState, + action: TabbedNavigationAction, +): TabbedNavigationState { + switch (action.type) { + case 'NEXT_TAB': { + const { tabCount, wrapAround, currentIndex } = state; + if (tabCount === 0) return state; + + let nextIndex = currentIndex + 1; + if (nextIndex >= tabCount) { + nextIndex = wrapAround ? 0 : tabCount - 1; + } + + if (nextIndex === currentIndex) return state; + return { ...state, currentIndex: nextIndex, pendingTabChange: true }; + } + + case 'PREV_TAB': { + const { tabCount, wrapAround, currentIndex } = state; + if (tabCount === 0) return state; + + let nextIndex = currentIndex - 1; + if (nextIndex < 0) { + nextIndex = wrapAround ? tabCount - 1 : 0; + } + + if (nextIndex === currentIndex) return state; + return { ...state, currentIndex: nextIndex, pendingTabChange: true }; + } + + case 'SET_INDEX': { + const { index } = action.payload; + const { tabCount, currentIndex } = state; + + if (index === currentIndex) return state; + if (index < 0 || index >= tabCount) return state; + + return { ...state, currentIndex: index, pendingTabChange: true }; + } + + case 'INITIALIZE': { + const { tabCount, initialIndex, wrapAround } = action.payload; + const validIndex = Math.max(0, Math.min(initialIndex, tabCount - 1)); + return { + ...state, + tabCount, + wrapAround, + currentIndex: tabCount > 0 ? validIndex : 0, + pendingTabChange: false, + }; + } + + case 'CLEAR_PENDING': { + return { ...state, pendingTabChange: false }; + } + + default: { + return state; + } + } +} + +/** + * A headless hook that provides keyboard navigation for tabbed interfaces. + * + * Features: + * - Keyboard navigation with left/right arrows + * - Optional Tab key navigation + * - Optional wrap-around navigation + * - Navigation blocking callback (for text input scenarios) + */ +export function useTabbedNavigation({ + tabCount, + initialIndex = 0, + wrapAround = false, + enableArrowNavigation = true, + enableTabKey = true, + isNavigationBlocked, + isActive = true, + onTabChange, +}: UseTabbedNavigationOptions): UseTabbedNavigationResult { + const [state, dispatch] = useReducer(tabbedNavigationReducer, { + currentIndex: Math.max(0, Math.min(initialIndex, tabCount - 1)), + tabCount, + wrapAround, + pendingTabChange: false, + }); + + const prevTabCountRef = useRef(tabCount); + const prevInitialIndexRef = useRef(initialIndex); + const prevWrapAroundRef = useRef(wrapAround); + + useEffect(() => { + const tabCountChanged = prevTabCountRef.current !== tabCount; + const initialIndexChanged = prevInitialIndexRef.current !== initialIndex; + const wrapAroundChanged = prevWrapAroundRef.current !== wrapAround; + + if (tabCountChanged || initialIndexChanged || wrapAroundChanged) { + dispatch({ + type: 'INITIALIZE', + payload: { tabCount, initialIndex, wrapAround }, + }); + prevTabCountRef.current = tabCount; + prevInitialIndexRef.current = initialIndex; + prevWrapAroundRef.current = wrapAround; + } + }, [tabCount, initialIndex, wrapAround]); + + useEffect(() => { + if (state.pendingTabChange) { + onTabChange?.(state.currentIndex); + dispatch({ type: 'CLEAR_PENDING' }); + } + }, [state.pendingTabChange, state.currentIndex, onTabChange]); + + const goToNextTab = useCallback(() => { + if (isNavigationBlocked?.()) return; + dispatch({ type: 'NEXT_TAB' }); + }, [isNavigationBlocked]); + + const goToPrevTab = useCallback(() => { + if (isNavigationBlocked?.()) return; + dispatch({ type: 'PREV_TAB' }); + }, [isNavigationBlocked]); + + const setCurrentIndex = useCallback( + (index: number) => { + if (isNavigationBlocked?.()) return; + dispatch({ type: 'SET_INDEX', payload: { index } }); + }, + [isNavigationBlocked], + ); + + const handleKeypress = useCallback( + (key: Key) => { + if (isNavigationBlocked?.()) return; + + if (enableArrowNavigation) { + if (keyMatchers[Command.MOVE_RIGHT](key)) { + goToNextTab(); + return; + } + if (keyMatchers[Command.MOVE_LEFT](key)) { + goToPrevTab(); + return; + } + } + + if (enableTabKey && key.name === 'tab' && !key.shift) { + goToNextTab(); + } + }, + [ + enableArrowNavigation, + enableTabKey, + goToNextTab, + goToPrevTab, + isNavigationBlocked, + ], + ); + + useKeypress(handleKeypress, { isActive: isActive && tabCount > 1 }); + + return { + currentIndex: state.currentIndex, + setCurrentIndex, + goToNextTab, + goToPrevTab, + isFirstTab: state.currentIndex === 0, + isLastTab: state.currentIndex === tabCount - 1, + }; +}