mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 19:14:33 -07:00
refactor(cli): keyboard handling and AskUserDialog (#17414)
This commit is contained in:
@@ -17,7 +17,9 @@ export const AdminSettingsChangedDialog = () => {
|
||||
(key) => {
|
||||
if (keyMatchers[Command.RESTART_APP](key)) {
|
||||
handleRestart();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -105,6 +106,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
actions(stdin);
|
||||
@@ -123,6 +125,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Move down to custom option
|
||||
@@ -157,6 +160,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Type a character without navigating down
|
||||
@@ -206,6 +210,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -218,6 +223,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -230,6 +236,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -259,6 +266,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Which testing framework?');
|
||||
@@ -299,6 +307,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Answer first question (should auto-advance)
|
||||
@@ -365,6 +374,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -392,6 +402,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||
@@ -435,6 +446,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate directly to Review tab without answering
|
||||
@@ -469,6 +481,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Answer only first question
|
||||
@@ -500,6 +513,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -520,6 +534,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -540,6 +555,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'abc') {
|
||||
@@ -573,6 +589,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -602,6 +619,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'useAuth') {
|
||||
@@ -615,9 +633,6 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -650,6 +665,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'DataTable') {
|
||||
@@ -698,6 +714,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
@@ -722,6 +739,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'SomeText') {
|
||||
@@ -766,6 +784,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// 1. Move to Text Q (Right arrow works for Choice Q)
|
||||
@@ -823,6 +842,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Answer Q1 and Q2 sequentialy
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { Question } from '@google/gemini-cli-core';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
@@ -25,40 +25,30 @@ 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';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.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;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -71,56 +61,22 @@ function askUserDialogReducerLogic(
|
||||
}
|
||||
|
||||
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 { index, answer } = action.payload;
|
||||
const hasAnswer =
|
||||
answer !== undefined && answer !== null && answer.trim() !== '';
|
||||
const newAnswers = { ...state.answers };
|
||||
|
||||
if (hasAnswer) {
|
||||
newAnswers[targetIndex] = answer;
|
||||
newAnswers[index] = answer;
|
||||
} else {
|
||||
delete newAnswers[targetIndex];
|
||||
delete newAnswers[index];
|
||||
}
|
||||
|
||||
const newState = {
|
||||
return {
|
||||
...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) {
|
||||
@@ -131,16 +87,6 @@ function askUserDialogReducerLogic(
|
||||
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,
|
||||
@@ -198,7 +144,9 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
onSubmit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
@@ -235,11 +183,10 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to submit"
|
||||
navigationActions="Tab/Shift+Tab to edit answers"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -251,7 +198,7 @@ interface TextQuestionViewProps {
|
||||
onAnswer: (answer: string) => void;
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
|
||||
availableWidth: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -262,18 +209,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
onCursorEdgeChange,
|
||||
availableWidth,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
||||
const bufferWidth =
|
||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: terminalWidth - 10, height: 1 },
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
@@ -289,32 +237,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
}
|
||||
}, [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('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true });
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(val: string) => {
|
||||
@@ -445,7 +380,7 @@ interface ChoiceQuestionViewProps {
|
||||
onAnswer: (answer: string) => void;
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
|
||||
availableWidth: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -456,14 +391,33 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
onCursorEdgeChange,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const numOptions =
|
||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||
const numLen = String(numOptions).length;
|
||||
const radioWidth = 2; // "● "
|
||||
const numberWidth = numLen + 2; // e.g., "1. "
|
||||
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
|
||||
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
|
||||
const cursorPadding = 1; // Extra character for cursor at end of line
|
||||
const outerBoxPadding = 4; // border (2) + paddingX (2)
|
||||
|
||||
const horizontalPadding =
|
||||
outerBoxPadding +
|
||||
radioWidth +
|
||||
numberWidth +
|
||||
checkboxWidth +
|
||||
checkmarkWidth +
|
||||
cursorPadding;
|
||||
|
||||
const bufferWidth = availableWidth - horizontalPadding;
|
||||
|
||||
const questionOptions = useMemo(
|
||||
() => question.options ?? [],
|
||||
@@ -537,29 +491,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
const customBuffer = useTextBuffer({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: terminalWidth - 20, height: 1 },
|
||||
viewport: { width: Math.max(1, bufferWidth), 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(
|
||||
(
|
||||
@@ -607,31 +545,51 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
// If focusing custom option, handle Ctrl+C
|
||||
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
||||
customBuffer.setText('');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Type-to-jump: if a printable character is typed and not focused, jump to custom
|
||||
// Don't jump if a navigation or selection key is pressed
|
||||
if (
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_UP](key) ||
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) ||
|
||||
keyMatchers[Command.DIALOG_NEXT](key) ||
|
||||
keyMatchers[Command.DIALOG_PREV](key) ||
|
||||
keyMatchers[Command.MOVE_LEFT](key) ||
|
||||
keyMatchers[Command.MOVE_RIGHT](key) ||
|
||||
keyMatchers[Command.RETURN](key) ||
|
||||
keyMatchers[Command.ESCAPE](key) ||
|
||||
keyMatchers[Command.QUIT](key)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a numeric quick selection key (if numbers are shown)
|
||||
const isNumeric = /^[0-9]$/.test(key.sequence);
|
||||
if (isNumeric) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type-to-jump: if printable characters are typed and not focused, jump to custom
|
||||
const isPrintable =
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.alt &&
|
||||
key.sequence.charCodeAt(0) >= 32;
|
||||
(key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32);
|
||||
|
||||
const isNumber = /^[0-9]$/.test(key.sequence);
|
||||
|
||||
if (isPrintable && !isCustomOptionFocused && !isNumber) {
|
||||
if (isPrintable && !isCustomOptionFocused) {
|
||||
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.
|
||||
// For IME or multi-char sequences, we want to capture the whole thing.
|
||||
// If it's a single char, we start the buffer with it.
|
||||
customBuffer.setText(key.sequence);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true });
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
|
||||
const selectionItems = useMemo((): Array<SelectionListItem<OptionItem>> => {
|
||||
const list: Array<SelectionListItem<OptionItem>> = questionOptions.map(
|
||||
@@ -841,11 +799,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<AskUserDialogProps> = ({
|
||||
questions,
|
||||
onSubmit,
|
||||
@@ -853,30 +806,29 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onActiveTextInputChange,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
|
||||
const {
|
||||
currentQuestionIndex,
|
||||
answers,
|
||||
isEditingCustomOption,
|
||||
cursorEdge,
|
||||
submitted,
|
||||
} = state;
|
||||
const { answers, isEditingCustomOption, submitted } = state;
|
||||
|
||||
// Use refs for synchronous checks to prevent race conditions in handleCancel
|
||||
const isEditingCustomOptionRef = useRef(false);
|
||||
isEditingCustomOptionRef.current = isEditingCustomOption;
|
||||
const uiState = useContext(UIStateContext);
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const reviewTabIndex = questions.length;
|
||||
const tabCount =
|
||||
questions.length > 1 ? questions.length + 1 : questions.length;
|
||||
|
||||
const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({
|
||||
tabCount,
|
||||
isActive: !submitted && questions.length > 1,
|
||||
enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually
|
||||
enableTabKey: false, // We'll handle tab manually to match existing behavior
|
||||
});
|
||||
|
||||
const currentQuestionIndex = currentIndex;
|
||||
|
||||
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 () => {
|
||||
@@ -884,70 +836,58 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
};
|
||||
}, [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 (submitted) return false;
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onCancel();
|
||||
} else if (
|
||||
keyMatchers[Command.QUIT](key) &&
|
||||
!isEditingCustomOptionRef.current
|
||||
) {
|
||||
return true;
|
||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[onCancel, submitted],
|
||||
[onCancel, submitted, isEditingCustomOption],
|
||||
);
|
||||
|
||||
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;
|
||||
if (submitted || questions.length <= 1) return false;
|
||||
|
||||
const isTab = key.name === 'tab';
|
||||
const isShiftTab = isTab && key.shift;
|
||||
const isPlainTab = isTab && !key.shift;
|
||||
const isNextKey = keyMatchers[Command.DIALOG_NEXT](key);
|
||||
const isPrevKey = keyMatchers[Command.DIALOG_PREV](key);
|
||||
|
||||
const isRight = key.name === 'right' && !key.ctrl && !key.alt;
|
||||
const isLeft = key.name === 'left' && !key.ctrl && !key.alt;
|
||||
const isRight = keyMatchers[Command.MOVE_RIGHT](key);
|
||||
const isLeft = keyMatchers[Command.MOVE_LEFT](key);
|
||||
|
||||
// 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));
|
||||
// Tab keys always trigger navigation.
|
||||
// Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge).
|
||||
const shouldGoNext = isNextKey || isRight;
|
||||
const shouldGoPrev = isPrevKey || isLeft;
|
||||
|
||||
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 },
|
||||
});
|
||||
goToNextTab();
|
||||
return true;
|
||||
} else if (shouldGoPrev) {
|
||||
dispatch({
|
||||
type: 'PREV_QUESTION',
|
||||
});
|
||||
goToPrevTab();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isEditingCustomOption, cursorEdge, questions, reviewTabIndex, submitted],
|
||||
[questions.length, submitted, goToNextTab, goToPrevTab],
|
||||
);
|
||||
|
||||
useKeypress(handleNavigation, {
|
||||
isActive: questions.length > 1 && !submitted,
|
||||
});
|
||||
|
||||
// Effect to trigger submission when state.submitted becomes true
|
||||
useEffect(() => {
|
||||
if (submitted) {
|
||||
onSubmit(answers);
|
||||
@@ -958,24 +898,23 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
(answer: string) => {
|
||||
if (submitted) return;
|
||||
|
||||
const reviewTabIndex = questions.length;
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
autoAdvance: questions.length > 1,
|
||||
maxIndex: reviewTabIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length === 1) {
|
||||
if (questions.length > 1) {
|
||||
goToNextTab();
|
||||
} else {
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
}
|
||||
},
|
||||
[questions.length, submitted],
|
||||
[currentQuestionIndex, questions.length, submitted, goToNextTab],
|
||||
);
|
||||
|
||||
// Submit from Review tab
|
||||
const handleReviewSubmit = useCallback(() => {
|
||||
if (submitted) return;
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
@@ -987,12 +926,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
autoAdvance: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[submitted],
|
||||
[submitted, currentQuestionIndex],
|
||||
);
|
||||
|
||||
const answeredIndices = useMemo(
|
||||
@@ -1002,7 +941,6 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
// For yesno type, generate Yes/No options and force single-select
|
||||
const effectiveQuestion = useMemo(() => {
|
||||
if (currentQuestion?.type === 'yesno') {
|
||||
return {
|
||||
@@ -1017,13 +955,11 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
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',
|
||||
@@ -1043,63 +979,74 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Render Review tab when on it
|
||||
if (isOnReviewTab) {
|
||||
return (
|
||||
<ReviewView
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
/>
|
||||
<Box aria-label="Review your answers">
|
||||
<ReviewView
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Safeguard for invalid question index
|
||||
if (!currentQuestion) return null;
|
||||
|
||||
const keyboardHints = (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{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'}
|
||||
</Text>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction={
|
||||
currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? 'Enter to submit'
|
||||
: 'Enter to select'
|
||||
}
|
||||
navigationActions={
|
||||
questions.length > 1
|
||||
? currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? 'Tab/Shift+Tab to switch questions'
|
||||
: '←/→ to switch questions'
|
||||
: currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? undefined
|
||||
: '↑/↓ to navigate'
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Render text-type or choice-type question view
|
||||
if (currentQuestion.type === 'text') {
|
||||
return (
|
||||
const questionView =
|
||||
currentQuestion.type === 'text' ? (
|
||||
<TextQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={currentQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
onCursorEdgeChange={handleCursorEdgeChange}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
) : (
|
||||
<ChoiceQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={effectiveQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={effectiveQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
onCursorEdgeChange={handleCursorEdgeChange}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={availableWidth}
|
||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||
>
|
||||
{questionView}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @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 type { Question } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Key Bubbling Regression', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const choiceQuestion: Question[] = [
|
||||
{
|
||||
question: 'Choice Q?',
|
||||
header: 'Choice',
|
||||
options: [
|
||||
{ label: 'Option 1', description: '' },
|
||||
{ label: 'Option 2', description: '' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
it('does not navigate when pressing "j" or "k" in a focused text input', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={choiceQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// 1. Move down to "Enter a custom value" (3rd item)
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Down arrow to Option 2
|
||||
});
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Down arrow to Custom
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Enter a custom value');
|
||||
});
|
||||
|
||||
// 2. Type "j"
|
||||
act(() => {
|
||||
stdin.write('j');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('j');
|
||||
// Verify we are still focusing the custom option (3rd item in list)
|
||||
expect(lastFrame()).toMatch(/● 3\.\s+j/);
|
||||
});
|
||||
|
||||
// 3. Type "k"
|
||||
act(() => {
|
||||
stdin.write('k');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('jk');
|
||||
expect(lastFrame()).toMatch(/● 3\.\s+jk/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,10 +50,13 @@ export function EditorSettingsDialog({
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -59,7 +59,9 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
handleExit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !isRestarting },
|
||||
);
|
||||
|
||||
@@ -21,7 +21,9 @@ export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
|
||||
if (key.name === 'r' || key.name === 'R') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
relaunchApp();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -372,7 +372,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +468,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// focused.
|
||||
/// We want to handle paste even when not focused to support drag and drop.
|
||||
if (!focus && key.name !== 'paste') {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
@@ -498,11 +497,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
buffer.handleInput(key);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
@@ -519,7 +518,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
@@ -544,27 +543,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setReverseSearchActive,
|
||||
reverseSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (commandSearchActive) {
|
||||
cancelSearch(
|
||||
setCommandSearchActive,
|
||||
commandSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
resetEscapeState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
resetEscapeState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle double ESC
|
||||
@@ -577,7 +576,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second ESC
|
||||
@@ -585,26 +584,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
return;
|
||||
return true;
|
||||
} else if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setReverseSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
@@ -629,29 +628,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (showSuggestions) {
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(activeSuggestionIndex);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||
sc.handleAutocomplete(activeSuggestionIndex);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +662,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
handleSubmitAndClear(textToSubmit);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prevent up/down from falling through to regular history navigation
|
||||
@@ -671,7 +670,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,7 +682,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
@@ -691,12 +690,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||
completion.navigateDown();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,7 +724,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completedText) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
handleSubmit(completedText.trim());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
} else if (!isArgumentCompletion) {
|
||||
// Existing logic for command name completion
|
||||
@@ -745,7 +744,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completedText) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
handleSubmit(completedText.trim());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,7 +755,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +766,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
@@ -775,22 +774,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setCommandSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||
// Check for queued messages first when input is empty
|
||||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||||
if (tryLoadQueuedMessages()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Only navigate history if popAllMessages doesn't exist
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
@@ -801,11 +800,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Check for queued messages first when input is empty
|
||||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||||
if (tryLoadQueuedMessages()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Only navigate history if popAllMessages doesn't exist
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||
@@ -813,19 +812,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,7 +839,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// get some feedback that their keypress was handled rather than
|
||||
// wondering why it was completely ignored.
|
||||
buffer.newline();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [row, col] = buffer.cursor;
|
||||
@@ -853,23 +852,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
handleSubmit(buffer.text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
buffer.move('end');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
@@ -877,36 +876,36 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
buffer.deleteWordLeft();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// External editor
|
||||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+V for clipboard paste
|
||||
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleClipboardPaste();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
@@ -914,11 +913,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (activePtyId) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
const handled = buffer.handleInput(key);
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
@@ -932,6 +931,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
return handled;
|
||||
},
|
||||
[
|
||||
focus,
|
||||
|
||||
@@ -28,7 +28,9 @@ export const LogoutConfirmationDialog: React.FC<
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onSelect(LogoutChoice.EXIT);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -27,7 +27,9 @@ export function LoopDetectionConfirmation({
|
||||
onComplete({
|
||||
userSelection: 'keep',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -62,10 +62,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
setPersistMode((prev) => !prev);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -72,7 +72,9 @@ export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
|
||||
if (key.name === 'escape') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !submitted },
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ export function PermissionsModifyTrustDialog({
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
if (needsRestart && key.name === 'r') {
|
||||
const success = commitTrustLevelChange();
|
||||
@@ -75,7 +76,9 @@ export function PermissionsModifyTrustDialog({
|
||||
} else {
|
||||
onExit();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -62,7 +62,9 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onConfirm(RewindOutcome.Cancel);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
if (!selectedMessageId) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onExit();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (
|
||||
@@ -98,12 +98,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
highlightedMessageId !== 'current-position'
|
||||
) {
|
||||
setExpandedMessageId(highlightedMessageId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
setExpandedMessageId(null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -775,10 +775,12 @@ export const useSessionBrowserInput = (
|
||||
state.setSearchQuery('');
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (key.name === 'backspace') {
|
||||
state.setSearchQuery((prev) => prev.slice(0, -1));
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
@@ -789,6 +791,7 @@ export const useSessionBrowserInput = (
|
||||
state.setSearchQuery((prev) => prev + key.sequence);
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode input handling. We're keeping the letter-based controls for non-search
|
||||
@@ -796,27 +799,33 @@ export const useSessionBrowserInput = (
|
||||
if (key.sequence === 'g') {
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (key.sequence === 'G') {
|
||||
state.setActiveIndex(state.totalSessions - 1);
|
||||
state.setScrollOffset(
|
||||
Math.max(0, state.totalSessions - SESSIONS_PER_PAGE),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Sorting controls.
|
||||
else if (key.sequence === 's') {
|
||||
cycleSortOrder();
|
||||
return true;
|
||||
} else if (key.sequence === 'r') {
|
||||
state.setSortReverse(!state.sortReverse);
|
||||
return true;
|
||||
}
|
||||
// Searching and exit controls.
|
||||
else if (key.sequence === '/') {
|
||||
state.setIsSearchMode(true);
|
||||
return true;
|
||||
} else if (
|
||||
key.sequence === 'q' ||
|
||||
key.sequence === 'Q' ||
|
||||
key.name === 'escape'
|
||||
) {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
// Delete session control.
|
||||
else if (key.sequence === 'x' || key.sequence === 'X') {
|
||||
@@ -846,12 +855,15 @@ export const useSessionBrowserInput = (
|
||||
);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// less-like u/d controls.
|
||||
else if (key.sequence === 'u') {
|
||||
moveSelection(-Math.round(SESSIONS_PER_PAGE / 2));
|
||||
return true;
|
||||
} else if (key.sequence === 'd') {
|
||||
moveSelection(Math.round(SESSIONS_PER_PAGE / 2));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,15 +878,21 @@ export const useSessionBrowserInput = (
|
||||
if (!selectedSession.isCurrentSession) {
|
||||
onResumeSession(selectedSession);
|
||||
}
|
||||
return true;
|
||||
} else if (key.name === 'up') {
|
||||
moveSelection(-1);
|
||||
return true;
|
||||
} else if (key.name === 'down') {
|
||||
moveSelection(1);
|
||||
return true;
|
||||
} else if (key.name === 'pageup') {
|
||||
moveSelection(-SESSIONS_PER_PAGE);
|
||||
return true;
|
||||
} else if (key.name === 'pagedown') {
|
||||
moveSelection(SESSIONS_PER_PAGE);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -201,10 +201,13 @@ export function ThemeDialog({
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -53,10 +53,13 @@ export function ValidationDialog({
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
|
||||
onChoice('cancel');
|
||||
return true;
|
||||
} else if (state === 'waiting' && keyMatchers[Command.RETURN](key)) {
|
||||
// User confirmed verification is complete - transition to 'complete' state
|
||||
setState('complete');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: state !== 'complete' },
|
||||
);
|
||||
|
||||
@@ -34,18 +34,18 @@ exports[`AskUserDialog > Text type questions > shows default placeholder when no
|
||||
`;
|
||||
|
||||
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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭─────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ 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`] = `
|
||||
@@ -123,16 +123,16 @@ exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
|
||||
`;
|
||||
|
||||
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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭─────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ 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 │
|
||||
╰─────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -413,3 +413,371 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render accessibility settings enabled correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render all boolean settings disabled correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false* │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update false* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render default state correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render file filtering settings configured correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render focused on scope selector correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
│ ● 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ 3. System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render mixed boolean and number settings correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render tools and security settings correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render various boolean settings enabled correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) true* │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -75,10 +75,12 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return;
|
||||
if (!isFocused) return false;
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface BaseSelectionListProps<
|
||||
maxItemsToShow?: number;
|
||||
wrapAround?: boolean;
|
||||
focusKey?: string;
|
||||
priority?: boolean;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ export function BaseSelectionList<
|
||||
maxItemsToShow = 10,
|
||||
wrapAround = true,
|
||||
focusKey,
|
||||
priority,
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
@@ -74,6 +76,7 @@ export function BaseSelectionList<
|
||||
showNumbers,
|
||||
wrapAround,
|
||||
focusKey,
|
||||
priority,
|
||||
});
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
@@ -336,7 +336,7 @@ export function BaseSettingsDialog({
|
||||
} else if (newIndex < scrollOffset) {
|
||||
setScrollOffset(newIndex);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
@@ -346,7 +346,7 @@ export function BaseSettingsDialog({
|
||||
} else if (newIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter - toggle or start edit
|
||||
@@ -359,19 +359,19 @@ export function BaseSettingsDialog({
|
||||
const initialValue = rawVal !== undefined ? String(rawVal) : '';
|
||||
startEditing(currentItem.key, initialValue);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+L - clear/reset to default (using only Ctrl+L to avoid Ctrl+C exit conflict)
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key) && currentItem) {
|
||||
onItemClear(currentItem.key, currentItem);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Number keys for quick edit on number fields
|
||||
if (currentItem?.type === 'number' && /^[0-9]$/.test(key.sequence)) {
|
||||
startEditing(currentItem.key, key.sequence);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,8 @@ export function BaseSettingsDialog({
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
@@ -565,6 +567,7 @@ export function BaseSettingsDialog({
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
showNumbers={focusSection === 'scope'}
|
||||
priority={focusSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export interface DialogFooterProps {
|
||||
/** The main shortcut (e.g., "Enter to submit") */
|
||||
primaryAction: string;
|
||||
/** Secondary navigation shortcuts (e.g., "Tab/Shift+Tab to switch questions") */
|
||||
navigationActions?: string;
|
||||
/** Exit shortcut (defaults to "Esc to cancel") */
|
||||
cancelAction?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared footer component for dialogs to ensure consistent styling and formatting
|
||||
* of keyboard shortcuts and help text.
|
||||
*/
|
||||
export const DialogFooter: React.FC<DialogFooterProps> = ({
|
||||
primaryAction,
|
||||
navigationActions,
|
||||
cancelAction = 'Esc to cancel',
|
||||
}) => {
|
||||
const parts = [primaryAction];
|
||||
if (navigationActions) {
|
||||
parts.push(navigationActions);
|
||||
}
|
||||
parts.push(cancelAction);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{parts.join(' · ')}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -44,6 +44,8 @@ export interface RadioButtonSelectProps<T> {
|
||||
maxItemsToShow?: number;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
/** Whether the hook should have priority over normal subscribers. */
|
||||
priority?: boolean;
|
||||
/** Optional custom renderer for items. */
|
||||
renderItem?: (
|
||||
item: RadioSelectItem<T>,
|
||||
@@ -66,6 +68,7 @@ export function RadioButtonSelect<T>({
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
priority,
|
||||
renderItem,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
return (
|
||||
@@ -78,6 +81,7 @@ export function RadioButtonSelect<T>({
|
||||
showNumbers={showNumbers}
|
||||
showScrollArrows={showScrollArrows}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
priority={priority}
|
||||
renderItem={
|
||||
renderItem ||
|
||||
((item, { titleColor }) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('TabHeader', () => {
|
||||
expect(frame).toContain('Tab 1');
|
||||
expect(frame).toContain('Tab 2');
|
||||
expect(frame).toContain('Tab 3');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders separators between tabs', () => {
|
||||
@@ -44,6 +45,7 @@ describe('TabHeader', () => {
|
||||
// Should have 2 separators for 3 tabs
|
||||
const separatorCount = (frame?.match(/│/g) || []).length;
|
||||
expect(separatorCount).toBe(2);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +57,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('←');
|
||||
expect(frame).toContain('→');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides arrows when showArrows is false', () => {
|
||||
@@ -64,6 +67,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('←');
|
||||
expect(frame).not.toContain('→');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +79,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
// Default uncompleted icon is □
|
||||
expect(frame).toContain('□');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides status icons when showStatusIcons is false', () => {
|
||||
@@ -84,6 +89,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('□');
|
||||
expect(frame).not.toContain('✓');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows checkmark for completed tabs', () => {
|
||||
@@ -100,6 +106,7 @@ describe('TabHeader', () => {
|
||||
const boxCount = (frame?.match(/□/g) || []).length;
|
||||
expect(checkmarkCount).toBe(2);
|
||||
expect(boxCount).toBe(1);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows special icon for special tabs', () => {
|
||||
@@ -113,6 +120,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
// Special tab shows ≡ icon
|
||||
expect(frame).toContain('≡');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('uses tab statusIcon when provided', () => {
|
||||
@@ -125,6 +133,7 @@ describe('TabHeader', () => {
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('★');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('uses custom renderStatusIcon when provided', () => {
|
||||
@@ -139,6 +148,7 @@ describe('TabHeader', () => {
|
||||
const frame = lastFrame();
|
||||
const bulletCount = (frame?.match(/•/g) || []).length;
|
||||
expect(bulletCount).toBe(3);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('falls back to default when renderStatusIcon returns undefined', () => {
|
||||
@@ -152,6 +162,7 @@ describe('TabHeader', () => {
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('□');
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,16 +81,16 @@ export function TabHeader({
|
||||
if (tab.statusIcon) return tab.statusIcon;
|
||||
|
||||
// Default icons
|
||||
if (tab.isSpecial) return '\u2261'; // ≡
|
||||
return isCompleted ? '\u2713' : '\u25A1'; // ✓ or □
|
||||
if (tab.isSpecial) return '≡';
|
||||
return isCompleted ? '✓' : '□';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{showArrows && <Text color={theme.text.secondary}>{'\u2190 '}</Text>}
|
||||
<Box flexDirection="row" marginBottom={1} aria-role="tablist">
|
||||
{showArrows && <Text color={theme.text.secondary}>{'← '}</Text>}
|
||||
{tabs.map((tab, i) => (
|
||||
<React.Fragment key={tab.key}>
|
||||
{i > 0 && <Text color={theme.text.secondary}>{' \u2502 '}</Text>}
|
||||
{i > 0 && <Text color={theme.text.secondary}>{' │ '}</Text>}
|
||||
{showStatusIcons && (
|
||||
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
|
||||
)}
|
||||
@@ -99,12 +99,13 @@ export function TabHeader({
|
||||
i === currentIndex ? theme.text.accent : theme.text.secondary
|
||||
}
|
||||
bold={i === currentIndex}
|
||||
aria-current={i === currentIndex ? 'step' : undefined}
|
||||
>
|
||||
{tab.header}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{showArrows && <Text color={theme.text.secondary}>{' \u2192'}</Text>}
|
||||
{showArrows && <Text color={theme.text.secondary}>{' →'}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,22 +40,23 @@ export function TextInput({
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'escape') {
|
||||
onCancel?.();
|
||||
return;
|
||||
if (key.name === 'escape' && onCancel) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.name === 'return') {
|
||||
onSubmit?.(text);
|
||||
return;
|
||||
if (key.name === 'return' && onSubmit) {
|
||||
onSubmit(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleInput(key);
|
||||
const handled = handleInput(key);
|
||||
return handled;
|
||||
},
|
||||
[handleInput, onCancel, onSubmit, text],
|
||||
);
|
||||
|
||||
useKeypress(handleKeyPress, { isActive: focus });
|
||||
useKeypress(handleKeyPress, { isActive: focus, priority: true });
|
||||
|
||||
const showPlaceholder = text.length === 0 && placeholder;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TabHeader > arrows > hides arrows when showArrows is false 1`] = `
|
||||
"□ Tab 1 │ □ Tab 2 │ □ Tab 3
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > arrows > shows arrows by default 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > rendering > renders all tab headers 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > rendering > renders separators between tabs 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > falls back to default when renderStatusIcon returns undefined 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > hides status icons when showStatusIcons is false 1`] = `
|
||||
"← Tab 1 │ Tab 2 │ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > shows checkmark for completed tabs 1`] = `
|
||||
"← ✓ Tab 1 │ □ Tab 2 │ ✓ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > shows special icon for special tabs 1`] = `
|
||||
"← □ Tab 1 │ ≡ Review →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > shows status icons by default 1`] = `
|
||||
"← □ Tab 1 │ □ Tab 2 │ □ Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > uses custom renderStatusIcon when provided 1`] = `
|
||||
"← • Tab 1 │ • Tab 2 │ • Tab 3 →
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TabHeader > status icons > uses tab statusIcon when provided 1`] = `
|
||||
"← ★ Tab 1 │ □ Tab 2 →
|
||||
"
|
||||
`;
|
||||
@@ -2867,27 +2867,98 @@ export function useTextBuffer({
|
||||
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): void => {
|
||||
(key: Key): boolean => {
|
||||
const { sequence: input } = key;
|
||||
|
||||
if (key.name === 'paste') insert(input, { paste: true });
|
||||
else if (keyMatchers[Command.RETURN](key)) newline();
|
||||
else if (keyMatchers[Command.NEWLINE](key)) newline();
|
||||
else if (keyMatchers[Command.MOVE_LEFT](key)) move('left');
|
||||
else if (keyMatchers[Command.MOVE_RIGHT](key)) move('right');
|
||||
else if (keyMatchers[Command.MOVE_UP](key)) move('up');
|
||||
else if (keyMatchers[Command.MOVE_DOWN](key)) move('down');
|
||||
else if (keyMatchers[Command.MOVE_WORD_LEFT](key)) move('wordLeft');
|
||||
else if (keyMatchers[Command.MOVE_WORD_RIGHT](key)) move('wordRight');
|
||||
else if (keyMatchers[Command.HOME](key)) move('home');
|
||||
else if (keyMatchers[Command.END](key)) move('end');
|
||||
else if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) deleteWordLeft();
|
||||
else if (keyMatchers[Command.DELETE_WORD_FORWARD](key)) deleteWordRight();
|
||||
else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) backspace();
|
||||
else if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) del();
|
||||
else if (keyMatchers[Command.UNDO](key)) undo();
|
||||
else if (keyMatchers[Command.REDO](key)) redo();
|
||||
else if (key.insertable) insert(input, { paste: false });
|
||||
if (key.name === 'paste') {
|
||||
insert(input, { paste: true });
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
if (singleLine) {
|
||||
return false;
|
||||
}
|
||||
newline();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
if (singleLine) {
|
||||
return false;
|
||||
}
|
||||
newline();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
||||
if (cursorRow === 0 && cursorCol === 0) return false;
|
||||
move('left');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
||||
const lastLineIdx = lines.length - 1;
|
||||
if (
|
||||
cursorRow === lastLineIdx &&
|
||||
cursorCol === cpLen(lines[lastLineIdx] ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
move('right');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_UP](key)) {
|
||||
if (cursorRow === 0) return false;
|
||||
move('up');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_DOWN](key)) {
|
||||
if (cursorRow === lines.length - 1) return false;
|
||||
move('down');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_WORD_LEFT](key)) {
|
||||
move('wordLeft');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_WORD_RIGHT](key)) {
|
||||
move('wordRight');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
move('home');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
move('end');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
deleteWordLeft();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_WORD_FORWARD](key)) {
|
||||
deleteWordRight();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
|
||||
backspace();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||
del();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.UNDO](key)) {
|
||||
undo();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.REDO](key)) {
|
||||
redo();
|
||||
return true;
|
||||
}
|
||||
if (key.insertable) {
|
||||
insert(input, { paste: false });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[
|
||||
newline,
|
||||
@@ -2899,6 +2970,10 @@ export function useTextBuffer({
|
||||
insert,
|
||||
undo,
|
||||
redo,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
lines,
|
||||
singleLine,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user