Compare commits

..

2 Commits

Author SHA1 Message Date
Sehoon Shon 3e2ccade9d Merge branch 'main' into test-workflow-validation 2026-01-30 15:08:39 -05:00
Bryan Morgan 5c34cf7dae docs: dummy change to trigger CI 2026-01-30 11:20:54 -05:00
18 changed files with 158 additions and 1722 deletions
@@ -48,15 +48,14 @@ jobs:
org: context.repo.owner,
team_slug: 'gemini-cli-maintainers'
});
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
maintainerLogins = new Set(members.map(m => m.login));
} catch (e) {
core.warning('Failed to fetch team members');
}
const isMaintainer = (login, assoc) => {
const isTeamMember = maintainerLogins.has(login.toLowerCase());
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
return isTeamMember || isRepoMaintainer;
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
};
// 2. Determine which PRs to check
+2
View File
@@ -147,3 +147,5 @@ This documentation is organized into the following sections:
the terms of service and privacy notices applicable to your use of Gemini CLI.
We hope this documentation helps you make the most of Gemini CLI!
<!-- workflow validation test -->
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -564,17 +564,11 @@ describe('run_shell_command', () => {
it('rejects invalid shell expressions', async () => {
await rig.setup('rejects invalid shell expressions', {
settings: {
tools: {
core: ['run_shell_command'],
allowed: ['run_shell_command(echo)'], // Specifically allow echo
},
},
settings: { tools: { core: ['run_shell_command'] } },
});
const invalidCommand = getInvalidCommand();
const result = await rig.run({
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
approvalMode: 'default', // Use default mode so safety fallback triggers confirmation
});
expect(result).toContain('FAIL');
+22 -62
View File
@@ -371,9 +371,7 @@ describe('AppContainer State Management', () => {
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
lines: [''],
cursor: [0, 0],
handleInput: vi.fn().mockReturnValue(false),
// Add other properties if AppContainer uses them
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
@@ -1902,7 +1900,7 @@ describe('AppContainer State Management', () => {
});
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let handleGlobalKeypress: (key: Key) => void;
let mockHandleSlashCommand: Mock;
let mockCancelOngoingRequest: Mock;
let rerender: () => void;
@@ -1937,11 +1935,9 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
// Capture the keypress handler from the AppContainer
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean) => {
handleGlobalKeypress = callback;
},
);
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
handleGlobalKeypress = callback;
});
// Mock slash command handler
mockHandleSlashCommand = vi.fn();
@@ -1965,9 +1961,6 @@ describe('AppContainer State Management', () => {
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
lines: [''],
cursor: [0, 0],
handleInput: vi.fn().mockReturnValue(false),
});
vi.useFakeTimers();
@@ -2027,6 +2020,19 @@ describe('AppContainer State Management', () => {
});
describe('CTRL+D', () => {
it('should do nothing if text buffer is not empty', async () => {
mockedUseTextBuffer.mockReturnValue({
text: 'some text',
setText: vi.fn(),
});
await setupKeypressTest();
pressKey({ name: 'd', ctrl: true }, 2);
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
unmount();
});
it('should quit on second press if buffer is empty', async () => {
await setupKeypressTest();
@@ -2041,50 +2047,6 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
mockedUseTextBuffer.mockReturnValue({
text: 'some text',
setText: vi.fn(),
lines: ['some text'],
cursor: [0, 9], // At the end
handleInput: vi.fn().mockReturnValue(false),
});
await setupKeypressTest();
// Capture return value
let result = true;
const originalPressKey = (key: Partial<Key>) => {
act(() => {
result = handleGlobalKeypress({
name: 'd',
shift: false,
alt: false,
ctrl: true,
cmd: false,
...key,
} as Key);
});
rerender();
};
originalPressKey({ name: 'd', ctrl: true });
// AppContainer's handler should return true if it reaches it
expect(result).toBe(true);
// But it should only be called once, so count is 1, not quitting yet.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
originalPressKey({ name: 'd', ctrl: true });
// Now count is 2, it should quit.
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
unmount();
});
it('should reset press count after a timeout', async () => {
await setupKeypressTest();
@@ -2104,7 +2066,7 @@ describe('AppContainer State Management', () => {
});
describe('Copy Mode (CTRL+S)', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let handleGlobalKeypress: (key: Key) => void;
let rerender: () => void;
let unmount: () => void;
@@ -2134,11 +2096,9 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
mocks.mockStdout.write.mockClear();
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean) => {
handleGlobalKeypress = callback;
},
);
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
handleGlobalKeypress = callback;
});
vi.useFakeTimers();
});
+13 -15
View File
@@ -532,14 +532,6 @@ export const AppContainer = (props: AppContainerProps) => {
shellModeActive,
getPreferredEditor,
});
const bufferRef = useRef(buffer);
useEffect(() => {
bufferRef.current = buffer;
}, [buffer]);
const stableSetText = useCallback((text: string) => {
bufferRef.current.setText(text);
}, []);
// Initialize input history from logger (past sessions)
useEffect(() => {
@@ -834,7 +826,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
},
setText: stableSetText,
setText: (text: string) => buffer.setText(text),
}),
[
setAuthState,
@@ -852,7 +844,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
stableSetText,
buffer,
],
);
@@ -1413,7 +1405,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (ctrlCPressCount > 1) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/quit', undefined, undefined, false);
} else if (ctrlCPressCount > 0) {
} else {
ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0);
ctrlCTimerRef.current = null;
@@ -1432,7 +1424,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (ctrlDPressCount > 1) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/quit', undefined, undefined, false);
} else if (ctrlDPressCount > 0) {
} else {
ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0);
ctrlDTimerRef.current = null;
@@ -1473,7 +1465,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
(key: Key) => {
if (copyModeEnabled) {
setCopyModeEnabled(false);
enableMouseEvents();
@@ -1500,6 +1492,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCtrlCPressCount((prev) => prev + 1);
return true;
} else if (keyMatchers[Command.EXIT](key)) {
if (buffer.text.length > 0) {
return false;
}
setCtrlDPressCount((prev) => prev + 1);
return true;
}
@@ -1543,7 +1538,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
} else if (
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
(activePtyId ||
(isBackgroundShellVisible && backgroundShells.size > 0)) &&
buffer.text.length === 0
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
@@ -1628,6 +1625,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
ideContextState,
setCtrlCPressCount,
buffer.text.length,
setCtrlDPressCount,
handleSlashCommand,
cancelOngoingRequest,
@@ -1649,7 +1647,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -10,7 +10,6 @@ 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';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
// Helper to write to stdin with proper act() wrapping
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
@@ -43,6 +42,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -108,6 +108,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -128,6 +129,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -157,49 +159,33 @@ describe('AskUserDialog', () => {
});
});
describe.each([
{ useAlternateBuffer: true, expectedArrows: false },
{ useAlternateBuffer: false, expectedArrows: true },
])(
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
({ useAlternateBuffer, expectedArrows }) => {
it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Scroll Test',
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
it('shows scroll arrows when options exceed available height', async () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Scroll Test',
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
{ useAlternateBuffer },
);
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
);
await waitFor(() => {
if (expectedArrows) {
expect(lastFrame()).toContain('▲');
expect(lastFrame()).toContain('▼');
} else {
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).not.toContain('▼');
}
expect(lastFrame()).toMatchSnapshot();
});
});
},
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
@@ -208,6 +194,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -259,6 +246,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -273,6 +261,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -287,6 +276,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -318,6 +308,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -360,6 +351,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -428,6 +420,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -457,6 +450,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -502,6 +496,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -538,6 +533,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -571,6 +567,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -593,6 +590,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -615,6 +613,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -650,6 +649,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -681,6 +681,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -728,6 +729,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -778,6 +780,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -804,6 +807,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={onCancel}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -850,6 +854,7 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -909,6 +914,7 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -940,72 +946,4 @@ describe('AskUserDialog', () => {
});
});
});
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Context Test',
options: Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const mockUIState = {
availableTerminalHeight: 5, // Small height to force scroll arrows
} as UIState;
const { lastFrame } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: false },
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
expect(lastFrame()).toContain('▲');
expect(lastFrame()).toContain('▼');
});
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
const longQuestion =
'This is a very long question ' + 'with many words '.repeat(10);
const questions: Question[] = [
{
question: longQuestion,
header: 'Alternate Buffer Test',
options: [{ label: 'Option 1', description: 'Desc 1' }],
multiSelect: false,
},
];
const mockUIState = {
availableTerminalHeight: 5,
} as UIState;
const { lastFrame } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={40} // Small width to force wrapping
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: true },
);
// Should NOT contain the truncation message
expect(lastFrame()).not.toContain('hidden ...');
// Should contain the full long question (or at least its parts)
expect(lastFrame()).toContain('This is a very long question');
});
});
@@ -5,14 +5,7 @@
*/
import type React from 'react';
import {
useCallback,
useMemo,
useRef,
useEffect,
useReducer,
useContext,
} from 'react';
import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core';
@@ -28,8 +21,6 @@ import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface AskUserDialogState {
answers: { [key: string]: string };
@@ -130,7 +121,7 @@ interface AskUserDialogProps {
/**
* Height constraint for scrollable content.
*/
availableHeight?: number;
availableHeight: number;
}
interface ReviewViewProps {
@@ -208,7 +199,7 @@ interface TextQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight?: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -225,7 +216,6 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const isAlternateBuffer = useAlternateBuffer();
const prefix = '> ';
const horizontalPadding = 1; // 1 for cursor
const bufferWidth =
@@ -289,20 +279,13 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const INPUT_HEIGHT = 2; // TextInput + margin
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
const questionHeight =
availableHeight && !isAlternateBuffer
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight = Math.max(1, availableHeight - overhead);
return (
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<MaxSizedBox
maxHeight={questionHeight}
maxWidth={availableWidth}
overflowDirection="bottom"
>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
@@ -406,7 +389,7 @@ interface ChoiceQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight?: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -423,7 +406,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
progressHeader,
keyboardHints,
}) => {
const isAlternateBuffer = useAlternateBuffer();
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length;
@@ -729,27 +711,18 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = availableHeight
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - 4))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
: selectionItems.length;
const listHeight = Math.max(1, availableHeight - overhead);
const questionHeight = Math.min(3, Math.max(1, listHeight - 4));
const maxItemsToShow = Math.max(
1,
Math.floor((listHeight - questionHeight) / 2),
);
return (
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox
maxHeight={questionHeight}
maxWidth={availableWidth}
overflowDirection="bottom"
>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
{question.multiSelect && (
@@ -851,15 +824,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onCancel,
onActiveTextInputChange,
width,
availableHeight: availableHeightProp,
availableHeight,
}) => {
const uiState = useContext(UIStateContext);
const availableHeight =
availableHeightProp ??
(uiState?.constrainHeight !== false
? uiState?.availableTerminalHeight
: undefined);
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const { answers, isEditingCustomOption, submitted } = state;
@@ -45,8 +45,6 @@ import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
@@ -171,16 +169,7 @@ describe('InputPrompt', () => {
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
handleInput: vi.fn((key: Key) => {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (mockBuffer.text.length > 0) {
mockBuffer.setText('');
return true;
}
return false;
}
return false;
}),
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: vi.fn((offset: number) => {
mockBuffer.cursor = [0, offset];
@@ -510,23 +499,6 @@ describe('InputPrompt', () => {
unmount();
});
it('should clear the buffer and reset completion on Ctrl+C', async () => {
mockBuffer.text = 'some text';
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\u0003'); // Ctrl+C
});
await waitFor(() => {
expect(mockBuffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
});
unmount();
});
describe('clipboard image paste', () => {
beforeEach(() => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+26 -27
View File
@@ -604,12 +604,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
@@ -617,6 +611,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
setBannerVisible(false);
onClearScreen();
return true;
}
if (reverseSearchActive || commandSearchActive) {
const isCommandSearch = commandSearchActive;
@@ -881,6 +881,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.move('end');
return true;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
}
return false;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
@@ -925,23 +933,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
const handled = buffer.handleInput(key);
if (handled) {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
resetCompletionState();
}
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.cmd
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.cmd
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
return handled;
},
@@ -980,10 +982,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
useKeypress(handleInput, {
isActive: !isEmbeddedShellFocused,
priority: true,
});
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
@@ -1,56 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
3. Option 3
Description 3
4. Option 4
Description 4
5. Option 5
Description 5
6. Option 6
Description 6
7. Option 7
Description 7
8. Option 8
Description 8
9. Option 9
Description 9
10. Option 10
Description 10
11. Option 11
Description 11
12. Option 12
Description 12
13. Option 13
Description 13
14. Option 14
Description 14
15. Option 15
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component?
@@ -155,6 +104,19 @@ Which database should we use?
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"← □ License │ □ README │ ≡ Review →
@@ -271,7 +271,7 @@ export const ToolConfirmationMessage: React.FC<
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
availableHeight={availableBodyContentHeight() ?? 10}
/>
);
return { question: '', bodyContent, options: [] };
@@ -1515,50 +1515,6 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).text).toBe('');
});
it('should handle CLEAR_INPUT (Ctrl+C)', () => {
const { result } = renderHook(() =>
useTextBuffer({
initialText: 'hello',
viewport,
isValidPath: () => false,
}),
);
expect(getBufferState(result).text).toBe('hello');
let handled = false;
act(() => {
handled = result.current.handleInput({
name: 'c',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\u0003',
});
});
expect(handled).toBe(true);
expect(getBufferState(result).text).toBe('');
});
it('should NOT handle CLEAR_INPUT if buffer is empty', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
let handled = true;
act(() => {
handled = result.current.handleInput({
name: 'c',
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\u0003',
});
});
expect(handled).toBe(false);
});
it('should handle "Backspace" key', () => {
const { result } = renderHook(() =>
useTextBuffer({
@@ -2930,13 +2930,6 @@ export function useTextBuffer({
move('end');
return true;
}
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (text.length > 0) {
setText('');
return true;
}
return false;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
deleteWordLeft();
return true;
@@ -2950,13 +2943,6 @@ export function useTextBuffer({
return true;
}
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
const lastLineIdx = lines.length - 1;
if (
cursorRow === lastLineIdx &&
cursorCol === cpLen(lines[lastLineIdx] ?? '')
) {
return false;
}
del();
return true;
}
@@ -2988,8 +2974,6 @@ export function useTextBuffer({
cursorCol,
lines,
singleLine,
setText,
text,
],
);
@@ -1398,100 +1398,6 @@ describe('PolicyEngine', () => {
});
});
describe('shell command parsing failure', () => {
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
decision: PolicyDecision.ALLOW,
priority: 999,
modes: [ApprovalMode.YOLO],
},
{
toolName: 'run_shell_command',
decision: PolicyDecision.ASK_USER,
priority: 10,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
// Simulate parsing failure (splitCommands returning empty array)
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
expect(result.rule).toBeDefined();
expect(result.rule?.priority).toBe(999);
});
it('should return DENY in YOLO mode if shell command parsing fails and a higher priority rule says DENY', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.DENY,
priority: 2000, // Very high priority DENY (e.g. Admin)
},
{
decision: PolicyDecision.ALLOW,
priority: 999,
modes: [ApprovalMode.YOLO],
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
});
// Simulate parsing failure
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 20,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
});
// Simulate parsing failure
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
expect(result.rule).toBeDefined();
expect(result.rule?.priority).toBe(20);
});
});
describe('safety checker integration', () => {
it('should call checker when rule allows and has safety_checker', async () => {
const rules: PolicyRule[] = [
+2 -16
View File
@@ -152,28 +152,14 @@ export class PolicyEngine {
const subCommands = splitCommands(command);
if (subCommands.length === 0) {
// If the matched rule says DENY, we should respect it immediately even if parsing fails.
if (ruleDecision === PolicyDecision.DENY) {
return { decision: PolicyDecision.DENY, rule };
}
// In YOLO mode, we should proceed anyway even if we can't parse the command.
if (this.approvalMode === ApprovalMode.YOLO) {
return {
decision: PolicyDecision.ALLOW,
rule,
};
}
debugLogger.debug(
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`,
);
// Parsing logic failed, we can't trust it. Force ASK_USER (or DENY).
// We return the rule that matched so the evaluation loop terminates.
// We don't blame a specific rule here, unless the input rule was stricter.
return {
decision: this.applyNonInteractiveMode(PolicyDecision.ASK_USER),
rule,
rule: undefined,
};
}
+11 -11
View File
@@ -101,33 +101,33 @@ describe('retryWithBackoff', () => {
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should default to 3 maxAttempts if no options are provided', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(5);
it('should default to 10 maxAttempts if no options are provided', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
const promise = retryWithBackoff(mockFn);
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 3'),
expect(promise).rejects.toThrow('Simulated error attempt 10'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(3);
expect(mockFn).toHaveBeenCalledTimes(10);
});
it('should default to 3 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(5);
it('should default to 10 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
const promise = retryWithBackoff(mockFn, { maxAttempts: undefined });
// Expect it to fail with the error from the 3rd attempt.
// Expect it to fail with the error from the 10th attempt.
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 3'),
expect(promise).rejects.toThrow('Simulated error attempt 10'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(3);
expect(mockFn).toHaveBeenCalledTimes(10);
});
it('should not retry if shouldRetry returns false', async () => {
+1 -1
View File
@@ -40,7 +40,7 @@ export interface RetryOptions {
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxAttempts: 3,
maxAttempts: 10,
initialDelayMs: 5000,
maxDelayMs: 30000, // 30 seconds
shouldRetryOnError: isRetryableError,