mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 675b3d7834 | |||
| ae672881d1 | |||
| 9e7c10ad88 | |||
| 381669dce0 | |||
| 707b3e85d5 | |||
| 7d36cc004f | |||
| cb4f0c6fa4 | |||
| b0f38104d7 | |||
| 7469ea0fca | |||
| 00fdb30211 | |||
| 0fe8492569 | |||
| 76387d22ae | |||
| a362b7b382 |
@@ -43,19 +43,23 @@ jobs:
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
let teamFetchSucceeded = false;
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
|
||||
teamFetchSucceeded = true;
|
||||
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
|
||||
} catch (e) {
|
||||
core.warning('Failed to fetch team members');
|
||||
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
return isTeamMember || isRepoMaintainer;
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
@@ -153,7 +157,17 @@ jobs:
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
let lastActivity = new Date(0);
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) {
|
||||
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
|
||||
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
@@ -177,8 +191,12 @@ jobs:
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// For maintainer PRs, the PR creation itself counts as maintainer activity.
|
||||
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
|
||||
@@ -97,7 +97,6 @@ they appear in the UI.
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ the full instructions and resources required to complete the task using the
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **Shared Expertise:** Package complex workflows (like a specific team's PR
|
||||
- **Shared Expertise:** Package complex workflows (like a a specific team's PR
|
||||
review process) into a folder that anyone can use.
|
||||
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
|
||||
consistently by providing a procedural framework.
|
||||
|
||||
@@ -215,14 +215,6 @@ a few things you can try in order of recommendation:
|
||||
MCP servers of their own. This should not be used as an airtight security
|
||||
mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes
|
||||
tool calls that are considered safe (e.g., read-only operations) without
|
||||
explicit user confirmation. If set to `true`, the CLI will bypass the
|
||||
confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
||||
- **Default:** `"Default"`
|
||||
|
||||
@@ -661,11 +661,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.autoAccept`** (boolean):
|
||||
- **Description:** Automatically accept and execute tool calls that are
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
|
||||
@@ -167,8 +167,8 @@ case is response validation and automatic retries.
|
||||
- `reason`: Required if denied. This text is sent **to the agent as a new
|
||||
prompt** to request a correction.
|
||||
- `continue`: Set to `false` to **stop the session** without retrying.
|
||||
- `clearContext`: If `true`, clears conversation history (LLM memory) while
|
||||
preserving UI display.
|
||||
- `hookSpecificOutput.clearContext`: If `true`, clears conversation history
|
||||
(LLM memory) while preserving UI display.
|
||||
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
|
||||
turn using `stderr` as the feedback prompt.
|
||||
|
||||
|
||||
@@ -564,11 +564,17 @@ describe('run_shell_command', () => {
|
||||
|
||||
it('rejects invalid shell expressions', async () => {
|
||||
await rig.setup('rejects invalid shell expressions', {
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
allowed: ['run_shell_command(echo)'], // Specifically allow echo
|
||||
},
|
||||
},
|
||||
});
|
||||
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');
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should handle complex mixed configurations', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Allows read-only tools
|
||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||
exclude: ['glob', 'dangerous-tool'],
|
||||
},
|
||||
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50
|
||||
allowed: ['specific-tool'], // Priority 100
|
||||
exclude: ['blocked-tool'], // Priority 200
|
||||
},
|
||||
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify rules are created with correct priorities', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true,
|
||||
allowed: ['tool1', 'tool2'],
|
||||
exclude: ['tool3'],
|
||||
},
|
||||
|
||||
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
|
||||
},
|
||||
tools: {
|
||||
sandbox: 'inherit',
|
||||
autoAccept: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1041,17 +1041,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
|
||||
@@ -371,7 +371,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
// Add other properties if AppContainer uses them
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -1900,7 +1902,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
@@ -1935,9 +1937,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
@@ -1961,6 +1965,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
@@ -2020,19 +2027,6 @@ 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();
|
||||
|
||||
@@ -2047,6 +2041,50 @@ 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();
|
||||
|
||||
@@ -2066,7 +2104,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -2096,9 +2134,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -532,6 +532,14 @@ 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(() => {
|
||||
@@ -826,7 +834,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
},
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -844,7 +852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
buffer,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1405,7 +1413,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 {
|
||||
} else if (ctrlCPressCount > 0) {
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
@@ -1424,7 +1432,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 {
|
||||
} else if (ctrlDPressCount > 0) {
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
@@ -1465,7 +1473,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
@@ -1492,9 +1500,6 @@ 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;
|
||||
}
|
||||
@@ -1538,9 +1543,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(activePtyId ||
|
||||
(isBackgroundShellVisible && backgroundShells.size > 0)) &&
|
||||
buffer.text.length === 0
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
@@ -1625,7 +1628,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
ideContextState,
|
||||
setCtrlCPressCount,
|
||||
buffer.text.length,
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
@@ -1647,7 +1649,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
|
||||
@@ -10,6 +10,7 @@ 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) => {
|
||||
@@ -42,7 +43,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -108,7 +108,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -129,7 +128,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -159,33 +157,49 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={10} // Small height to force scrolling
|
||||
/>,
|
||||
);
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={10} // Small height to force scrolling
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
if (expectedArrows) {
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
} else {
|
||||
expect(lastFrame()).not.toContain('▲');
|
||||
expect(lastFrame()).not.toContain('▼');
|
||||
}
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
@@ -194,7 +208,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -246,7 +259,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -261,7 +273,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -276,7 +287,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -308,7 +318,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -351,7 +360,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -420,7 +428,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -450,7 +457,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -496,7 +502,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -533,7 +538,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -567,7 +571,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -590,7 +593,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -613,7 +615,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -649,7 +650,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -681,7 +681,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -729,7 +728,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -780,7 +778,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -807,7 +804,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -854,7 +850,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -914,7 +909,6 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -946,4 +940,139 @@ 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');
|
||||
});
|
||||
|
||||
describe('Choice question placeholder', () => {
|
||||
it('uses placeholder for "Other" option when provided', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Select your preferred language:',
|
||||
header: 'Language',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'JavaScript', description: '' },
|
||||
],
|
||||
placeholder: 'Type another language...',
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
// Navigate to the "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default placeholder when not provided', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Select your preferred language:',
|
||||
header: 'Language',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'JavaScript', description: '' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
// Navigate to the "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { Question } from '@google/gemini-cli-core';
|
||||
@@ -21,6 +28,8 @@ 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 };
|
||||
@@ -121,7 +130,7 @@ interface AskUserDialogProps {
|
||||
/**
|
||||
* Height constraint for scrollable content.
|
||||
*/
|
||||
availableHeight: number;
|
||||
availableHeight?: number;
|
||||
}
|
||||
|
||||
interface ReviewViewProps {
|
||||
@@ -199,7 +208,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;
|
||||
@@ -216,6 +225,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 1; // 1 for cursor
|
||||
const bufferWidth =
|
||||
@@ -279,13 +289,20 @@ 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 = Math.max(1, availableHeight - overhead);
|
||||
const questionHeight =
|
||||
availableHeight && !isAlternateBuffer
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
@@ -389,7 +406,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;
|
||||
@@ -406,6 +423,7 @@ 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;
|
||||
@@ -711,18 +729,27 @@ 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 = 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),
|
||||
);
|
||||
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;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={TITLE_MARGIN}>
|
||||
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
{question.multiSelect && (
|
||||
@@ -753,7 +780,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
// Render inline text input for custom option
|
||||
if (optionItem.type === 'other') {
|
||||
const placeholder = 'Enter a custom value';
|
||||
const placeholder = question.placeholder || 'Enter a custom value';
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
@@ -824,8 +851,15 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onCancel,
|
||||
onActiveTextInputChange,
|
||||
width,
|
||||
availableHeight,
|
||||
availableHeight: availableHeightProp,
|
||||
}) => {
|
||||
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,6 +45,8 @@ 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');
|
||||
@@ -169,7 +171,16 @@ describe('InputPrompt', () => {
|
||||
allVisualLines: [''],
|
||||
visualCursor: [0, 0],
|
||||
visualScrollRow: 0,
|
||||
handleInput: vi.fn(),
|
||||
handleInput: vi.fn((key: Key) => {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (mockBuffer.text.length > 0) {
|
||||
mockBuffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn((offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
@@ -499,6 +510,23 @@ 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);
|
||||
|
||||
@@ -482,6 +482,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
@@ -604,6 +612,12 @@ 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);
|
||||
@@ -611,12 +625,6 @@ 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,14 +889,6 @@ 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)) {
|
||||
@@ -933,17 +933,23 @@ 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);
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
},
|
||||
@@ -979,10 +985,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
history,
|
||||
streamingState,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused,
|
||||
priority: true,
|
||||
});
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
|
||||
@@ -1391,7 +1391,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: true,
|
||||
useRipgrep: true,
|
||||
},
|
||||
security: {
|
||||
@@ -1484,7 +1483,6 @@ describe('SettingsDialog', () => {
|
||||
userSettings: {
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: false,
|
||||
useRipgrep: true,
|
||||
truncateToolOutputThreshold: 25000,
|
||||
truncateToolOutputLines: 500,
|
||||
@@ -1537,7 +1535,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: false,
|
||||
autoAccept: false,
|
||||
useRipgrep: false,
|
||||
},
|
||||
security: {
|
||||
|
||||
@@ -1,5 +1,76 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Enter a custom value
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Type another language...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
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?
|
||||
|
||||
@@ -104,19 +175,6 @@ 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() ?? 10}
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
|
||||
@@ -1515,6 +1515,50 @@ 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,6 +2930,13 @@ 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;
|
||||
@@ -2943,6 +2950,13 @@ 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;
|
||||
}
|
||||
@@ -2974,6 +2988,8 @@ export function useTextBuffer({
|
||||
cursorCol,
|
||||
lines,
|
||||
singleLine,
|
||||
setText,
|
||||
text,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,14 @@ import { coreEvents } from '@google/gemini-cli-core';
|
||||
// Mock modules
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('path');
|
||||
vi.mock('../../utils/sessionUtils.js');
|
||||
vi.mock('../../utils/sessionUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getSessionFiles: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
|
||||
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
|
||||
|
||||
@@ -13,11 +13,12 @@ import type {
|
||||
ConversationRecord,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { partListUnionToString, coreEvents } from '@google/gemini-cli-core';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import { MessageType, ToolCallStatus } from '../types.js';
|
||||
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
|
||||
export const useSessionBrowser = (
|
||||
config: Config,
|
||||
@@ -112,190 +113,3 @@ export const useSessionBrowser = (
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Add the message only if it has content
|
||||
const displayContentString = msg.displayContent
|
||||
? partListUnionToString(msg.displayContent)
|
||||
: undefined;
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
const uiText = displayContentString || contentString;
|
||||
|
||||
if (uiText.trim()) {
|
||||
let messageType: MessageType;
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
messageType = MessageType.USER;
|
||||
break;
|
||||
case 'info':
|
||||
messageType = MessageType.INFO;
|
||||
break;
|
||||
case 'error':
|
||||
messageType = MessageType.ERROR;
|
||||
break;
|
||||
case 'warning':
|
||||
messageType = MessageType.WARNING;
|
||||
break;
|
||||
case 'gemini':
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
default:
|
||||
checkExhaustive(msg);
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
}
|
||||
|
||||
uiHistory.push({
|
||||
type: messageType,
|
||||
text: uiText,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool calls if present
|
||||
if (
|
||||
msg.type !== 'user' &&
|
||||
'toolCalls' in msg &&
|
||||
msg.toolCalls &&
|
||||
msg.toolCalls.length > 0
|
||||
) {
|
||||
uiHistory.push({
|
||||
type: 'tool_group',
|
||||
tools: msg.toolCalls.map((tool) => ({
|
||||
callId: tool.id,
|
||||
name: tool.displayName || tool.name,
|
||||
description: tool.description || '',
|
||||
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
|
||||
status:
|
||||
tool.status === 'success'
|
||||
? ToolCallStatus.Success
|
||||
: ToolCallStatus.Error,
|
||||
resultDisplay: tool.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Gemini client history format
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip system/error messages and user slash commands
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
// Skip user slash commands
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add regular user message
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: Array.isArray(msg.content)
|
||||
? (msg.content as Part[])
|
||||
: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Handle Gemini messages with potential tool calls
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
// Create model message with function calls
|
||||
const modelParts: Part[] = [];
|
||||
|
||||
// Add text content if present
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
// Add function calls
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
// Create single function response message with all tool call responses
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
// Convert PartListUnion result to function response format
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// toolCall.result is an array containing properly formatted
|
||||
// function responses
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
// Fallback for non-array results
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add user message if we have function responses
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular Gemini message without tool calls
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import { checkExhaustive } from './checks.js';
|
||||
import {
|
||||
MessageType,
|
||||
ToolCallStatus,
|
||||
type HistoryItemWithoutId,
|
||||
} from '../ui/types.js';
|
||||
|
||||
/**
|
||||
* Constant for the resume "latest" identifier.
|
||||
@@ -514,3 +521,190 @@ export class SessionSelector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Add the message only if it has content
|
||||
const displayContentString = msg.displayContent
|
||||
? partListUnionToString(msg.displayContent)
|
||||
: undefined;
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
const uiText = displayContentString || contentString;
|
||||
|
||||
if (uiText.trim()) {
|
||||
let messageType: MessageType;
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
messageType = MessageType.USER;
|
||||
break;
|
||||
case 'info':
|
||||
messageType = MessageType.INFO;
|
||||
break;
|
||||
case 'error':
|
||||
messageType = MessageType.ERROR;
|
||||
break;
|
||||
case 'warning':
|
||||
messageType = MessageType.WARNING;
|
||||
break;
|
||||
case 'gemini':
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
default:
|
||||
checkExhaustive(msg);
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
}
|
||||
|
||||
uiHistory.push({
|
||||
type: messageType,
|
||||
text: uiText,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool calls if present
|
||||
if (
|
||||
msg.type !== 'user' &&
|
||||
'toolCalls' in msg &&
|
||||
msg.toolCalls &&
|
||||
msg.toolCalls.length > 0
|
||||
) {
|
||||
uiHistory.push({
|
||||
type: 'tool_group',
|
||||
tools: msg.toolCalls.map((tool) => ({
|
||||
callId: tool.id,
|
||||
name: tool.displayName || tool.name,
|
||||
description: tool.description || '',
|
||||
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
|
||||
status:
|
||||
tool.status === 'success'
|
||||
? ToolCallStatus.Success
|
||||
: ToolCallStatus.Error,
|
||||
resultDisplay: tool.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Gemini client history format
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip system/error messages and user slash commands
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
// Skip user slash commands
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add regular user message
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: Array.isArray(msg.content)
|
||||
? (msg.content as Part[])
|
||||
: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Handle Gemini messages with potential tool calls
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
// Create model message with function calls
|
||||
const modelParts: Part[] = [];
|
||||
|
||||
// Add text content if present
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
// Add function calls
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
// Create single function response message with all tool call responses
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
// Convert PartListUnion result to function response format
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// toolCall.result is an array containing properly formatted
|
||||
// function responses
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
// Fallback for non-array results
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add user message if we have function responses
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular Gemini message without tool calls
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './zedIntegration.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
SessionSelector: vi.fn(),
|
||||
convertSessionToHistoryFormats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent Session Resume', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
resumeChat: vi.fn().mockResolvedValue(undefined),
|
||||
getChat: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
|
||||
|
||||
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
|
||||
});
|
||||
|
||||
it('should advertise loadSession capability', async () => {
|
||||
const response = await agent.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should load a session, resume chat, and stream all message types', async () => {
|
||||
const sessionId = 'existing-session-id';
|
||||
const sessionData = {
|
||||
sessionId,
|
||||
messages: [
|
||||
{ type: 'user', content: [{ text: 'Hello' }] },
|
||||
{
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Hi there' }],
|
||||
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
displayName: 'Test Tool',
|
||||
status: 'success',
|
||||
resultDisplay: 'Tool output',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Trying a write' }],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'write_file',
|
||||
displayName: 'Write File',
|
||||
status: 'error',
|
||||
resultDisplay: 'Permission denied',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
});
|
||||
|
||||
(SessionSelector as unknown as Mock).mockImplementation(() => ({
|
||||
resolveSession: vi.fn().mockResolvedValue({
|
||||
sessionData,
|
||||
sessionPath: '/path/to/session.json',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockClientHistory = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
];
|
||||
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
|
||||
clientHistory: mockClientHistory,
|
||||
uiHistory: [],
|
||||
});
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response).toEqual({});
|
||||
|
||||
// Verify resumeChat received the correct arguments
|
||||
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
|
||||
mockClientHistory,
|
||||
expect.objectContaining({
|
||||
conversation: sessionData,
|
||||
filePath: '/path/to/session.json',
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// User message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: expect.objectContaining({ text: 'Hello' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent thought
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: expect.objectContaining({
|
||||
text: '**Thinking**\nabout greeting',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Agent message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: expect.objectContaining({ text: 'Hi there' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Successful tool call → 'completed'
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-1',
|
||||
status: 'completed',
|
||||
title: 'Test Tool',
|
||||
kind: 'read',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Tool output' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Failed tool call → 'failed'
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-2',
|
||||
status: 'failed',
|
||||
title: 'Write File',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('GeminiAgent', () => {
|
||||
|
||||
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||
expect(response.authMethods).toHaveLength(3);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(false);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should authenticate correctly', async () => {
|
||||
@@ -273,6 +273,7 @@ describe('Session', () => {
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'native',
|
||||
@@ -293,6 +294,7 @@ describe('Session', () => {
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue({}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ToolResult,
|
||||
ToolCallConfirmationDetails,
|
||||
FilterFilesOptions,
|
||||
ConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
AuthType,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
createWorkingStdio,
|
||||
startupProfiler,
|
||||
Kind,
|
||||
partListUnionToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -47,6 +49,10 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
|
||||
export async function runZedIntegration(
|
||||
config: Config,
|
||||
@@ -107,7 +113,7 @@ export class GeminiAgent {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentCapabilities: {
|
||||
loadSession: false,
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
@@ -146,23 +152,11 @@ export class GeminiAgent {
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
let isAuthenticated = false;
|
||||
if (this.settings.merged.security.auth.selectedType) {
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
this.settings.merged.security.auth.selectedType,
|
||||
);
|
||||
isAuthenticated = true;
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
@@ -184,6 +178,88 @@ export class GeminiAgent {
|
||||
};
|
||||
}
|
||||
|
||||
async loadSession({
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse> {
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
const { clientHistory } = convertSessionToHistoryFormats(
|
||||
sessionData.messages,
|
||||
);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
await geminiClient.resumeChat(clientHistory, {
|
||||
conversation: sessionData,
|
||||
filePath: sessionPath,
|
||||
});
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
): Promise<Config> {
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(selectedAuthType);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
@@ -228,8 +304,6 @@ export class GeminiAgent {
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -269,6 +343,73 @@ export class Session {
|
||||
this.pendingPrompt = null;
|
||||
}
|
||||
|
||||
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
|
||||
for (const msg of messages) {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
|
||||
if (msg.type === 'user') {
|
||||
if (contentString.trim()) {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: contentString },
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Thoughts
|
||||
if (msg.thoughts) {
|
||||
for (const thought of msg.thoughts) {
|
||||
const thoughtText = `**${thought.subject}**\n${thought.description}`;
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: thoughtText },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Message text
|
||||
if (contentString.trim()) {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: contentString },
|
||||
});
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if (msg.toolCalls) {
|
||||
for (const toolCall of msg.toolCalls) {
|
||||
const toolCallContent: acp.ToolCallContent[] = [];
|
||||
if (toolCall.resultDisplay) {
|
||||
if (typeof toolCall.resultDisplay === 'string') {
|
||||
toolCallContent.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolCall.resultDisplay },
|
||||
});
|
||||
} else if ('fileName' in toolCall.resultDisplay) {
|
||||
toolCallContent.push({
|
||||
type: 'diff',
|
||||
path: toolCall.resultDisplay.fileName,
|
||||
oldText: toolCall.resultDisplay.originalContent,
|
||||
newText: toolCall.resultDisplay.newContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tool = this.config.getToolRegistry().getTool(toolCall.name);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: toolCall.id,
|
||||
status: toolCall.status === 'success' ? 'completed' : 'failed',
|
||||
title: toolCall.displayName || toolCall.name,
|
||||
content: toolCallContent,
|
||||
kind: tool ? toAcpToolKind(tool.kind) : 'other',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
@@ -533,6 +674,33 @@ export class Session {
|
||||
),
|
||||
);
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId,
|
||||
name: fc.name,
|
||||
args,
|
||||
isClientInitiated: false,
|
||||
prompt_id: promptId,
|
||||
},
|
||||
tool,
|
||||
invocation,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: convertToFunctionResponse(
|
||||
fc.name,
|
||||
callId,
|
||||
toolResult.llmContent,
|
||||
this.config.getActiveModel(),
|
||||
),
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return convertToFunctionResponse(
|
||||
fc.name,
|
||||
callId,
|
||||
@@ -551,6 +719,35 @@ export class Session {
|
||||
],
|
||||
});
|
||||
|
||||
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
|
||||
{
|
||||
status: 'error',
|
||||
request: {
|
||||
callId,
|
||||
name: fc.name,
|
||||
args,
|
||||
isClientInitiated: false,
|
||||
prompt_id: promptId,
|
||||
},
|
||||
tool,
|
||||
response: {
|
||||
callId,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: fc.name ?? '',
|
||||
response: { error: error.message },
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: error.message,
|
||||
error,
|
||||
errorType: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export interface Question {
|
||||
options?: QuestionOption[];
|
||||
/** Allow multiple selections. Only applies when type='choice'. */
|
||||
multiSelect?: boolean;
|
||||
/** Placeholder hint text. Only applies when type='text'. */
|
||||
/** Placeholder hint text. For type='text', shown in the input field. For type='choice', shown in the "Other" custom input. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1398,6 +1398,100 @@ 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[] = [
|
||||
|
||||
@@ -152,14 +152,28 @@ 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 don't blame a specific rule here, unless the input rule was stricter.
|
||||
// We return the rule that matched so the evaluation loop terminates.
|
||||
return {
|
||||
decision: this.applyNonInteractiveMode(PolicyDecision.ASK_USER),
|
||||
rule: undefined,
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,24 @@ describe('AskUserTool', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept placeholder for choice type', () => {
|
||||
const result = tool.validateToolParams({
|
||||
questions: [
|
||||
{
|
||||
question: 'Which language?',
|
||||
header: 'Language',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Typed JavaScript' },
|
||||
{ label: 'JavaScript', description: 'Dynamic language' },
|
||||
],
|
||||
placeholder: 'Type another language...',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error if option has empty label', () => {
|
||||
const result = tool.validateToolParams({
|
||||
questions: [
|
||||
|
||||
@@ -90,7 +90,7 @@ export class AskUserTool extends BaseDeclarativeTool<
|
||||
placeholder: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Only applies when type='text'. Hint text shown in the input field.",
|
||||
"Hint text shown in the input field. For type='text', shown in the main input. For type='choice', shown in the 'Other' custom input.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -101,33 +101,33 @@ describe('retryWithBackoff', () => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
const promise = retryWithBackoff(mockFn);
|
||||
|
||||
await Promise.all([
|
||||
expect(promise).rejects.toThrow('Simulated error attempt 10'),
|
||||
expect(promise).rejects.toThrow('Simulated error attempt 3'),
|
||||
vi.runAllTimersAsync(),
|
||||
]);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(10);
|
||||
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
const promise = retryWithBackoff(mockFn, { maxAttempts: undefined });
|
||||
|
||||
// Expect it to fail with the error from the 10th attempt.
|
||||
// Expect it to fail with the error from the 3rd attempt.
|
||||
await Promise.all([
|
||||
expect(promise).rejects.toThrow('Simulated error attempt 10'),
|
||||
expect(promise).rejects.toThrow('Simulated error attempt 3'),
|
||||
vi.runAllTimersAsync(),
|
||||
]);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(10);
|
||||
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not retry if shouldRetry returns false', async () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface RetryOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
maxAttempts: 10,
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 5000,
|
||||
maxDelayMs: 30000, // 30 seconds
|
||||
shouldRetryOnError: isRetryableError,
|
||||
|
||||
@@ -1112,13 +1112,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"autoAccept": {
|
||||
"title": "Auto Accept",
|
||||
"description": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).",
|
||||
"markdownDescription": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"approvalMode": {
|
||||
"title": "Approval Mode",
|
||||
"description": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet.",
|
||||
|
||||
Reference in New Issue
Block a user