mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
26 Commits
scratch_si
...
fix_shell
| Author | SHA1 | Date | |
|---|---|---|---|
| e2981da430 | |||
| 1dbb9af41c | |||
| b4724a1eed | |||
| 4eea909f28 | |||
| af8462f76d | |||
| 2f4242fb60 | |||
| aeecf5a9e7 | |||
| dfa5c79a47 | |||
| d6770d2ae9 | |||
| c1d7bc4c8c | |||
| 39b393abfb | |||
| 5b3819073f | |||
| 926afab788 | |||
| 555b95eb09 | |||
| 0c6ebbd1f5 | |||
| 25803e05fd | |||
| 774ae220be | |||
| 813e0c18ac | |||
| 3f6cec22e6 | |||
| 3966f3c053 | |||
| 2e3cbd6363 | |||
| 8b1dc15182 | |||
| fa9aee2bf0 | |||
| 6628cbb39d | |||
| aa9163da60 | |||
| ec0f23ae03 |
@@ -155,7 +155,10 @@ jobs:
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
}
|
||||
},
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)"
|
||||
],
|
||||
}
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
@@ -225,7 +225,7 @@ priority = 100
|
||||
modes = ["plan"]
|
||||
# Adjust the pattern to match your custom directory.
|
||||
# This example matches any .md file in a .gemini/plans directory within the project.
|
||||
argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\""
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
```
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
|
||||
@@ -64,6 +64,16 @@ and more.
|
||||
|
||||
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
You can check your current token usage and quota information using the
|
||||
`/stats model` command. This command provides a snapshot of your current
|
||||
session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Follow the [File management](../cli/tutorials/file-management.md) guide to
|
||||
|
||||
@@ -32,6 +32,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
conversation state interactively, or resuming a previous state from a later
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`debug`**
|
||||
- **Description:** Export the most recent API request as a JSON payload.
|
||||
- **`delete <tag>`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **`list`**
|
||||
@@ -128,8 +130,29 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### `/extensions`
|
||||
|
||||
- **Description:** Lists all active extensions in the current Gemini CLI
|
||||
session. See [Gemini CLI Extensions](../extensions/index.md).
|
||||
- **Description:** Manage extensions. See
|
||||
[Gemini CLI Extensions](../extensions/index.md).
|
||||
- **Sub-commands:**
|
||||
- **`config`**:
|
||||
- **Description:** Configure extension settings.
|
||||
- **`disable`**:
|
||||
- **Description:** Disable an extension.
|
||||
- **`enable`**:
|
||||
- **Description:** Enable an extension.
|
||||
- **`explore`**:
|
||||
- **Description:** Open extensions page in your browser.
|
||||
- **`install`**:
|
||||
- **Description:** Install an extension from a git repo or local path.
|
||||
- **`link`**:
|
||||
- **Description:** Link an extension from a local path.
|
||||
- **`list`**:
|
||||
- **Description:** List active extensions.
|
||||
- **`restart`**:
|
||||
- **Description:** Restart all extensions.
|
||||
- **`uninstall`**:
|
||||
- **Description:** Uninstall an extension.
|
||||
- **`update`**:
|
||||
- **Description:** Update extensions. Usage: update <extension-names>|--all
|
||||
|
||||
### `/help` (or `/?`)
|
||||
|
||||
@@ -184,6 +207,10 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
servers that support OAuth authentication.
|
||||
- **`desc`**
|
||||
- **Description:** List configured MCP servers and tools with descriptions.
|
||||
- **`disable`**
|
||||
- **Description:** Disable an MCP server.
|
||||
- **`enable`**
|
||||
- **Description:** Enable a disabled MCP server.
|
||||
- **`list`** or **`ls`**:
|
||||
- **Description:** List configured MCP servers and tools. This is the
|
||||
default action if no subcommand is specified.
|
||||
@@ -221,7 +248,21 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### `/model`
|
||||
|
||||
- **Description:** Opens a dialog to choose your Gemini model.
|
||||
- **Description:** Manage model configuration.
|
||||
- **Sub-commands:**
|
||||
- **`manage`**:
|
||||
- **Description:** Opens a dialog to configure the model.
|
||||
- **`set`**:
|
||||
- **Description:** Set the model to use.
|
||||
- **Usage:** `/model set <model-name> [--persist]`
|
||||
|
||||
### `/permissions`
|
||||
|
||||
- **Description:** Manage folder trust settings and other permissions.
|
||||
- **Sub-commands:**
|
||||
- **`trust`**:
|
||||
- **Description:** Manage folder trust settings.
|
||||
- **Usage:** `/permissions trust [<directory-path>]`
|
||||
|
||||
### `/plan`
|
||||
|
||||
@@ -331,10 +372,16 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
### `/stats`
|
||||
|
||||
- **Description:** Display detailed statistics for the current Gemini CLI
|
||||
session, including token usage, cached token savings (when available), and
|
||||
session duration. Note: Cached token information is only displayed when cached
|
||||
tokens are being used, which occurs with API key authentication but not with
|
||||
OAuth authentication at this time.
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`session`**:
|
||||
- **Description:** Show session-specific usage statistics, including
|
||||
duration, tool calls, and performance metrics. This is the default view.
|
||||
- **`model`**:
|
||||
- **Description:** Show model-specific usage statistics, including token
|
||||
counts and quota information.
|
||||
- **`tools`**:
|
||||
- **Description:** Show tool-specific usage statistics.
|
||||
|
||||
### `/terminal-setup`
|
||||
|
||||
|
||||
@@ -135,6 +135,18 @@ Flow video editor). These plans do not apply to the API usage which powers the
|
||||
Gemini CLI. Supporting these plans is under active consideration for future
|
||||
support.
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
You can check your current token usage and quota information using the
|
||||
`/stats model` command. This command provides a snapshot of your current
|
||||
session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
A summary of model usage is also presented on exit at the end of a session.
|
||||
|
||||
## Tips to avoid high costs
|
||||
|
||||
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
|
||||
@@ -151,8 +163,3 @@ costs.
|
||||
models directly.
|
||||
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
|
||||
managing Gemini models with specific security and control requirements.
|
||||
|
||||
## Understanding your usage
|
||||
|
||||
A summary of model usage is available through the `/stats` command and presented
|
||||
on exit at the end of a session.
|
||||
|
||||
+1
-15
@@ -72,14 +72,9 @@
|
||||
"slug": "docs/extensions/index"
|
||||
},
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Help", "link": "/docs/reference/commands/#help-or" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{
|
||||
"label": "Memory management",
|
||||
"link": "/docs/reference/commands/#memory"
|
||||
},
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
|
||||
@@ -96,17 +91,8 @@
|
||||
{ "label": "Rewind", "slug": "docs/cli/rewind" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "Shell",
|
||||
"link": "/docs/reference/commands/#shells-or-bashes"
|
||||
},
|
||||
{
|
||||
"label": "Stats",
|
||||
"link": "/docs/reference/commands/#stats"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Tools", "link": "/docs/reference/commands/#tools" }
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+1
@@ -17419,6 +17419,7 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"systeminformation": "^5.25.11",
|
||||
"tree-sitter-bash": "^0.25.0",
|
||||
"undici": "^7.10.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAllMCPServerStatuses,
|
||||
MCPServerStatus,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
parseAndFormatApiError,
|
||||
safeLiteralReplace,
|
||||
DEFAULT_GUI_EDITOR,
|
||||
@@ -727,16 +728,17 @@ export class Task {
|
||||
// Block scope for lexical declaration
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
|
||||
const errorMessage =
|
||||
errorEvent.value?.error.message ?? 'Unknown error from LLM stream';
|
||||
const errorMessage = errorEvent.value?.error
|
||||
? getErrorMessage(errorEvent.value.error)
|
||||
: 'Unknown error from LLM stream';
|
||||
logger.error(
|
||||
'[Task] Received error event from LLM stream:',
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
|
||||
if (errorEvent.value) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value);
|
||||
if (errorEvent.value?.error) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value.error);
|
||||
}
|
||||
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
|
||||
this.setTaskStateAndPublishUpdate(
|
||||
|
||||
+16
-1
@@ -36,7 +36,21 @@ process.on('uncaughtException', (error) => {
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
await runExitCleanup();
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
writeToStderr('Cleanup timed out, forcing exit...\n');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} catch (cleanupError) {
|
||||
writeToStderr(
|
||||
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
|
||||
if (error instanceof FatalError) {
|
||||
let errorMessage = error.message;
|
||||
@@ -46,6 +60,7 @@ main().catch(async (error) => {
|
||||
writeToStderr(errorMessage + '\n');
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
|
||||
writeToStderr('An unexpected critical error occurred:');
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
|
||||
@@ -339,6 +339,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
|
||||
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
|
||||
'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
|
||||
'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
|
||||
];
|
||||
|
||||
for (const file_path of validPaths) {
|
||||
@@ -364,7 +366,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
const invalidPaths = [
|
||||
'/project/src/file.ts', // Workspace
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
|
||||
'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
|
||||
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
|
||||
];
|
||||
|
||||
|
||||
@@ -411,6 +411,73 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls ');
|
||||
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'dir1', value: 'dir1' },
|
||||
{ label: 'dir2', value: 'dir2' },
|
||||
],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// Press Enter without navigating — should dismiss suggestions and fall
|
||||
// through to the main submit handler.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed)
|
||||
});
|
||||
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls ');
|
||||
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'dir1', value: 'dir1' },
|
||||
{ label: 'dir2', value: 'dir2' },
|
||||
],
|
||||
activeSuggestionIndex: 1,
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// Press ArrowDown to navigate, then Enter to accept
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter — should accept navigated suggestion
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT call shell history methods when not in shell mode', async () => {
|
||||
props.buffer.setText('some text');
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
|
||||
@@ -259,6 +259,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
@@ -615,6 +616,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setSuppressCompletion(
|
||||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||||
);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
}
|
||||
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
@@ -648,7 +650,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
Boolean(completion.promptCompletion.text) ||
|
||||
reverseSearchActive ||
|
||||
commandSearchActive;
|
||||
if (isPlainTab) {
|
||||
|
||||
if (isPlainTab && shellModeActive) {
|
||||
resetPlainTabPress();
|
||||
if (!completion.showSuggestions) {
|
||||
setSuppressCompletion(false);
|
||||
}
|
||||
} else if (isPlainTab) {
|
||||
if (!hasTabCompletionInteraction) {
|
||||
if (registerPlainTabPress() === 2) {
|
||||
toggleCleanUiDetailsVisible();
|
||||
@@ -892,7 +900,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.isPerfectMatch &&
|
||||
keyMatchers[Command.SUBMIT](key) &&
|
||||
recentUnsafePasteTime === null &&
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
(!completion.showSuggestions ||
|
||||
(completion.activeSuggestionIndex <= 0 &&
|
||||
!hasUserNavigatedSuggestions.current))
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return true;
|
||||
@@ -908,11 +918,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
hasUserNavigatedSuggestions.current = true;
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||
completion.navigateDown();
|
||||
hasUserNavigatedSuggestions.current = true;
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return true;
|
||||
}
|
||||
@@ -930,6 +942,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const isEnterKey = key.name === 'return' && !key.ctrl;
|
||||
|
||||
if (isEnterKey && shellModeActive) {
|
||||
if (hasUserNavigatedSuggestions.current) {
|
||||
completion.handleAutocomplete(
|
||||
completion.activeSuggestionIndex,
|
||||
);
|
||||
setExpandedSuggestionIndex(-1);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
return true;
|
||||
}
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
hasUserNavigatedSuggestions.current = false;
|
||||
if (buffer.text.trim()) {
|
||||
handleSubmit(buffer.text);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isEnterKey && buffer.text.startsWith('/')) {
|
||||
const { isArgumentCompletion, leafCommand } =
|
||||
completion.slashCompletionRange;
|
||||
@@ -1386,7 +1416,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
scrollOffset={activeCompletion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
mode={
|
||||
completion.completionMode === CompletionMode.AT
|
||||
completion.completionMode === CompletionMode.AT ||
|
||||
completion.completionMode === CompletionMode.SHELL
|
||||
? 'reverse'
|
||||
: buffer.text.startsWith('/') &&
|
||||
!reverseSearchActive &&
|
||||
|
||||
@@ -173,6 +173,28 @@ describe('TabHeader', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates long headers when not selected', async () => {
|
||||
const longTabs: Tab[] = [
|
||||
{ key: '0', header: 'ThisIsAVeryLongHeaderThatShouldBeTruncated' },
|
||||
{ key: '1', header: 'AnotherVeryLongHeader' },
|
||||
];
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<TabHeader tabs={longTabs} currentIndex={0} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
|
||||
// Current tab (index 0) should NOT be truncated
|
||||
expect(frame).toContain('ThisIsAVeryLongHeaderThatShouldBeTruncated');
|
||||
|
||||
// Inactive tab (index 1) SHOULD be truncated to 16 chars (15 chars + …)
|
||||
const expectedTruncated = 'AnotherVeryLong…';
|
||||
expect(frame).toContain(expectedTruncated);
|
||||
expect(frame).not.toContain('AnotherVeryLongHeader');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('falls back to default when renderStatusIcon returns undefined', async () => {
|
||||
const renderStatusIcon = () => undefined;
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
|
||||
@@ -94,16 +94,19 @@ export function TabHeader({
|
||||
{showStatusIcons && (
|
||||
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
|
||||
)}
|
||||
<Text
|
||||
color={
|
||||
i === currentIndex ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
bold={i === currentIndex}
|
||||
underline={i === currentIndex}
|
||||
aria-current={i === currentIndex ? 'step' : undefined}
|
||||
>
|
||||
{tab.header}
|
||||
</Text>
|
||||
<Box maxWidth={i !== currentIndex ? 16 : 100}>
|
||||
<Text
|
||||
color={
|
||||
i === currentIndex ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
bold={i === currentIndex}
|
||||
underline={i === currentIndex}
|
||||
aria-current={i === currentIndex ? 'step' : undefined}
|
||||
wrap="truncate"
|
||||
>
|
||||
{tab.header}
|
||||
</Text>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{showArrows && <Text color={theme.text.secondary}>{' →'}</Text>}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { gitProvider } from './gitProvider.js';
|
||||
import * as childProcess from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('gitProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('suggests git subcommands for cursorIndex 1', async () => {
|
||||
const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp');
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]),
|
||||
);
|
||||
expect(
|
||||
result.suggestions.find((s) => s.value === 'commit'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('suggests branch names for checkout at cursorIndex 2', async () => {
|
||||
vi.mocked(childProcess.execFile).mockImplementation(
|
||||
(_cmd, _args, _opts, cb: unknown) => {
|
||||
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||
error: Error | null,
|
||||
result: { stdout: string },
|
||||
) => void;
|
||||
callback(null, {
|
||||
stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n',
|
||||
});
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'checkout', 'feat'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
expect(result.suggestions[0].label).toBe('feature-branch');
|
||||
expect(result.suggestions[0].value).toBe('feature-branch');
|
||||
expect(childProcess.execFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['branch', '--format=%(refname:short)'],
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes branch names with shell metacharacters', async () => {
|
||||
vi.mocked(childProcess.execFile).mockImplementation(
|
||||
(_cmd, _args, _opts, cb: unknown) => {
|
||||
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||
error: Error | null,
|
||||
result: { stdout: string },
|
||||
) => void;
|
||||
callback(null, { stdout: 'main\nbranch(with)special\n' });
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'checkout', 'branch('],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
expect(result.suggestions[0].label).toBe('branch(with)special');
|
||||
|
||||
// On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested,
|
||||
// we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath).
|
||||
// Let's match the value against either unescaped (win) or escaped (unix).
|
||||
const isWin = process.platform === 'win32';
|
||||
expect(result.suggestions[0].value).toBe(
|
||||
isWin ? 'branch(with)special' : 'branch\\(with\\)special',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty results if git branch fails', async () => {
|
||||
vi.mocked(childProcess.execFile).mockImplementation(
|
||||
(_cmd, _args, _opts, cb: unknown) => {
|
||||
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||
error: Error,
|
||||
stdout?: string,
|
||||
) => void;
|
||||
callback(new Error('Not a git repository'));
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'checkout', ''],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns non-exclusive for unrecognized position', async () => {
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'commit', '-m', 'some message'],
|
||||
3,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(false);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||
import { escapeShellPath } from '../useShellCompletion.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const GIT_SUBCOMMANDS = [
|
||||
'add',
|
||||
'branch',
|
||||
'checkout',
|
||||
'commit',
|
||||
'diff',
|
||||
'merge',
|
||||
'pull',
|
||||
'push',
|
||||
'rebase',
|
||||
'status',
|
||||
'switch',
|
||||
];
|
||||
|
||||
export const gitProvider: ShellCompletionProvider = {
|
||||
command: 'git',
|
||||
async getCompletions(
|
||||
tokens: string[],
|
||||
cursorIndex: number,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult> {
|
||||
// We are completing the first argument (subcommand)
|
||||
if (cursorIndex === 1) {
|
||||
const partial = tokens[1] || '';
|
||||
return {
|
||||
suggestions: GIT_SUBCOMMANDS.filter((cmd) =>
|
||||
cmd.startsWith(partial),
|
||||
).map((cmd) => ({
|
||||
label: cmd,
|
||||
value: cmd,
|
||||
description: 'git command',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
}
|
||||
|
||||
// We are completing the second argument (e.g. branch name)
|
||||
if (cursorIndex === 2) {
|
||||
const subcommand = tokens[1];
|
||||
if (
|
||||
subcommand === 'checkout' ||
|
||||
subcommand === 'switch' ||
|
||||
subcommand === 'merge' ||
|
||||
subcommand === 'branch'
|
||||
) {
|
||||
const partial = tokens[2] || '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['branch', '--format=%(refname:short)'],
|
||||
{ cwd, signal },
|
||||
);
|
||||
|
||||
const branches = stdout
|
||||
.split('\n')
|
||||
.map((b) => b.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
suggestions: branches
|
||||
.filter((b) => b.startsWith(partial))
|
||||
.map((b) => ({
|
||||
label: b,
|
||||
value: escapeShellPath(b),
|
||||
description: 'branch',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
} catch {
|
||||
// If git fails (e.g. not a git repo), return nothing
|
||||
return { suggestions: [], exclusive: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unhandled git argument, fallback to default file completions
|
||||
return { suggestions: [], exclusive: false };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||
import { gitProvider } from './gitProvider.js';
|
||||
import { npmProvider } from './npmProvider.js';
|
||||
|
||||
const providers: ShellCompletionProvider[] = [gitProvider, npmProvider];
|
||||
|
||||
export async function getArgumentCompletions(
|
||||
commandToken: string,
|
||||
tokens: string[],
|
||||
cursorIndex: number,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult | null> {
|
||||
const provider = providers.find((p) => p.command === commandToken);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
return provider.getCompletions(tokens, cursorIndex, cwd, signal);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { npmProvider } from './npmProvider.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('npmProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('suggests npm subcommands for cursorIndex 1', async () => {
|
||||
const result = await npmProvider.getCompletions(['npm', 'ru'], 1, '/tmp');
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toEqual([
|
||||
expect.objectContaining({ value: 'run' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests package.json scripts for npm run at cursorIndex 2', async () => {
|
||||
const mockPackageJson = {
|
||||
scripts: {
|
||||
start: 'node index.js',
|
||||
build: 'tsc',
|
||||
'build:dev': 'tsc --watch',
|
||||
},
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
|
||||
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'run', 'bu'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(2);
|
||||
expect(result.suggestions[0].label).toBe('build');
|
||||
expect(result.suggestions[0].value).toBe('build');
|
||||
expect(result.suggestions[1].label).toBe('build:dev');
|
||||
expect(result.suggestions[1].value).toBe('build:dev');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('package.json'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes script names with shell metacharacters', async () => {
|
||||
const mockPackageJson = {
|
||||
scripts: {
|
||||
'build(prod)': 'tsc',
|
||||
'test:watch': 'vitest',
|
||||
},
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
|
||||
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'run', 'bu'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
expect(result.suggestions[0].label).toBe('build(prod)');
|
||||
|
||||
// Windows does not escape spaces/parens in cmds by default in our function, but Unix does.
|
||||
const isWin = process.platform === 'win32';
|
||||
expect(result.suggestions[0].value).toBe(
|
||||
isWin ? 'build(prod)' : 'build\\(prod\\)',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles missing package.json gracefully', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'run', ''],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns non-exclusive for unrecognized position', async () => {
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'install', 'react'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(false);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||
import { escapeShellPath } from '../useShellCompletion.js';
|
||||
|
||||
const NPM_SUBCOMMANDS = [
|
||||
'build',
|
||||
'ci',
|
||||
'dev',
|
||||
'install',
|
||||
'publish',
|
||||
'run',
|
||||
'start',
|
||||
'test',
|
||||
];
|
||||
|
||||
export const npmProvider: ShellCompletionProvider = {
|
||||
command: 'npm',
|
||||
async getCompletions(
|
||||
tokens: string[],
|
||||
cursorIndex: number,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult> {
|
||||
if (cursorIndex === 1) {
|
||||
const partial = tokens[1] || '';
|
||||
return {
|
||||
suggestions: NPM_SUBCOMMANDS.filter((cmd) =>
|
||||
cmd.startsWith(partial),
|
||||
).map((cmd) => ({
|
||||
label: cmd,
|
||||
value: cmd,
|
||||
description: 'npm command',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (cursorIndex === 2 && tokens[1] === 'run') {
|
||||
const partial = tokens[2] || '';
|
||||
try {
|
||||
if (signal?.aborted) return { suggestions: [], exclusive: true };
|
||||
|
||||
const pkgJsonPath = path.join(cwd, 'package.json');
|
||||
const content = await fs.readFile(pkgJsonPath, 'utf8');
|
||||
const pkg = JSON.parse(content) as unknown;
|
||||
|
||||
const scripts =
|
||||
pkg &&
|
||||
typeof pkg === 'object' &&
|
||||
'scripts' in pkg &&
|
||||
pkg.scripts &&
|
||||
typeof pkg.scripts === 'object'
|
||||
? Object.keys(pkg.scripts)
|
||||
: [];
|
||||
|
||||
return {
|
||||
suggestions: scripts
|
||||
.filter((s) => s.startsWith(partial))
|
||||
.map((s) => ({
|
||||
label: s,
|
||||
value: escapeShellPath(s),
|
||||
description: 'npm script',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
} catch {
|
||||
// No package.json or invalid JSON
|
||||
return { suggestions: [], exclusive: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions: [], exclusive: false };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Suggestion } from '../../components/SuggestionsDisplay.js';
|
||||
|
||||
export interface CompletionResult {
|
||||
suggestions: Suggestion[];
|
||||
// If true, this prevents the shell from appending generic file/path completions
|
||||
// to this list. Use this when the tool expects ONLY specific values (e.g. branches).
|
||||
exclusive?: boolean;
|
||||
}
|
||||
|
||||
export interface ShellCompletionProvider {
|
||||
command: string; // The command trigger, e.g., 'git' or 'npm'
|
||||
getCompletions(
|
||||
tokens: string[], // List of arguments parsed from the input
|
||||
cursorIndex: number, // Which token index the cursor is currently on
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult>;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import type { UseAtCompletionProps } from './useAtCompletion.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import type { UseSlashCompletionProps } from './useSlashCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { useShellCompletion } from './useShellCompletion.js';
|
||||
|
||||
vi.mock('./useAtCompletion', () => ({
|
||||
useAtCompletion: vi.fn(),
|
||||
@@ -40,19 +41,35 @@ vi.mock('./useSlashCompletion', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./useShellCompletion', () => ({
|
||||
useShellCompletion: vi.fn(() => ({
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
query: '',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Helper to set up mocks in a consistent way for both child hooks
|
||||
const setupMocks = ({
|
||||
atSuggestions = [],
|
||||
slashSuggestions = [],
|
||||
shellSuggestions = [],
|
||||
isLoading = false,
|
||||
isPerfectMatch = false,
|
||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
|
||||
}: {
|
||||
atSuggestions?: Suggestion[];
|
||||
slashSuggestions?: Suggestion[];
|
||||
shellSuggestions?: Suggestion[];
|
||||
isLoading?: boolean;
|
||||
isPerfectMatch?: boolean;
|
||||
slashCompletionRange?: { completionStart: number; completionEnd: number };
|
||||
shellCompletionRange?: {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
};
|
||||
}) => {
|
||||
// Mock for @-completions
|
||||
(useAtCompletion as Mock).mockImplementation(
|
||||
@@ -89,11 +106,25 @@ const setupMocks = ({
|
||||
return slashCompletionRange;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock for shell completions
|
||||
(useShellCompletion as Mock).mockImplementation(
|
||||
({ enabled, setSuggestions, setIsLoadingSuggestions }) => {
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
setIsLoadingSuggestions(isLoading);
|
||||
setSuggestions(shellSuggestions);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
return shellCompletionRange;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('useCommandCompletion', () => {
|
||||
const mockCommandContext = {} as CommandContext;
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => false,
|
||||
getGeminiClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
const testRootDir = '/';
|
||||
@@ -498,6 +529,7 @@ describe('useCommandCompletion', () => {
|
||||
describe('prompt completion filtering', () => {
|
||||
it('should not trigger prompt completion for line comments', async () => {
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => true,
|
||||
getGeminiClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -530,6 +562,7 @@ describe('useCommandCompletion', () => {
|
||||
|
||||
it('should not trigger prompt completion for block comments', async () => {
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => true,
|
||||
getGeminiClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -564,6 +597,7 @@ describe('useCommandCompletion', () => {
|
||||
|
||||
it('should trigger prompt completion for regular text when enabled', async () => {
|
||||
const mockConfig = {
|
||||
getEnablePromptCompletion: () => true,
|
||||
getGeminiClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { useShellCompletion } from './useShellCompletion.js';
|
||||
import type { PromptCompletion } from './usePromptCompletion.js';
|
||||
import {
|
||||
usePromptCompletion,
|
||||
@@ -26,6 +27,7 @@ export enum CompletionMode {
|
||||
AT = 'AT',
|
||||
SLASH = 'SLASH',
|
||||
PROMPT = 'PROMPT',
|
||||
SHELL = 'SHELL',
|
||||
}
|
||||
|
||||
export interface UseCommandCompletionReturn {
|
||||
@@ -99,89 +101,105 @@ export function useCommandCompletion({
|
||||
const cursorRow = buffer.cursor[0];
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
const { completionMode, query, completionStart, completionEnd } =
|
||||
useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
// FIRST: Check for @ completion (scan backwards from cursor)
|
||||
// This must happen before slash command check so that `/cmd @file`
|
||||
// triggers file completion, not just slash command completion.
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
break;
|
||||
}
|
||||
} else if (char === '@') {
|
||||
let end = codePoints.length;
|
||||
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||
if (codePoints[i] === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount % 2 === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pathStart = i + 1;
|
||||
const partialPath = currentLine.substring(pathStart, end);
|
||||
return {
|
||||
completionMode: CompletionMode.AT,
|
||||
query: partialPath,
|
||||
completionStart: pathStart,
|
||||
completionEnd: end,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// THEN: Check for slash command (only if no @ completion is active)
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return {
|
||||
completionMode: CompletionMode.SLASH,
|
||||
query: currentLine,
|
||||
completionStart: 0,
|
||||
completionEnd: currentLine.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for prompt completion - only if enabled
|
||||
const trimmedText = buffer.text.trim();
|
||||
const isPromptCompletionEnabled = false;
|
||||
if (
|
||||
isPromptCompletionEnabled &&
|
||||
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
|
||||
!isSlashCommand(trimmedText) &&
|
||||
!trimmedText.includes('@')
|
||||
) {
|
||||
return {
|
||||
completionMode: CompletionMode.PROMPT,
|
||||
query: trimmedText,
|
||||
completionStart: 0,
|
||||
completionEnd: trimmedText.length,
|
||||
};
|
||||
}
|
||||
const {
|
||||
completionMode,
|
||||
query: memoQuery,
|
||||
completionStart,
|
||||
completionEnd,
|
||||
} = useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
if (shellModeActive) {
|
||||
return {
|
||||
completionMode: CompletionMode.IDLE,
|
||||
query: null,
|
||||
completionMode:
|
||||
currentLine.trim().length === 0
|
||||
? CompletionMode.IDLE
|
||||
: CompletionMode.SHELL,
|
||||
query: '',
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
};
|
||||
}, [cursorRow, cursorCol, buffer.lines, buffer.text]);
|
||||
}
|
||||
|
||||
// FIRST: Check for @ completion (scan backwards from cursor)
|
||||
// This must happen before slash command check so that `/cmd @file`
|
||||
// triggers file completion, not just slash command completion.
|
||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
break;
|
||||
}
|
||||
} else if (char === '@') {
|
||||
let end = codePoints.length;
|
||||
for (let i = cursorCol; i < codePoints.length; i++) {
|
||||
if (codePoints[i] === ' ') {
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
if (backslashCount % 2 === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pathStart = i + 1;
|
||||
const partialPath = currentLine.substring(pathStart, end);
|
||||
return {
|
||||
completionMode: CompletionMode.AT,
|
||||
query: partialPath,
|
||||
completionStart: pathStart,
|
||||
completionEnd: end,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// THEN: Check for slash command (only if no @ completion is active)
|
||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||
return {
|
||||
completionMode: CompletionMode.SLASH,
|
||||
query: currentLine,
|
||||
completionStart: 0,
|
||||
completionEnd: currentLine.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for prompt completion - only if enabled
|
||||
const trimmedText = buffer.text.trim();
|
||||
const isPromptCompletionEnabled = false;
|
||||
if (
|
||||
isPromptCompletionEnabled &&
|
||||
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
|
||||
!isSlashCommand(trimmedText) &&
|
||||
!trimmedText.includes('@')
|
||||
) {
|
||||
return {
|
||||
completionMode: CompletionMode.PROMPT,
|
||||
query: trimmedText,
|
||||
completionStart: 0,
|
||||
completionEnd: trimmedText.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
completionMode: CompletionMode.IDLE,
|
||||
query: null,
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
};
|
||||
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
|
||||
|
||||
useAtCompletion({
|
||||
enabled: active && completionMode === CompletionMode.AT,
|
||||
pattern: query || '',
|
||||
pattern: memoQuery || '',
|
||||
config,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
@@ -191,7 +209,7 @@ export function useCommandCompletion({
|
||||
const slashCompletionRange = useSlashCompletion({
|
||||
enabled:
|
||||
active && completionMode === CompletionMode.SLASH && !shellModeActive,
|
||||
query,
|
||||
query: memoQuery,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
setSuggestions,
|
||||
@@ -199,9 +217,22 @@ export function useCommandCompletion({
|
||||
setIsPerfectMatch,
|
||||
});
|
||||
|
||||
const shellCompletionRange = useShellCompletion({
|
||||
enabled: active && completionMode === CompletionMode.SHELL,
|
||||
line: buffer.lines[cursorRow] || '',
|
||||
cursorCol,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
});
|
||||
|
||||
const query =
|
||||
completionMode === CompletionMode.SHELL
|
||||
? shellCompletionRange.query
|
||||
: memoQuery;
|
||||
|
||||
const promptCompletion = usePromptCompletion({
|
||||
buffer,
|
||||
config,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -258,6 +289,9 @@ export function useCommandCompletion({
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
start = slashCompletionRange.completionStart;
|
||||
end = slashCompletionRange.completionEnd;
|
||||
} else if (completionMode === CompletionMode.SHELL) {
|
||||
start = shellCompletionRange.completionStart;
|
||||
end = shellCompletionRange.completionEnd;
|
||||
}
|
||||
|
||||
if (start === -1 || end === -1) {
|
||||
@@ -287,6 +321,7 @@ export function useCommandCompletion({
|
||||
completionStart,
|
||||
completionEnd,
|
||||
slashCompletionRange,
|
||||
shellCompletionRange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -307,6 +342,9 @@ export function useCommandCompletion({
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
start = slashCompletionRange.completionStart;
|
||||
end = slashCompletionRange.completionEnd;
|
||||
} else if (completionMode === CompletionMode.SHELL) {
|
||||
start = shellCompletionRange.completionStart;
|
||||
end = shellCompletionRange.completionEnd;
|
||||
}
|
||||
|
||||
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
|
||||
@@ -345,6 +383,7 @@ export function useCommandCompletion({
|
||||
completionStart,
|
||||
completionEnd,
|
||||
slashCompletionRange,
|
||||
shellCompletionRange,
|
||||
getCompletedText,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
getTokenAtCursor,
|
||||
escapeShellPath,
|
||||
resolvePathCompletions,
|
||||
scanPathExecutables,
|
||||
} from './useShellCompletion.js';
|
||||
import type { FileSystemStructure } from '@google/gemini-cli-test-utils';
|
||||
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
|
||||
describe('useShellCompletion utilities', () => {
|
||||
describe('getTokenAtCursor', () => {
|
||||
it('should return empty token struct for empty line', () => {
|
||||
expect(getTokenAtCursor('', 0)).toEqual({
|
||||
token: '',
|
||||
start: 0,
|
||||
end: 0,
|
||||
isFirstToken: true,
|
||||
tokens: [''],
|
||||
cursorIndex: 0,
|
||||
commandToken: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract the first token at cursor position 0', () => {
|
||||
const result = getTokenAtCursor('git status', 3);
|
||||
expect(result).toEqual({
|
||||
token: 'git',
|
||||
start: 0,
|
||||
end: 3,
|
||||
isFirstToken: true,
|
||||
tokens: ['git', 'status'],
|
||||
cursorIndex: 0,
|
||||
commandToken: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract the second token when cursor is on it', () => {
|
||||
const result = getTokenAtCursor('git status', 7);
|
||||
expect(result).toEqual({
|
||||
token: 'status',
|
||||
start: 4,
|
||||
end: 10,
|
||||
isFirstToken: false,
|
||||
tokens: ['git', 'status'],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cursor at start of second token', () => {
|
||||
const result = getTokenAtCursor('git status', 4);
|
||||
expect(result).toEqual({
|
||||
token: 'status',
|
||||
start: 4,
|
||||
end: 10,
|
||||
isFirstToken: false,
|
||||
tokens: ['git', 'status'],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle escaped spaces', () => {
|
||||
const result = getTokenAtCursor('cat my\\ file.txt', 16);
|
||||
expect(result).toEqual({
|
||||
token: 'my file.txt',
|
||||
start: 4,
|
||||
end: 16,
|
||||
isFirstToken: false,
|
||||
tokens: ['cat', 'my file.txt'],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'cat',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single-quoted strings', () => {
|
||||
const result = getTokenAtCursor("cat 'my file.txt'", 17);
|
||||
expect(result).toEqual({
|
||||
token: 'my file.txt',
|
||||
start: 4,
|
||||
end: 17,
|
||||
isFirstToken: false,
|
||||
tokens: ['cat', 'my file.txt'],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'cat',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle double-quoted strings', () => {
|
||||
const result = getTokenAtCursor('cat "my file.txt"', 17);
|
||||
expect(result).toEqual({
|
||||
token: 'my file.txt',
|
||||
start: 4,
|
||||
end: 17,
|
||||
isFirstToken: false,
|
||||
tokens: ['cat', 'my file.txt'],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'cat',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cursor past all tokens (trailing space)', () => {
|
||||
const result = getTokenAtCursor('git ', 4);
|
||||
expect(result).toEqual({
|
||||
token: '',
|
||||
start: 4,
|
||||
end: 4,
|
||||
isFirstToken: false,
|
||||
tokens: ['git', ''],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cursor in the middle of a word', () => {
|
||||
const result = getTokenAtCursor('git checkout main', 7);
|
||||
expect(result).toEqual({
|
||||
token: 'checkout',
|
||||
start: 4,
|
||||
end: 12,
|
||||
isFirstToken: false,
|
||||
tokens: ['git', 'checkout', 'main'],
|
||||
cursorIndex: 1,
|
||||
commandToken: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark isFirstToken correctly for first word', () => {
|
||||
const result = getTokenAtCursor('gi', 2);
|
||||
expect(result?.isFirstToken).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark isFirstToken correctly for second word', () => {
|
||||
const result = getTokenAtCursor('git sta', 7);
|
||||
expect(result?.isFirstToken).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeShellPath', () => {
|
||||
it('should escape spaces', () => {
|
||||
expect(escapeShellPath('my file.txt')).toBe('my\\ file.txt');
|
||||
});
|
||||
|
||||
it('should escape parentheses', () => {
|
||||
expect(escapeShellPath('file (copy).txt')).toBe('file\\ \\(copy\\).txt');
|
||||
});
|
||||
|
||||
it('should not escape normal characters', () => {
|
||||
expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt');
|
||||
});
|
||||
|
||||
it('should escape tabs, newlines, carriage returns, and backslashes', () => {
|
||||
expect(escapeShellPath('a\tb')).toBe('a\\\tb');
|
||||
expect(escapeShellPath('a\nb')).toBe('a\\\nb');
|
||||
expect(escapeShellPath('a\rb')).toBe('a\\\rb');
|
||||
expect(escapeShellPath('a\\b')).toBe('a\\\\b');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(escapeShellPath('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePathCompletions', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await cleanupTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should list directory contents for empty partial', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'file.txt': '',
|
||||
subdir: {},
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('', tmpDir);
|
||||
const values = results.map((s) => s.label);
|
||||
expect(values).toContain('subdir/');
|
||||
expect(values).toContain('file.txt');
|
||||
});
|
||||
|
||||
it('should filter by prefix', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'abc.txt': '',
|
||||
'def.txt': '',
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('a', tmpDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('abc.txt');
|
||||
});
|
||||
|
||||
it('should match case-insensitively', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
Desktop: {},
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('desk', tmpDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('Desktop/');
|
||||
});
|
||||
|
||||
it('should append trailing slash to directories', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
mydir: {},
|
||||
'myfile.txt': '',
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('my', tmpDir);
|
||||
const dirSuggestion = results.find((s) => s.label.startsWith('mydir'));
|
||||
expect(dirSuggestion?.label).toBe('mydir/');
|
||||
expect(dirSuggestion?.description).toBe('directory');
|
||||
});
|
||||
|
||||
it('should hide dotfiles by default', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'.hidden': '',
|
||||
visible: '',
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('', tmpDir);
|
||||
const labels = results.map((s) => s.label);
|
||||
expect(labels).not.toContain('.hidden');
|
||||
expect(labels).toContain('visible');
|
||||
});
|
||||
|
||||
it('should show dotfiles when query starts with a dot', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'.hidden': '',
|
||||
'.bashrc': '',
|
||||
visible: '',
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('.h', tmpDir);
|
||||
const labels = results.map((s) => s.label);
|
||||
expect(labels).toContain('.hidden');
|
||||
});
|
||||
|
||||
it('should show dotfiles in the current directory when query is exactly "."', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'.hidden': '',
|
||||
'.bashrc': '',
|
||||
visible: '',
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('.', tmpDir);
|
||||
const labels = results.map((s) => s.label);
|
||||
expect(labels).toContain('.hidden');
|
||||
expect(labels).toContain('.bashrc');
|
||||
expect(labels).not.toContain('visible');
|
||||
});
|
||||
|
||||
it('should handle dotfile completions within a subdirectory', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
subdir: {
|
||||
'.secret': '',
|
||||
'public.txt': '',
|
||||
},
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('subdir/.', tmpDir);
|
||||
const labels = results.map((s) => s.label);
|
||||
expect(labels).toContain('.secret');
|
||||
expect(labels).not.toContain('public.txt');
|
||||
});
|
||||
|
||||
it('should strip leading quotes to resolve inner directory contents', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
src: {
|
||||
'index.ts': '',
|
||||
},
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('"src/', tmpDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('index.ts');
|
||||
|
||||
const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir);
|
||||
expect(resultsSingleQuote).toHaveLength(1);
|
||||
expect(resultsSingleQuote[0].label).toBe('index.ts');
|
||||
});
|
||||
|
||||
it('should properly escape resolutions with spaces inside stripped quote queries', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'Folder With Spaces': {},
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('"Fo', tmpDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('Folder With Spaces/');
|
||||
expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/'));
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent directory', async () => {
|
||||
const results = await resolvePathCompletions(
|
||||
'/nonexistent/path/foo',
|
||||
'/tmp',
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle tilde expansion', async () => {
|
||||
// Just ensure ~ doesn't throw
|
||||
const results = await resolvePathCompletions('~/', '/tmp');
|
||||
// We can't assert specific files since it depends on the test runner's home
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should escape special characters in results', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'my file.txt': '',
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('my', tmpDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].value).toBe('my\\ file.txt');
|
||||
});
|
||||
|
||||
it('should sort directories before files', async () => {
|
||||
const structure: FileSystemStructure = {
|
||||
'b-file.txt': '',
|
||||
'a-dir': {},
|
||||
};
|
||||
tmpDir = await createTmpDir(structure);
|
||||
|
||||
const results = await resolvePathCompletions('', tmpDir);
|
||||
expect(results[0].description).toBe('directory');
|
||||
expect(results[1].description).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanPathExecutables', () => {
|
||||
it('should return an array of executables', async () => {
|
||||
const results = await scanPathExecutables();
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
// Very basic sanity check: common commands should be found
|
||||
if (process.platform !== 'win32') {
|
||||
expect(results).toContain('ls');
|
||||
} else {
|
||||
expect(results).toContain('dir');
|
||||
expect(results).toContain('cls');
|
||||
expect(results).toContain('copy');
|
||||
}
|
||||
});
|
||||
|
||||
it('should support abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const results = await scanPathExecutables(controller.signal);
|
||||
// May return empty or partial depending on timing
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty PATH', async () => {
|
||||
vi.stubEnv('PATH', '');
|
||||
const results = await scanPathExecutables();
|
||||
expect(results).toEqual([]);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getArgumentCompletions } from './shell-completions/index.js';
|
||||
|
||||
/**
|
||||
* Maximum number of suggestions to return to avoid freezing the React Ink UI.
|
||||
*/
|
||||
const MAX_SHELL_SUGGESTIONS = 100;
|
||||
|
||||
/**
|
||||
* Debounce interval (ms) for file system completions.
|
||||
*/
|
||||
const FS_COMPLETION_DEBOUNCE_MS = 50;
|
||||
|
||||
// Backslash-quote shell metacharacters on non-Windows platforms.
|
||||
|
||||
// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.).
|
||||
// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path
|
||||
// separator, so we leave the path as-is.
|
||||
const UNIX_SHELL_SPECIAL_CHARS = /[ \t\n\r'"()&|;<>!#$`{}[\]*?\\]/g;
|
||||
|
||||
/**
|
||||
* Escapes special shell characters in a path segment.
|
||||
*/
|
||||
export function escapeShellPath(segment: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
return segment;
|
||||
}
|
||||
return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&');
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
/** The raw token text (without surrounding quotes but with internal escapes). */
|
||||
token: string;
|
||||
/** Offset in the original line where this token begins. */
|
||||
start: number;
|
||||
/** Offset in the original line where this token ends (exclusive). */
|
||||
end: number;
|
||||
/** Whether this is the first token (command position). */
|
||||
isFirstToken: boolean;
|
||||
/** The fully built list of tokens parsing the string. */
|
||||
tokens: string[];
|
||||
/** The index in the tokens list where the cursor lies. */
|
||||
cursorIndex: number;
|
||||
/** The command token (always tokens[0] if length > 0, otherwise empty string) */
|
||||
commandToken: string;
|
||||
}
|
||||
|
||||
export function getTokenAtCursor(
|
||||
line: string,
|
||||
cursorCol: number,
|
||||
): TokenInfo | null {
|
||||
const tokensInfo: Array<{ token: string; start: number; end: number }> = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
// Skip whitespace
|
||||
if (line[i] === ' ' || line[i] === '\t') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenStart = i;
|
||||
let token = '';
|
||||
|
||||
while (i < line.length) {
|
||||
const ch = line[i];
|
||||
|
||||
// Backslash escape: consume the next char literally
|
||||
if (ch === '\\' && i + 1 < line.length) {
|
||||
token += line[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-quoted string
|
||||
if (ch === "'") {
|
||||
i++; // skip opening quote
|
||||
while (i < line.length && line[i] !== "'") {
|
||||
token += line[i];
|
||||
i++;
|
||||
}
|
||||
if (i < line.length) i++; // skip closing quote
|
||||
continue;
|
||||
}
|
||||
|
||||
// Double-quoted string
|
||||
if (ch === '"') {
|
||||
i++; // skip opening quote
|
||||
while (i < line.length && line[i] !== '"') {
|
||||
if (line[i] === '\\' && i + 1 < line.length) {
|
||||
token += line[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
token += line[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (i < line.length) i++; // skip closing quote
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unquoted whitespace ends the token
|
||||
if (ch === ' ' || ch === '\t') {
|
||||
break;
|
||||
}
|
||||
|
||||
token += ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
tokensInfo.push({ token, start: tokenStart, end: i });
|
||||
}
|
||||
|
||||
const rawTokens = tokensInfo.map((t) => t.token);
|
||||
const commandToken = rawTokens.length > 0 ? rawTokens[0] : '';
|
||||
|
||||
if (tokensInfo.length === 0) {
|
||||
return {
|
||||
token: '',
|
||||
start: cursorCol,
|
||||
end: cursorCol,
|
||||
isFirstToken: true,
|
||||
tokens: [''],
|
||||
cursorIndex: 0,
|
||||
commandToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the token that contains or is immediately adjacent to the cursor
|
||||
for (let idx = 0; idx < tokensInfo.length; idx++) {
|
||||
const t = tokensInfo[idx];
|
||||
if (cursorCol >= t.start && cursorCol <= t.end) {
|
||||
return {
|
||||
token: t.token,
|
||||
start: t.start,
|
||||
end: t.end,
|
||||
isFirstToken: idx === 0,
|
||||
tokens: rawTokens,
|
||||
cursorIndex: idx,
|
||||
commandToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor is past all tokens — treat as starting a new token at cursor position
|
||||
// (useful when the user types a space and then presses Tab)
|
||||
return {
|
||||
token: '',
|
||||
start: cursorCol,
|
||||
end: cursorCol,
|
||||
isFirstToken: tokensInfo.length === 0,
|
||||
tokens: [...rawTokens, ''],
|
||||
cursorIndex: rawTokens.length,
|
||||
commandToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanPathExecutables(
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const pathEnv = process.env['PATH'] ?? '';
|
||||
const dirs = pathEnv.split(path.delimiter).filter(Boolean);
|
||||
const isWindows = process.platform === 'win32';
|
||||
const pathExtList = isWindows
|
||||
? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((e) => e.toLowerCase())
|
||||
: [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const executables: string[] = [];
|
||||
|
||||
// Add Windows shell built-ins
|
||||
if (isWindows) {
|
||||
const builtins = [
|
||||
'assoc',
|
||||
'break',
|
||||
'call',
|
||||
'cd',
|
||||
'chcp',
|
||||
'chdir',
|
||||
'cls',
|
||||
'color',
|
||||
'copy',
|
||||
'date',
|
||||
'del',
|
||||
'dir',
|
||||
'echo',
|
||||
'endlocal',
|
||||
'erase',
|
||||
'exit',
|
||||
'for',
|
||||
'ftype',
|
||||
'goto',
|
||||
'if',
|
||||
'md',
|
||||
'mkdir',
|
||||
'mklink',
|
||||
'move',
|
||||
'path',
|
||||
'pause',
|
||||
'popd',
|
||||
'prompt',
|
||||
'pushd',
|
||||
'rd',
|
||||
'rem',
|
||||
'ren',
|
||||
'rename',
|
||||
'rmdir',
|
||||
'set',
|
||||
'setlocal',
|
||||
'shift',
|
||||
'start',
|
||||
'time',
|
||||
'title',
|
||||
'type',
|
||||
'ver',
|
||||
'verify',
|
||||
'vol',
|
||||
];
|
||||
for (const builtin of builtins) {
|
||||
seen.add(builtin);
|
||||
executables.push(builtin);
|
||||
}
|
||||
}
|
||||
|
||||
const dirResults = await Promise.all(
|
||||
dirs.map(async (dir) => {
|
||||
if (signal?.aborted) return [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const validEntries: string[] = [];
|
||||
|
||||
// Check executability in parallel (batched per directory)
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
if (signal?.aborted) return;
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) return;
|
||||
|
||||
const name = entry.name;
|
||||
if (isWindows) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (pathExtList.length > 0 && !pathExtList.includes(ext)) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(
|
||||
path.join(dir, name),
|
||||
fs.constants.R_OK | fs.constants.X_OK,
|
||||
);
|
||||
validEntries.push(name);
|
||||
} catch {
|
||||
// Not executable — skip
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return validEntries;
|
||||
} catch {
|
||||
// EACCES, ENOENT, etc. — skip this directory
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const names of dirResults) {
|
||||
for (const name of names) {
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
executables.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executables.sort();
|
||||
return executables;
|
||||
}
|
||||
|
||||
function expandTilde(inputPath: string): [string, boolean] {
|
||||
if (
|
||||
inputPath === '~' ||
|
||||
inputPath.startsWith('~/') ||
|
||||
inputPath.startsWith('~' + path.sep)
|
||||
) {
|
||||
return [path.join(os.homedir(), inputPath.slice(1)), true];
|
||||
}
|
||||
return [inputPath, false];
|
||||
}
|
||||
|
||||
export async function resolvePathCompletions(
|
||||
partial: string,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Suggestion[]> {
|
||||
if (partial == null) return [];
|
||||
|
||||
// Input Sanitization
|
||||
let strippedPartial = partial;
|
||||
if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) {
|
||||
strippedPartial = strippedPartial.slice(1);
|
||||
}
|
||||
if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) {
|
||||
strippedPartial = strippedPartial.slice(0, -1);
|
||||
}
|
||||
|
||||
// Normalize separators \ to /
|
||||
const normalizedPartial = strippedPartial.replace(/\\/g, '/');
|
||||
|
||||
const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial);
|
||||
|
||||
// Directory Detection
|
||||
const endsWithSep =
|
||||
normalizedPartial.endsWith('/') || normalizedPartial === '';
|
||||
const dirToRead = endsWithSep
|
||||
? path.resolve(cwd, expandedPartial)
|
||||
: path.resolve(cwd, path.dirname(expandedPartial));
|
||||
|
||||
const prefix = endsWithSep ? '' : path.basename(expandedPartial);
|
||||
const prefixLower = prefix.toLowerCase();
|
||||
|
||||
const showDotfiles = prefix.startsWith('.');
|
||||
|
||||
let entries: Array<import('node:fs').Dirent>;
|
||||
try {
|
||||
if (signal?.aborted) return [];
|
||||
entries = await fs.readdir(dirToRead, { withFileTypes: true });
|
||||
} catch {
|
||||
// EACCES, ENOENT, etc.
|
||||
return [];
|
||||
}
|
||||
|
||||
if (signal?.aborted) return [];
|
||||
|
||||
const suggestions: Suggestion[] = [];
|
||||
for (const entry of entries) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const name = entry.name;
|
||||
|
||||
// Hide dotfiles unless query starts with '.'
|
||||
if (name.startsWith('.') && !showDotfiles) continue;
|
||||
|
||||
// Case-insensitive matching
|
||||
if (!name.toLowerCase().startsWith(prefixLower)) continue;
|
||||
|
||||
const isDir = entry.isDirectory();
|
||||
const displayName = isDir ? name + '/' : name;
|
||||
|
||||
// Build the completion value relative to what the user typed
|
||||
let completionValue: string;
|
||||
if (endsWithSep) {
|
||||
completionValue = normalizedPartial + displayName;
|
||||
} else {
|
||||
const parentPart = normalizedPartial.slice(
|
||||
0,
|
||||
normalizedPartial.length - path.basename(normalizedPartial).length,
|
||||
);
|
||||
completionValue = parentPart + displayName;
|
||||
}
|
||||
|
||||
// Restore tilde if we expanded it
|
||||
if (didExpandTilde) {
|
||||
const homeDir = os.homedir().replace(/\\/g, '/');
|
||||
if (completionValue.startsWith(homeDir)) {
|
||||
completionValue = '~' + completionValue.slice(homeDir.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Output formatting: Escape special characters in the completion value
|
||||
// Since normalizedPartial stripped quotes, we escape the value directly.
|
||||
const escapedValue = escapeShellPath(completionValue);
|
||||
|
||||
suggestions.push({
|
||||
label: displayName,
|
||||
value: escapedValue,
|
||||
description: isDir ? 'directory' : 'file',
|
||||
});
|
||||
|
||||
if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
suggestions.sort((a, b) => {
|
||||
const aIsDir = a.description === 'directory';
|
||||
const bIsDir = b.description === 'directory';
|
||||
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export interface UseShellCompletionProps {
|
||||
/** Whether shell completion is active. */
|
||||
enabled: boolean;
|
||||
/** The current line text. */
|
||||
line: string;
|
||||
/** The current cursor column. */
|
||||
cursorCol: number;
|
||||
/** The current working directory for path resolution. */
|
||||
cwd: string;
|
||||
/** Callback to set suggestions on the parent state. */
|
||||
setSuggestions: (suggestions: Suggestion[]) => void;
|
||||
/** Callback to set loading state on the parent. */
|
||||
setIsLoadingSuggestions: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseShellCompletionReturn {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function useShellCompletion({
|
||||
enabled,
|
||||
line,
|
||||
cursorCol,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
}: UseShellCompletionProps): UseShellCompletionReturn {
|
||||
const pathCachePromiseRef = useRef<Promise<string[]> | null>(null);
|
||||
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const tokenInfo = useMemo(
|
||||
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
|
||||
[enabled, line, cursorCol],
|
||||
);
|
||||
|
||||
const {
|
||||
token: query = '',
|
||||
start: completionStart = -1,
|
||||
end: completionEnd = -1,
|
||||
isFirstToken: isCommandPosition = false,
|
||||
tokens = [],
|
||||
cursorIndex = -1,
|
||||
commandToken = '',
|
||||
} = tokenInfo || {};
|
||||
|
||||
// Invalidate PATH cache when $PATH changes
|
||||
useEffect(() => {
|
||||
const currentPath = process.env['PATH'] ?? '';
|
||||
if (currentPath !== pathEnvRef.current) {
|
||||
pathCachePromiseRef.current = null;
|
||||
pathEnvRef.current = currentPath;
|
||||
}
|
||||
});
|
||||
|
||||
const performCompletion = useCallback(async () => {
|
||||
if (!enabled || !tokenInfo) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip flags
|
||||
if (query.startsWith('-')) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const { signal } = controller;
|
||||
|
||||
try {
|
||||
let results: Suggestion[];
|
||||
|
||||
if (isCommandPosition) {
|
||||
setIsLoadingSuggestions(true);
|
||||
|
||||
if (!pathCachePromiseRef.current) {
|
||||
// We don't pass the signal here because we want the cache to finish
|
||||
// even if this specific completion request is aborted.
|
||||
pathCachePromiseRef.current = scanPathExecutables();
|
||||
}
|
||||
|
||||
const executables = await pathCachePromiseRef.current;
|
||||
if (signal.aborted) return;
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
results = executables
|
||||
.filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
|
||||
.sort((a, b) => {
|
||||
// Prioritize shorter commands as they are likely common built-ins
|
||||
if (a.length !== b.length) {
|
||||
return a.length - b.length;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.slice(0, MAX_SHELL_SUGGESTIONS)
|
||||
.map((cmd) => ({
|
||||
label: cmd,
|
||||
value: escapeShellPath(cmd),
|
||||
description: 'command',
|
||||
}));
|
||||
} else {
|
||||
const argumentCompletions = await getArgumentCompletions(
|
||||
commandToken,
|
||||
tokens,
|
||||
cursorIndex,
|
||||
cwd,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (argumentCompletions?.exclusive) {
|
||||
results = argumentCompletions.suggestions;
|
||||
} else {
|
||||
const pathSuggestions = await resolvePathCompletions(
|
||||
query,
|
||||
cwd,
|
||||
signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
|
||||
results = [
|
||||
...(argumentCompletions?.suggestions ?? []),
|
||||
...pathSuggestions,
|
||||
].slice(0, MAX_SHELL_SUGGESTIONS);
|
||||
}
|
||||
}
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
setSuggestions(results);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
signal.aborted ||
|
||||
(error instanceof Error && error.name === 'AbortError')
|
||||
)
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setSuggestions([]);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
tokenInfo,
|
||||
query,
|
||||
isCommandPosition,
|
||||
tokens,
|
||||
cursorIndex,
|
||||
commandToken,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSuggestions([]);
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
|
||||
// Debounced effect to trigger completion
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
performCompletion();
|
||||
}, FS_COMPLETION_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, performCompletion]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
completionStart,
|
||||
completionEnd,
|
||||
query,
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,24 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
CoreToolCallStatus: {
|
||||
Validating: 'validating',
|
||||
Scheduled: 'scheduled',
|
||||
Error: 'error',
|
||||
Success: 'success',
|
||||
Executing: 'executing',
|
||||
Cancelled: 'cancelled',
|
||||
AwaitingApproval: 'awaiting_approval',
|
||||
},
|
||||
LlmRole: {
|
||||
MAIN: 'main',
|
||||
SUBAGENT: 'subagent',
|
||||
UTILITY_TOOL: 'utility_tool',
|
||||
USER: 'user',
|
||||
MODEL: 'model',
|
||||
SYSTEM: 'system',
|
||||
TOOL: 'tool',
|
||||
},
|
||||
convertSessionToClientHistory: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -256,6 +274,7 @@ describe('GeminiAgent Session Resume', () => {
|
||||
toolCallId: 'call-2',
|
||||
status: 'failed',
|
||||
title: 'Write File',
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -73,7 +73,7 @@ vi.mock(
|
||||
...actual,
|
||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'native',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
@@ -84,6 +84,28 @@ vi.mock(
|
||||
})),
|
||||
logToolCall: vi.fn(),
|
||||
isWithinRoot: vi.fn().mockReturnValue(true),
|
||||
LlmRole: {
|
||||
MAIN: 'main',
|
||||
SUBAGENT: 'subagent',
|
||||
UTILITY_TOOL: 'utility_tool',
|
||||
UTILITY_COMPRESSOR: 'utility_compressor',
|
||||
UTILITY_SUMMARIZER: 'utility_summarizer',
|
||||
UTILITY_ROUTER: 'utility_router',
|
||||
UTILITY_LOOP_DETECTOR: 'utility_loop_detector',
|
||||
UTILITY_NEXT_SPEAKER: 'utility_next_speaker',
|
||||
UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector',
|
||||
UTILITY_AUTOCOMPLETE: 'utility_autocomplete',
|
||||
UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper',
|
||||
},
|
||||
CoreToolCallStatus: {
|
||||
Validating: 'validating',
|
||||
Scheduled: 'scheduled',
|
||||
Error: 'error',
|
||||
Success: 'success',
|
||||
Executing: 'executing',
|
||||
Cancelled: 'cancelled',
|
||||
AwaitingApproval: 'awaiting_approval',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -406,7 +428,7 @@ describe('Session', () => {
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'native',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
@@ -511,6 +533,7 @@ describe('Session', () => {
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
status: 'in_progress',
|
||||
kind: 'read',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -632,6 +655,92 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include _meta.kind in diff tool calls', async () => {
|
||||
// Test 'add' (no original content)
|
||||
const addConfirmation = {
|
||||
type: 'edit',
|
||||
fileName: 'new.txt',
|
||||
originalContent: null,
|
||||
newContent: 'New content',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
// Test 'modify' (original and new content)
|
||||
const modifyConfirmation = {
|
||||
type: 'edit',
|
||||
fileName: 'existing.txt',
|
||||
originalContent: 'Old content',
|
||||
newContent: 'New content',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
// Test 'delete' (original content, no new content)
|
||||
const deleteConfirmation = {
|
||||
type: 'edit',
|
||||
fileName: 'deleted.txt',
|
||||
originalContent: 'Old content',
|
||||
newContent: '',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const mockBuild = vi.fn();
|
||||
mockTool.build = mockBuild;
|
||||
|
||||
// Helper to simulate tool call and check permission request
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const checkDiffKind = async (confirmation: any, expectedKind: string) => {
|
||||
mockBuild.mockReturnValueOnce({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmation),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const emptyStream = createMockStream([]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream)
|
||||
.mockResolvedValueOnce(emptyStream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolCall: expect.objectContaining({
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'diff',
|
||||
_meta: { kind: expectedKind },
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await checkDiffKind(addConfirmation, 'add');
|
||||
await checkDiffKind(modifyConfirmation, 'modify');
|
||||
await checkDiffKind(deleteConfirmation, 'delete');
|
||||
});
|
||||
|
||||
it('should handle @path resolution', async () => {
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
|
||||
@@ -682,6 +682,13 @@ export class Session {
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
_meta: {
|
||||
kind: !confirmationDetails.originalContent
|
||||
? 'add'
|
||||
: confirmationDetails.newContent === ''
|
||||
? 'delete'
|
||||
: 'modify',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1203,6 +1210,13 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
path: toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
_meta: {
|
||||
kind: !toolResult.returnDisplay.originalContent
|
||||
? 'add'
|
||||
: toolResult.returnDisplay.newContent === ''
|
||||
? 'delete'
|
||||
: 'modify',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -1291,14 +1305,16 @@ function toAcpToolKind(kind: Kind): acp.ToolKind {
|
||||
switch (kind) {
|
||||
case Kind.Read:
|
||||
case Kind.Edit:
|
||||
case Kind.Execute:
|
||||
case Kind.Search:
|
||||
case Kind.Delete:
|
||||
case Kind.Move:
|
||||
case Kind.Search:
|
||||
case Kind.Execute:
|
||||
case Kind.Think:
|
||||
case Kind.Fetch:
|
||||
case Kind.SwitchMode:
|
||||
case Kind.Other:
|
||||
return kind as acp.ToolKind;
|
||||
case Kind.Plan:
|
||||
case Kind.Communicate:
|
||||
default:
|
||||
return 'other';
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from './policyCatalog.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
@@ -22,6 +24,27 @@ describe('policyCatalog', () => {
|
||||
expect(chain).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns Gemini 3.1 chain when useGemini31 is true', () => {
|
||||
const chain = getModelPolicyChain({
|
||||
previewEnabled: true,
|
||||
useGemini31: true,
|
||||
});
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('returns Gemini 3.1 Custom Tools chain when useGemini31 and useCustomToolModel are true', () => {
|
||||
const chain = getModelPolicyChain({
|
||||
previewEnabled: true,
|
||||
useGemini31: true,
|
||||
useCustomToolModel: true,
|
||||
});
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('returns default chain when preview disabled', () => {
|
||||
const chain = getModelPolicyChain({ previewEnabled: false });
|
||||
expect(chain[0]?.model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
|
||||
@@ -28,6 +29,8 @@ type PolicyConfig = Omit<ModelPolicy, 'actions' | 'stateTransitions'> & {
|
||||
export interface ModelPolicyOptions {
|
||||
previewEnabled: boolean;
|
||||
userTier?: UserTierId;
|
||||
useGemini31?: boolean;
|
||||
useCustomToolModel?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIONS: ModelPolicyActionMap = {
|
||||
@@ -56,11 +59,6 @@ const DEFAULT_CHAIN: ModelPolicyChain = [
|
||||
definePolicy({ model: DEFAULT_GEMINI_FLASH_MODEL, isLastResort: true }),
|
||||
];
|
||||
|
||||
const PREVIEW_CHAIN: ModelPolicyChain = [
|
||||
definePolicy({ model: PREVIEW_GEMINI_MODEL }),
|
||||
definePolicy({ model: PREVIEW_GEMINI_FLASH_MODEL, isLastResort: true }),
|
||||
];
|
||||
|
||||
const FLASH_LITE_CHAIN: ModelPolicyChain = [
|
||||
definePolicy({
|
||||
model: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
@@ -84,7 +82,15 @@ export function getModelPolicyChain(
|
||||
options: ModelPolicyOptions,
|
||||
): ModelPolicyChain {
|
||||
if (options.previewEnabled) {
|
||||
return cloneChain(PREVIEW_CHAIN);
|
||||
const previewModel = resolveModel(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
options.useGemini31,
|
||||
options.useCustomToolModel,
|
||||
);
|
||||
return [
|
||||
definePolicy({ model: previewModel }),
|
||||
definePolicy({ model: PREVIEW_GEMINI_FLASH_MODEL, isLastResort: true }),
|
||||
];
|
||||
}
|
||||
|
||||
return cloneChain(DEFAULT_CHAIN);
|
||||
|
||||
@@ -15,12 +15,17 @@ import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
|
||||
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
getUserTier: () => undefined,
|
||||
getModel: () => 'gemini-2.5-pro',
|
||||
getGemini31LaunchedSync: () => false,
|
||||
getContentGeneratorConfig: () => ({ authType: undefined }),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
@@ -128,6 +133,27 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-pro');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('returns Gemini 3.1 Pro chain when launched and auto-gemini-3 requested', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'auto-gemini-3',
|
||||
getGemini31LaunchedSync: () => true,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
|
||||
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('returns Gemini 3.1 Pro Custom Tools chain when launched, auth is Gemini, and auto-gemini-3 requested', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'auto-gemini-3',
|
||||
getGemini31LaunchedSync: () => true,
|
||||
getContentGeneratorConfig: () => ({ authType: AuthType.USE_GEMINI }),
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFallbackPolicyContext', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { GenerateContentConfig } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import type {
|
||||
FailureKind,
|
||||
FallbackAction,
|
||||
@@ -44,9 +45,15 @@ export function resolvePolicyChain(
|
||||
const configuredModel = config.getModel();
|
||||
|
||||
let chain;
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useCustomToolModel =
|
||||
useGemini31 &&
|
||||
config.getContentGeneratorConfig?.()?.authType === AuthType.USE_GEMINI;
|
||||
|
||||
const resolvedModel = resolveModel(
|
||||
modelFromConfig,
|
||||
config.getGemini31LaunchedSync?.() ?? false,
|
||||
useGemini31,
|
||||
useCustomToolModel,
|
||||
);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
@@ -67,6 +74,8 @@ export function resolvePolicyChain(
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useCustomToolModel,
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
@@ -74,6 +83,8 @@ export function resolvePolicyChain(
|
||||
return getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useCustomToolModel,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -936,6 +936,70 @@ describe('oauth2', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unexpected requests (like /favicon.ico) without crashing', async () => {
|
||||
const mockAuthUrl = 'https://example.com/auth';
|
||||
const mockOAuth2Client = {
|
||||
generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl),
|
||||
on: vi.fn(),
|
||||
} as unknown as OAuth2Client;
|
||||
vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client);
|
||||
|
||||
vi.mocked(open).mockImplementation(
|
||||
async () => ({ on: vi.fn() }) as never,
|
||||
);
|
||||
|
||||
let requestCallback!: http.RequestListener;
|
||||
let serverListeningCallback: (value: unknown) => void;
|
||||
const serverListeningPromise = new Promise(
|
||||
(resolve) => (serverListeningCallback = resolve),
|
||||
);
|
||||
|
||||
const mockHttpServer = {
|
||||
listen: vi.fn(
|
||||
(_port: number, _host: string, callback?: () => void) => {
|
||||
if (callback) callback();
|
||||
serverListeningCallback(undefined);
|
||||
},
|
||||
),
|
||||
close: vi.fn(),
|
||||
on: vi.fn(),
|
||||
address: () => ({ port: 3000 }),
|
||||
};
|
||||
(http.createServer as Mock).mockImplementation((cb) => {
|
||||
requestCallback = cb;
|
||||
return mockHttpServer as unknown as http.Server;
|
||||
});
|
||||
|
||||
const clientPromise = getOauthClient(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
mockConfig,
|
||||
);
|
||||
await serverListeningPromise;
|
||||
|
||||
// Simulate an unexpected request, like a browser requesting a favicon
|
||||
const mockReq = {
|
||||
url: '/favicon.ico',
|
||||
} as http.IncomingMessage;
|
||||
const mockRes = {
|
||||
writeHead: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as http.ServerResponse;
|
||||
|
||||
await expect(async () => {
|
||||
requestCallback(mockReq, mockRes);
|
||||
await clientPromise;
|
||||
}).rejects.toThrow(
|
||||
'OAuth callback not received. Unexpected request: /favicon.ico',
|
||||
);
|
||||
|
||||
// Assert that we correctly redirected to the failure page
|
||||
expect(mockRes.writeHead).toHaveBeenCalledWith(301, {
|
||||
Location:
|
||||
'https://developers.google.com/gemini-code-assist/auth_failure_gemini',
|
||||
});
|
||||
expect(mockRes.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token exchange failure with descriptive error', async () => {
|
||||
const mockAuthUrl = 'https://example.com/auth';
|
||||
const mockCode = 'test-code';
|
||||
|
||||
@@ -490,6 +490,7 @@ async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
|
||||
'OAuth callback not received. Unexpected request: ' + req.url,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// acquire the code from the querystring, and close the web server.
|
||||
const qs = new url.URL(req.url!, 'http://127.0.0.1:3000').searchParams;
|
||||
|
||||
@@ -261,7 +261,10 @@ describe('Turn', () => {
|
||||
const errorEvent = events[0] as ServerGeminiErrorEvent;
|
||||
expect(errorEvent.type).toBe(GeminiEventType.Error);
|
||||
expect(errorEvent.value).toEqual({
|
||||
error: { message: 'API Error', status: undefined },
|
||||
error: {
|
||||
message: 'API Error',
|
||||
status: undefined,
|
||||
},
|
||||
});
|
||||
expect(turn.getDebugResponses().length).toBe(0);
|
||||
expect(reportError).toHaveBeenCalledWith(
|
||||
|
||||
@@ -116,7 +116,7 @@ export interface StructuredError {
|
||||
}
|
||||
|
||||
export interface GeminiErrorEventValue {
|
||||
error: StructuredError;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface GeminiFinishedEventValue {
|
||||
|
||||
@@ -48,13 +48,13 @@ decision = "ask_user"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
# Allow write_file and replace for .md files in plans directory
|
||||
# Allow write_file and replace for .md files in the plans directory (cross-platform)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\""
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
|
||||
# Explicitly Deny other write operations in Plan mode with a clear message.
|
||||
[[rule]]
|
||||
|
||||
@@ -103,19 +103,6 @@ describe('AskUserTool', () => {
|
||||
expect(result).toContain("must have required property 'header'");
|
||||
});
|
||||
|
||||
it('should return error if header exceeds max length', () => {
|
||||
const result = tool.validateToolParams({
|
||||
questions: [
|
||||
{
|
||||
question: 'Test?',
|
||||
header: 'This is way too long',
|
||||
type: QuestionType.CHOICE,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toContain('must NOT have more than 16 characters');
|
||||
});
|
||||
|
||||
it('should return error if options has fewer than 2 items', () => {
|
||||
const result = tool.validateToolParams({
|
||||
questions: [
|
||||
@@ -276,13 +263,7 @@ describe('AskUserTool', () => {
|
||||
describe('validateBuildAndExecute', () => {
|
||||
it('should hide validation errors from returnDisplay', async () => {
|
||||
const params = {
|
||||
questions: [
|
||||
{
|
||||
question: 'Test?',
|
||||
header: 'This is way too long',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
const result = await tool.validateBuildAndExecute(
|
||||
|
||||
+2
-4
@@ -80,8 +80,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
|
||||
"items": {
|
||||
"properties": {
|
||||
"header": {
|
||||
"description": "MUST be 16 characters or fewer or the call will fail. Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".",
|
||||
"maxLength": 16,
|
||||
"description": "Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".",
|
||||
"type": "string",
|
||||
},
|
||||
"multiSelect": {
|
||||
@@ -869,8 +868,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
"items": {
|
||||
"properties": {
|
||||
"header": {
|
||||
"description": "MUST be 16 characters or fewer or the call will fail. Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".",
|
||||
"maxLength": 16,
|
||||
"description": "Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".",
|
||||
"type": "string",
|
||||
},
|
||||
"multiSelect": {
|
||||
|
||||
@@ -609,9 +609,8 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
},
|
||||
header: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
description:
|
||||
'MUST be 16 characters or fewer or the call will fail. Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".',
|
||||
'Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
|
||||
@@ -587,9 +587,8 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
},
|
||||
header: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
description:
|
||||
'MUST be 16 characters or fewer or the call will fail. Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".',
|
||||
'Very short label displayed as a chip/tag. Use abbreviations: "Auth" not "Authentication", "Config" not "Configuration". Examples: "Auth method", "Library", "Approach", "Database".',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
|
||||
@@ -381,6 +381,36 @@ describe('McpClientManager', () => {
|
||||
expect(manager.getMcpServers()).not.toHaveProperty('test-server');
|
||||
});
|
||||
|
||||
it('should ignore an extension attempting to register a server with an existing name', async () => {
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
const userConfig = { command: 'node', args: ['user-server.js'] };
|
||||
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
'test-server': userConfig,
|
||||
});
|
||||
mockedMcpClient.getServerConfig.mockReturnValue(userConfig);
|
||||
|
||||
await manager.startConfiguredMcpServers();
|
||||
expect(mockedMcpClient.connect).toHaveBeenCalledTimes(1);
|
||||
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'test-extension',
|
||||
mcpServers: {
|
||||
'test-server': { command: 'node', args: ['ext-server.js'] },
|
||||
},
|
||||
isActive: true,
|
||||
version: '1.0.0',
|
||||
path: '/some-path',
|
||||
contextFiles: [],
|
||||
id: '123',
|
||||
};
|
||||
|
||||
await manager.startExtension(extension);
|
||||
|
||||
expect(mockedMcpClient.disconnect).not.toHaveBeenCalled();
|
||||
expect(mockedMcpClient.connect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should remove servers from blockedMcpServers when stopExtension is called', async () => {
|
||||
mockConfig.getBlockedMcpServers.mockReturnValue(['blocked-server']);
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
|
||||
@@ -176,6 +176,20 @@ export class McpClientManager {
|
||||
name: string,
|
||||
config: MCPServerConfig,
|
||||
): Promise<void> {
|
||||
const existing = this.clients.get(name);
|
||||
if (
|
||||
existing &&
|
||||
existing.getServerConfig().extension?.id !== config.extension?.id
|
||||
) {
|
||||
const extensionText = config.extension
|
||||
? ` from extension "${config.extension.name}"`
|
||||
: '';
|
||||
debugLogger.warn(
|
||||
`Skipping MCP config for server with name "${name}"${extensionText} as it already exists.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always track server config for UI display
|
||||
this.allServerConfigs.set(name, config);
|
||||
|
||||
@@ -191,7 +205,6 @@ export class McpClientManager {
|
||||
}
|
||||
// User-disabled servers: disconnect if running, don't start
|
||||
if (await this.isDisabledByUser(name)) {
|
||||
const existing = this.clients.get(name);
|
||||
if (existing) {
|
||||
await this.disconnectClient(name);
|
||||
}
|
||||
@@ -203,16 +216,6 @@ export class McpClientManager {
|
||||
if (config.extension && !config.extension.isActive) {
|
||||
return;
|
||||
}
|
||||
const existing = this.clients.get(name);
|
||||
if (existing && existing.getServerConfig().extension !== config.extension) {
|
||||
const extensionText = config.extension
|
||||
? ` from extension "${config.extension.name}"`
|
||||
: '';
|
||||
debugLogger.warn(
|
||||
`Skipping MCP config for server with name "${name}"${extensionText} as it already exists.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDiscoveryPromise = new Promise<void>((resolve, reject) => {
|
||||
(async () => {
|
||||
|
||||
@@ -817,6 +817,7 @@ export enum Kind {
|
||||
Fetch = 'fetch',
|
||||
Communicate = 'communicate',
|
||||
Plan = 'plan',
|
||||
SwitchMode = 'switch_mode',
|
||||
Other = 'other',
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -541,7 +541,13 @@ describe('retryWithBackoff', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await assertionPromise;
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 12345);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.any(Number),
|
||||
);
|
||||
const calledDelayMs = setTimeoutSpy.mock.calls[0][1];
|
||||
expect(calledDelayMs).toBeGreaterThanOrEqual(12345);
|
||||
expect(calledDelayMs).toBeLessThanOrEqual(12345 * 1.2);
|
||||
});
|
||||
|
||||
it.each([[AuthType.USE_GEMINI], [AuthType.USE_VERTEX_AI], [undefined]])(
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
import type { RetryAvailabilityContext } from '../availability/modelPolicy.js';
|
||||
|
||||
export type { RetryAvailabilityContext };
|
||||
export const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
export const DEFAULT_MAX_ATTEMPTS = 10;
|
||||
|
||||
export interface RetryOptions {
|
||||
maxAttempts: number;
|
||||
@@ -302,13 +302,18 @@ export async function retryWithBackoff<T>(
|
||||
classifiedError instanceof RetryableQuotaError &&
|
||||
classifiedError.retryDelayMs !== undefined
|
||||
) {
|
||||
currentDelay = Math.max(currentDelay, classifiedError.retryDelayMs);
|
||||
// Positive jitter up to +20% while respecting server minimum delay
|
||||
const jitter = currentDelay * 0.2 * Math.random();
|
||||
const delayWithJitter = currentDelay + jitter;
|
||||
debugLogger.warn(
|
||||
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
|
||||
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${Math.round(delayWithJitter)}ms...`,
|
||||
);
|
||||
if (onRetry) {
|
||||
onRetry(attempt, error, classifiedError.retryDelayMs);
|
||||
onRetry(attempt, error, delayWithJitter);
|
||||
}
|
||||
await delay(classifiedError.retryDelayMs, signal);
|
||||
await delay(delayWithJitter, signal);
|
||||
currentDelay = Math.min(maxDelayMs, currentDelay * 2);
|
||||
continue;
|
||||
} else {
|
||||
const errorStatus = getErrorStatus(error);
|
||||
|
||||
@@ -73,4 +73,26 @@ if (existsSync(builtinSkillsSrc)) {
|
||||
console.log('Copied built-in skills to bundle/builtin/');
|
||||
}
|
||||
|
||||
// 5. Copy DevTools package so the external dynamic import resolves at runtime
|
||||
const devtoolsSrc = join(root, 'packages/devtools');
|
||||
const devtoolsDest = join(
|
||||
bundleDir,
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli-devtools',
|
||||
);
|
||||
const devtoolsDistSrc = join(devtoolsSrc, 'dist');
|
||||
if (existsSync(devtoolsDistSrc)) {
|
||||
mkdirSync(devtoolsDest, { recursive: true });
|
||||
cpSync(devtoolsDistSrc, join(devtoolsDest, 'dist'), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
copyFileSync(
|
||||
join(devtoolsSrc, 'package.json'),
|
||||
join(devtoolsDest, 'package.json'),
|
||||
);
|
||||
console.log('Copied devtools package to bundle/node_modules/');
|
||||
}
|
||||
|
||||
console.log('Assets copied to bundle/');
|
||||
|
||||
Reference in New Issue
Block a user